forked from PathOfBuildingCommunity/PathOfBuilding-PoE2
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathBuildExportPoE2.lua
More file actions
577 lines (542 loc) · 21.2 KB
/
Copy pathBuildExportPoE2.lua
File metadata and controls
577 lines (542 loc) · 21.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
-- Path of Building
--
-- Module: Build Export (Path of Exile 2 BuildPlanner)
-- Serialises the current build into a .build JSON file the in-game
-- BuildPlanner can load. See: https://www.pathofexile.com/developer/docs/game
--
local ipairs = ipairs
local pairs = pairs
local tostring = tostring
local tonumber = tonumber
local t_insert = table.insert
local t_concat = table.concat
local m_floor = math.floor
local m_min = math.min
local m_max = math.max
local s_format = string.format
local s_gsub = string.gsub
local dkjson = require "dkjson"
local M = {}
-- PoB internal slot name -> { inventory_id, weapon_set? }
-- inventory_id values derived from GGG's .build format documentation example
-- (https://www.pathofexile.com/developer/docs/game#buildplanner).
-- Only "Weapon1" is explicitly documented; others match the example output.
M.SlotMap = {
["Weapon 1"] = { inventory_id = "Weapon1" },
["Weapon 2"] = { inventory_id = "Weapon2" },
["Weapon 1 Swap"] = { inventory_id = "Weapon1", weapon_set = 2 },
["Weapon 2 Swap"] = { inventory_id = "Weapon2", weapon_set = 2 },
["Helmet"] = { inventory_id = "Helm" },
["Body Armour"] = { inventory_id = "BodyArmour" },
["Gloves"] = { inventory_id = "Gloves" },
["Boots"] = { inventory_id = "Boots" },
["Amulet"] = { inventory_id = "Amulet" },
["Ring 1"] = { inventory_id = "Ring" },
["Ring 2"] = { inventory_id = "Ring2" },
["Ring 3"] = { inventory_id = "Ring3" },
["Belt"] = { inventory_id = "Belt" },
["Charm 1"] = { inventory_id = "Charm1" },
["Charm 2"] = { inventory_id = "Charm2" },
["Charm 3"] = { inventory_id = "Charm3" },
["Flask 1"] = { inventory_id = "Flask1" },
["Flask 2"] = { inventory_id = "Flask2" },
}
-- Default level brackets when the user hasn't set levelMin/Max on a set.
-- Indexed by total number of sets; falls back to a uniform split otherwise.
local presetBrackets = {
[2] = { {1, 50}, {50, 100} },
[3] = { {1, 33}, {33, 66}, {66, 100} },
[4] = { {1, 30}, {30, 60}, {60, 90}, {90, 100} },
}
function M.ClampLevel(v)
v = tonumber(v)
if not v then return nil end
v = m_floor(v)
if v < 0 then v = 0 end
if v > 100 then v = 100 end
return v
end
local function autoBracket(i, n)
if presetBrackets[n] then
return presetBrackets[n][i]
end
local lo = (i == 1) and 1 or (m_floor((i - 1) / n * 99) + 1)
local hi = (i == n) and 100 or m_max(lo, m_floor(i / n * 99))
return { lo, hi }
end
-- bracketsFor(orderList, getEntry) -> { [orderIndex] = {min, max} | nil, ... } | nil
-- Returns nil when there's only one set (caller should omit level_interval).
-- When some sets are explicitly tagged and others aren't, the untagged ones
-- intentionally return nil (no level_interval) — the loader treats them as
-- "applies at all levels", which is what a build-author usually means when
-- they tag a leveling loadout but leave the main build untagged.
local function bracketsFor(orderList, getEntry)
local n = orderList and #orderList or 0
if n <= 1 then return nil end
local hasAnyExplicit = false
for _, id in ipairs(orderList) do
local entry = getEntry(id)
if entry and entry.levelMin and entry.levelMax then
hasAnyExplicit = true
break
end
end
local out = {}
for i, id in ipairs(orderList) do
local entry = getEntry(id)
local lo = entry and M.ClampLevel(entry.levelMin)
local hi = entry and M.ClampLevel(entry.levelMax)
if lo and hi then
if lo > hi then lo, hi = hi, lo end
out[i] = { lo, hi }
elseif hasAnyExplicit then
out[i] = nil
else
out[i] = autoBracket(i, n)
end
end
return out
end
local function safeFilename(name)
name = (name and name ~= "") and name or "Unnamed"
name = s_gsub(name, "[\\/:%*%?\"<>|%c]", "-")
return name
end
--- Compute a default (lo, hi) for a new loadout: the next 30-level chunk after
--- the highest existing levelMax found across tree specs, item sets and skill sets.
--- Returns (1, 30) when nothing has been tagged yet.
--- @param build table The build (must have treeTab/itemsTab/skillsTab).
--- @return number lo
--- @return number hi
function M.NextLoadoutBracket(build)
local maxLvl = 0
local function consider(entry)
if entry and entry.levelMax and entry.levelMax > maxLvl then
maxLvl = entry.levelMax
end
end
if build.treeTab and build.treeTab.specList then
for _, spec in ipairs(build.treeTab.specList) do consider(spec) end
end
if build.itemsTab and build.itemsTab.itemSetOrderList then
for _, id in ipairs(build.itemsTab.itemSetOrderList) do
consider(build.itemsTab.itemSets[id])
end
end
if build.skillsTab and build.skillsTab.skillSetOrderList then
for _, id in ipairs(build.skillsTab.skillSetOrderList) do
consider(build.skillsTab.skillSets[id])
end
end
if maxLvl == 0 then return 1, 30 end
return maxLvl, m_min(maxLvl + 30, 100)
end
--- Preset the new loadout's levelMin/levelMax to the next 30-level chunk
--- after the highest existing levelMax (e.g. [1,30] -> [30,60] -> [60,90] -> [90,100]).
--- If no existing entry has levels set, seeds the first existing entry to [1, 30]
--- so the new one fits a clean chain.
--- @param existingEntries table Array of entry objects (NOT including newEntry) with optional levelMin/levelMax.
--- @param newEntry table The entry to populate.
function M.PresetNextLevels(existingEntries, newEntry)
local maxLvl = 0
local anyHas = false
for _, entry in ipairs(existingEntries or {}) do
if entry.levelMin or entry.levelMax then
anyHas = true
if entry.levelMax and entry.levelMax > maxLvl then maxLvl = entry.levelMax end
end
end
if not anyHas then
local first = existingEntries and existingEntries[1]
if first and not first.levelMin and not first.levelMax then
first.levelMin = 1
first.levelMax = 30
maxLvl = 30
end
end
if maxLvl == 0 then
newEntry.levelMin = 1
newEntry.levelMax = 30
else
newEntry.levelMin = maxLvl
newEntry.levelMax = m_min(maxLvl + 30, 100)
end
end
local POE2_APP_ID = "2694490"
local POE2_RELATIVE = "Documents" .. "/" .. "My Games" .. "/" .. "Path of Exile 2" .. "/" .. "BuildPlanner"
local function dirExists(path)
-- os.rename(x, x) is a POSIX-guaranteed no-op when x exists; we only
-- trust the true-return because error strings are locale-dependent and
-- cannot be used to distinguish ENOENT from EACCES reliably. False
-- negatives (directory exists but isn't renameable) are acceptable here
-- since this function only determines a suggested default path.
local ok = os.rename(path, path)
return ok == true
end
local function tryProtonPath(baseSteam)
-- cspell:ignore compatdata steamuser
local prefix = baseSteam .. "/steamapps/compatdata/" .. POE2_APP_ID
.. "/pfx/drive_c/users/steamuser/" .. POE2_RELATIVE
if dirExists(prefix) then return prefix end
return nil
end
function M.DefaultDir()
if os.getenv("USERPROFILE") then
return os.getenv("USERPROFILE") .. "\\" .. POE2_RELATIVE .. "\\"
end
local home = os.getenv("HOME") or ""
local nativePath = home .. "/" .. POE2_RELATIVE
if dirExists(nativePath) then return nativePath .. "/" end
local steamFlatpak = home .. "/.var/app/com.valvesoftware.Steam/.local/share/Steam" -- cspell:ignore valvesoftware
local protonPath = tryProtonPath(steamFlatpak)
if protonPath then return protonPath .. "/" end
local steamNative = home .. "/.local/share/Steam"
protonPath = tryProtonPath(steamNative)
if protonPath then return protonPath .. "/" end
return nativePath .. "/"
end
function M.DefaultPath(build)
return M.DefaultDir() .. safeFilename(build.buildName) .. ".build"
end
local function buildAscendancy(build)
local spec = build.spec
if not spec or not spec.tree or not spec.curClassId then return nil end
local class = spec.tree.classes[spec.curClassId]
if not class or not class.classes or not spec.curAscendClassId then return nil end
local asc = class.classes[spec.curAscendClassId]
if not asc or not asc.internalId or asc.internalId == "" then return nil end
return asc.internalId
end
-- Nodes with numeric IDs >= this value are cluster-jewel synthetic subgraphs
-- and should not be emitted into the PassiveSkills table the loader looks up.
local MAX_NORMAL_NODE_ID = 65536
-- buildPassives returns (outTable, warningString|nil).
-- warningString is non-nil when tree.lua lacks stringId fields, meaning the
-- in-game BuildPlanner may not recognise the numeric passive ids.
local function buildPassives(build, brackets)
local specList = build.treeTab and build.treeTab.specList
if not specList then return {} end
-- Dedupe by node id: collect contributing intervals + node refs across specs. -- cspell:ignore Dedupe
-- "Always" (no interval) wins — if any contributing spec is untagged, the
-- merged entry has no level_interval. Otherwise the merged interval covers
-- the union span (min lo, max hi) of all contributing specs.
local merged = {}
local order = {}
local sawStringId = false
local sawMissingStringId = false
for specIdx, spec in ipairs(specList) do
local interval = brackets and brackets[specIdx]
local notes = spec.nodeNotes or {}
for nodeId, node in pairs(spec.allocNodes or {}) do
-- Skip cluster-jewel synthetic subgraph nodes; they aren't in the
-- vanilla PassiveSkills table the loader looks up.
if type(nodeId) == "number" and nodeId < MAX_NORMAL_NODE_ID then
local nodeTable = type(node) == "table" and node or nil
local note = notes[nodeId]
local existing = merged[nodeId]
if existing == nil then
merged[nodeId] = {
node = nodeTable,
interval = interval and { interval[1], interval[2] } or false,
note = (note and note ~= "") and note or nil,
}
t_insert(order, nodeId)
else
if not existing.node and nodeTable then existing.node = nodeTable end
-- First non-empty note wins.
if not existing.note and note and note ~= "" then existing.note = note end
if existing.interval == false then
-- Already "always" - nothing to do.
elseif interval == nil then
existing.interval = false
else
if interval[1] < existing.interval[1] then existing.interval[1] = interval[1] end
if interval[2] > existing.interval[2] then existing.interval[2] = interval[2] end
end
end
if nodeTable and nodeTable.stringId then
sawStringId = true
else
sawMissingStringId = true
end
end
end
end
local passiveWarning = nil
if sawMissingStringId and not sawStringId then
passiveWarning = "tree.lua has no stringId fields — passive ids are numeric and may not be recognised by the in-game BuildPlanner. Regenerate tree.lua via Export/Scripts/passivetree.lua to fix."
ConPrintf("[PoE2Export] " .. passiveWarning)
end
table.sort(order)
local out = {}
for _, nodeId in ipairs(order) do
local m = merged[nodeId]
local idStr = (m.node and m.node.stringId) or tostring(nodeId)
if not m.interval and not m.note then
-- Bare-string shorthand when there's nothing else to attach.
t_insert(out, idStr)
else
local entry = { id = idStr }
if m.interval then entry.level_interval = { m.interval[1], m.interval[2] } end
if m.note then entry.additional_text = m.note end
t_insert(out, entry)
end
end
return out, passiveWarning
end
-- Prefer the Gems.lua table key (gemId) over the gemData.gameId field;
-- the key is always authoritative whereas gameId may differ in some entries.
local function gemIdFor(gem)
if gem and gem.gemId then return gem.gemId end
return gem and gem.gemData and gem.gemData.gameId or nil
end
-- Build the additional_text for a gem instance. An author-set note (via
-- Shift+Right-Click on the gem) takes precedence; otherwise falls back to a
-- "Level N[, Q% Quality]" hint so the loader has something useful to show.
-- The .build schema has no level field on BuildSkill/BuildSupport, so this is
-- the only channel for either piece of info.
--
-- Support gems get the trivial "Level 1, 0% Quality" hint suppressed - that's
-- the default PoB assigns when a support is first placed and showing it on
-- every uncustomised support is just noise. Custom level/quality and notes -- cspell:ignore uncustomised
-- still come through.
local function gemAdditionalText(gem, isSupport)
if not gem then return nil end
if gem.note and gem.note ~= "" then
return gem.note
end
local level = tonumber(gem.level)
if not level or level <= 0 then return nil end
local quality = tonumber(gem.quality) or 0
if isSupport and level == 1 and quality == 0 then
return nil
end
if quality > 0 then
return "Level " .. tostring(level) .. ", " .. tostring(quality) .. "% Quality"
end
return "Level " .. tostring(level)
end
local function buildSkills(build, brackets)
local out = {}
local skillsTab = build.skillsTab
if not skillsTab or not skillsTab.skillSets then return out end
local orderList = skillsTab.skillSetOrderList or {}
for setIdx, setId in ipairs(orderList) do
local skillSet = skillsTab.skillSets[setId]
local interval = brackets and brackets[setIdx] or nil
if skillSet and skillSet.socketGroupList then
for _, group in ipairs(skillSet.socketGroupList) do
if group.enabled ~= false and group.gemList and #group.gemList > 0 then
local activeIdx = tonumber(group.mainActiveSkill) or 1
local activeGem = group.gemList[activeIdx] or group.gemList[1]
local activeId = gemIdFor(activeGem)
if activeId then
local entry = { id = activeId }
if interval then entry.level_interval = { interval[1], interval[2] } end
local activeText = gemAdditionalText(activeGem, false)
if activeText then entry.additional_text = activeText end
local supports = {}
for gi, gem in ipairs(group.gemList) do
if gem ~= activeGem and gem.enabled ~= false then
local supId = gemIdFor(gem)
if supId then
local supText = gemAdditionalText(gem, true)
if not interval and not supText then
-- Bare-string shorthand when there's nothing else to attach.
t_insert(supports, supId)
else
local sup = { id = supId }
if interval then sup.level_interval = { interval[1], interval[2] } end
if supText then sup.additional_text = supText end
t_insert(supports, sup)
end
else
ConPrintf("[PoE2Export] skipping support gem with no id in group '%s'", tostring(group.label or "?"))
end
end
end
if #supports > 0 then entry.support_skills = supports end
t_insert(out, entry)
else
ConPrintf("[PoE2Export] skipping active gem with no id in group '%s'", tostring(group.label or "?"))
end
end
end
end
end
return out
end
-- additional_text uses PoE2's Custom Text markup (see
-- https://www.pathofexile.com/developer/docs/game#buildplanner):
-- <bold>{ text } <italic>{ text } <red>{ text } <rgb(R,G,B)>{ text }
-- The format uses { } as delimiters, so any stray braces in mod text must be
-- stripped to avoid breaking the parser.
local function stripBraces(s)
if not s then return "" end
return (s:gsub("[{}]", ""))
end
local function titleCaseRarity(r)
if not r or r == "" then return "Item" end
return r:sub(1, 1):upper() .. r:sub(2):lower()
end
-- Header for non-unique gear. Bold the item's name; for rares with a rolled
-- title (e.g. "Mire Spike"), put the base type on the next line. Examples:
-- <bold>{Mire Spike}\nAncestral Tiara (rare with a rolled title)
-- <bold>{Magic Ultimate Mana Flask} (magic)
-- <bold>{Ancestral Tiara} (normal)
local function itemHeader(item)
local title = item.title
local base = item.baseName
if title and title ~= "" and base and title ~= base then
return "<bold>{" .. stripBraces(title) .. "}\n" .. stripBraces(base)
elseif title and title ~= "" then
return "<bold>{" .. stripBraces(title) .. "}"
elseif base then
if item.rarity and item.rarity ~= "NORMAL" then
return "<bold>{" .. titleCaseRarity(item.rarity) .. " " .. stripBraces(base) .. "}"
end
return "<bold>{" .. stripBraces(base) .. "}"
end
return "<bold>{" .. titleCaseRarity(item.rarity) .. "}"
end
-- Builds a styled hint for non-unique gear. Implicit/rune/enchant mods are
-- italicised to set them apart from explicit mods, matching PoE convention.
local function itemAdditionalText(item)
local parts = { itemHeader(item) }
local function appendItalic(modLines)
if not modLines then return end
for _, modLine in ipairs(modLines) do
if modLine.line and modLine.line ~= "" then
t_insert(parts, "<italic>{" .. stripBraces(modLine.line) .. "}")
end
end
end
local function appendPlain(modLines)
if not modLines then return end
for _, modLine in ipairs(modLines) do
if modLine.line and modLine.line ~= "" then
t_insert(parts, stripBraces(modLine.line))
end
end
end
appendItalic(item.enchantModLines)
appendItalic(item.runeModLines)
appendItalic(item.implicitModLines)
appendPlain(item.explicitModLines)
local text = t_concat(parts, "\n")
if #text > 1000 then text = text:sub(1, 997) .. "..." end
return text
end
local function uniqueNameOf(item)
-- For UNIQUE/RELIC items, Item.lua stores the first line of the unique
-- entry (the GGG Words-table key) in self.title; fall back to .name.
return item.title or item.name
end
local function buildItems(build, brackets)
local out = {}
local itemsTab = build.itemsTab
if not itemsTab or not itemsTab.itemSets then return out end
local orderList = itemsTab.itemSetOrderList or {}
for setIdx, setId in ipairs(orderList) do
local itemSet = itemsTab.itemSets[setId]
local interval = brackets and brackets[setIdx] or nil
if itemSet then
local slotNames = {}
for k in pairs(M.SlotMap) do t_insert(slotNames, k) end
table.sort(slotNames)
for _, pobSlotName in ipairs(slotNames) do
local mapping = M.SlotMap[pobSlotName]
local slotEntry = itemSet[pobSlotName]
if slotEntry and slotEntry.selItemId and slotEntry.selItemId ~= 0 then
local item = itemsTab.items[slotEntry.selItemId]
if item then
local entry = {
inventory_id = mapping.inventory_id,
slot_x = 0,
slot_y = 0,
}
if mapping.weapon_set then entry.weapon_set = mapping.weapon_set end
if interval then entry.level_interval = { interval[1], interval[2] } end
if item.rarity == "UNIQUE" or item.rarity == "RELIC" then
local name = uniqueNameOf(item)
if name and name ~= "" and name ~= "?" then
entry.unique_name = name
else
entry.additional_text = itemAdditionalText(item)
ConPrintf("[PoE2Export] UNIQUE item in slot '%s' has no title; falling back to additional_text", pobSlotName)
end
else
entry.additional_text = itemAdditionalText(item)
end
t_insert(out, entry)
end
end
end
end
end
return out
end
local function getItemSet(itemsTab, id)
return itemsTab.itemSets[id]
end
local function getSkillSet(skillsTab, id)
return skillsTab.skillSets[id]
end
--- Build the in-memory table that will be JSON-encoded as the .build file.
--- Exposed for testing.
function M.BuildTable(build)
local root = {
name = (build.buildName and build.buildName ~= "") and build.buildName or "Unnamed",
}
local ascendancy = buildAscendancy(build)
if ascendancy then root.ascendancy = ascendancy end
-- Bracket each section independently so users can tag tree/items/skills
-- with different level ranges without forcing the set counts to match.
local treeBrackets = nil
if build.treeTab and build.treeTab.specList and #build.treeTab.specList > 1 then
local pseudoOrder = {}
for i = 1, #build.treeTab.specList do pseudoOrder[i] = i end
treeBrackets = bracketsFor(pseudoOrder, function(i) return build.treeTab.specList[i] end)
end
local skillBrackets = nil
if build.skillsTab and build.skillsTab.skillSetOrderList then
skillBrackets = bracketsFor(build.skillsTab.skillSetOrderList, function(id) return getSkillSet(build.skillsTab, id) end)
end
local itemBrackets = nil
if build.itemsTab and build.itemsTab.itemSetOrderList then
itemBrackets = bracketsFor(build.itemsTab.itemSetOrderList, function(id) return getItemSet(build.itemsTab, id) end)
end
local passiveWarning
root.passives, passiveWarning = buildPassives(build, treeBrackets)
root.skills = buildSkills(build, skillBrackets)
root.items = buildItems(build, itemBrackets)
return root, passiveWarning
end
--- Returns (jsonString, nil, warning) on success, or (nil, errorMessage) on failure.
--- warning is a non-nil string when the export succeeded but passive ids are
--- numeric (tree.lua has no stringId fields) and may not be recognised in-game.
function M.Export(build)
local root, passiveWarning = M.BuildTable(build)
-- Force array-ness on the three top-level lists even when empty so the
-- loader sees `[]` instead of `{}`.
local state = { indent = true, level = 0 }
local json, err = dkjson.encode(root, state)
if not json then return nil, "JSON encode failed: " .. tostring(err) end
return json, nil, passiveWarning
end
-- Writes the exported build to disk. Caller should confirm overwrite with the
-- user before calling this — no existing-file check is performed here.
--- Returns (path, nil, warning) on success; (nil, errMsg) on failure.
function M.WriteFile(build, path)
local json, err, warning = M.Export(build)
if not json then return nil, err end
-- Best-effort: ensure the target directory exists.
local dir = path:match("^(.*[/\\])")
if dir then MakeDir(dir) end
local f, fileErr = io.open(path, "w")
if not f then return nil, "Couldn't open '" .. path .. "': " .. tostring(fileErr) end
f:write(json)
f:close()
return path, nil, warning
end
return M