From 1b0ad8e4d8f89f6a9155b02ceb2635f780cd1541 Mon Sep 17 00:00:00 2001 From: TheClassified <7460948+TheClassified@users.noreply.github.com> Date: Tue, 19 May 2026 21:51:35 +0200 Subject: [PATCH] add passive tree progression timeline --- spec/System/PassiveProgression_spec.lua | 503 +++++++++++++++++++++ src/Classes/ImportTab.lua | 4 + src/Classes/PassiveSpec.lua | 163 ++++++- src/Classes/PassiveSpecListControl.lua | 5 + src/Classes/PassiveTreeView.lua | 81 +++- src/Classes/ProgressionTimeline.lua | 554 ++++++++++++++++++++++++ src/Classes/TimelineControl.lua | 373 ++++++++++++++++ src/Classes/TreeTab.lua | 111 ++++- src/Modules/Build.lua | 27 +- 9 files changed, 1773 insertions(+), 48 deletions(-) create mode 100644 spec/System/PassiveProgression_spec.lua create mode 100644 src/Classes/ProgressionTimeline.lua create mode 100644 src/Classes/TimelineControl.lua diff --git a/spec/System/PassiveProgression_spec.lua b/spec/System/PassiveProgression_spec.lua new file mode 100644 index 0000000000..0934282a6d --- /dev/null +++ b/spec/System/PassiveProgression_spec.lua @@ -0,0 +1,503 @@ +-- Tests for the automatic passive-tree progression timeline (PassiveSpec.progression) + +describe("PassiveProgression", function() + before_each(function() + newBuild() + end) + + -- Allocate one unallocated node directly linked to an already-allocated node, + -- recording it through the same path the tree UI uses. Returns the node id. + local function allocOneReachable(spec) + spec = spec or build.spec + local prog = spec:Progression() + for _, node in pairs(spec.allocNodes) do + for _, linked in ipairs(node.linked) do + if not linked.alloc and linked.path and linked.type ~= "Mastery" + and linked.type ~= "ClassStart" and linked.type ~= "AscendClassStart" then + prog:Capture(prog:NodeAllocationOrder(linked), function() spec:AllocNode(linked) end) + return linked.id + end + end + end + end + + -- Same as allocOneReachable but skips a given node id, forcing a distinct pick + -- (a just-refunded node is reachable again and would otherwise be re-selected). + local function allocOneReachableExcept(excludeId, spec) + spec = spec or build.spec + local prog = spec:Progression() + for _, node in pairs(spec.allocNodes) do + for _, linked in ipairs(node.linked) do + if linked.id ~= excludeId and not linked.alloc and linked.path + and linked.type ~= "Mastery" and linked.type ~= "ClassStart" + and linked.type ~= "AscendClassStart" then + prog:Capture(prog:NodeAllocationOrder(linked), function() spec:AllocNode(linked) end) + return linked.id + end + end + end + end + + -- Allocate a node several hops away (path length >= 3) to exercise multi-node ordering + local function allocFarNode(spec) + spec = spec or build.spec + local prog = spec:Progression() + for _, node in pairs(spec.nodes) do + if not node.alloc and node.path and #node.path >= 3 and node.type ~= "Mastery" + and node.type ~= "ClassStart" and node.type ~= "AscendClassStart" then + prog:Capture(prog:NodeAllocationOrder(node), function() spec:AllocNode(node) end) + return node.id + end + end + end + + local function count(set) + local n = 0 + for _ in pairs(set) do n = n + 1 end + return n + end + + local function invariantHolds(spec) + spec = spec or build.spec + local allocIds = spec:SnapshotAllocIds() + local produced = spec:Progression():StateAt(#spec.progression.stages) + for id in pairs(allocIds) do if not produced[id] then return false end end + for id in pairs(produced) do if not allocIds[id] then return false end end + return true + end + + local function stageOf(spec, id) + for _, st in ipairs(spec.progression.stages) do + for _, aid in ipairs(st.alloc) do if aid == id then return st end end + end + end + + local function stageIndexOf(spec, id) + for i, st in ipairs(spec.progression.stages) do + for _, aid in ipairs(st.alloc) do if aid == id then return i end end + end + end + + -- Allocate a not-yet-allocated reachable node WITHOUT recording (an unhooked mutation) + local function allocUnhooked(spec) + spec = spec or build.spec + for _, node in pairs(spec.allocNodes) do + for _, linked in ipairs(node.linked) do + if not linked.alloc and linked.path and linked.type ~= "Mastery" + and linked.type ~= "ClassStart" and linked.type ~= "AscendClassStart" then + spec:AllocNode(linked) + return linked.id + end + end + end + end + + -- While scrubbed: allocate a node adjacent to the (partial) tree that is NOT already in + -- the timeline, recorded through the mid-scrub insert path. Returns its id. + local function allocScrubbedNew(spec) + spec = spec or build.spec + local prog = spec:Progression() + local inStages = { } + for _, st in ipairs(spec.progression.stages) do + for _, id in ipairs(st.alloc) do inStages[id] = true end + end + for _, node in pairs(spec.allocNodes) do + for _, linked in ipairs(node.linked) do + if not linked.alloc and not inStages[linked.id] and linked.path + and linked.type ~= "Mastery" and linked.type ~= "ClassStart" + and linked.type ~= "AscendClassStart" then + prog:CaptureScrubbed(prog:NodeAllocationOrder(linked), + function() spec:AllocNode(linked) end, false) + return linked.id + end + end + end + end + + it("is enabled with an empty timeline for a new build", function() + local prog = build.spec.progression + assert.is_true(prog.enabled) + assert.are.equal(0, #prog.stages) + assert.is_false(prog.respecOpen) + end) + + it("auto-creates one progress entry per allocated node", function() + local id = allocOneReachable() + assert.is_not_nil(id) + local st = stageOf(build.spec, id) + assert.is_not_nil(st) + assert.are.equal("progress", st.kind) + assert.are.equal(1, #st.alloc) + assert.is_true(#build.spec.progression.stages >= 1) + assert.is_true(invariantHolds()) + end) + + it("treats a deallocation (no respec) as a silent correction", function() + local id = allocOneReachable() + local before = #build.spec.progression.stages + local node = build.spec.nodes[id] + build.spec:Progression():Capture(nil, function() build.spec:DeallocNode(node) end) + assert.is_nil(stageOf(build.spec, id)) + assert.is_true(#build.spec.progression.stages < before) + assert.is_true(invariantHolds()) + end) + + it("groups refunds into one atomic respec block via ToggleRespec", function() + local idA = allocOneReachable() + build.spec:AddUndoState() + build.spec:Progression():ToggleRespec() + assert.is_true(build.spec.progression.respecOpen) + local respec = build.spec.progression.stages[#build.spec.progression.stages] + assert.are.equal("respec", respec.kind) + + local node = build.spec.nodes[idA] + build.spec:Progression():Capture(nil, function() build.spec:DeallocNode(node) end) + local refunded = false + for _, did in ipairs(respec.dealloc) do if did == idA then refunded = true end end + assert.is_true(refunded) + + local idB = allocOneReachableExcept(idA) + assert.is_not_nil(idB) + local added = false + for _, aid in ipairs(respec.alloc) do if aid == idB then added = true end end + assert.is_true(added) + + build.spec:Progression():ToggleRespec() + assert.is_false(build.spec.progression.respecOpen) + assert.is_true(invariantHolds()) + end) + + it("drops a refund-only respec and its orphaned entries", function() + allocOneReachable() + local idB = allocOneReachable() + assert.is_not_nil(idB) + build.spec:Progression():ToggleRespec() + -- refund idB inside the respec but add nothing back + build.spec:Progression():Capture(nil, function() build.spec:DeallocNode(build.spec.nodes[idB]) end) + build.spec:Progression():ToggleRespec() + build.spec:Progression():Normalize() + for _, st in ipairs(build.spec.progression.stages) do + assert.are_not.equal("respec", st.kind) + for _, id in ipairs(st.alloc) do assert.are_not.equal(idB, id) end + end + assert.is_true(invariantHolds()) + end) + + it("records a multi-node path in leveling order so every scrub prefix stays connected", function() + local id = allocFarNode() + assert.is_not_nil(id) + local spec = build.spec + local n = #spec.progression.stages + assert.is_true(n >= 3) + local startCount = count(spec:Progression():StateAt(0)) + local prev = startCount + for k = 1, n do + spec:Progression():ScrubToStage(k >= n and nil or k) + runCallback("OnFrame") + local c = count(spec:SnapshotAllocIds()) + -- every prefix must actually allocate (connected to the tree), growing monotonically + assert.is_true(c >= prev, "prefix "..k.." lost allocations (disconnected order)") + if k < n then assert.is_true(c >= k) end + prev = c + end + spec:Progression():ScrubToStage(nil) + runCallback("OnFrame") + assert.is_true(invariantHolds()) + end) + + it("stays consistent (replay == tree) after a cascade with a respec open", function() + -- Build a small chain, open a respec, then deallocate a connector so the cascade + -- removes several nodes at once. The timeline must never desync from the tree. + allocOneReachable() + allocOneReachable() + local id = allocFarNode() + assert.is_not_nil(id) + build.spec:Progression():ToggleRespec() + assert.is_true(build.spec.progression.respecOpen) + -- find an allocated connector near the start and refund it (cascades dependents) + for _, node in pairs(build.spec.allocNodes) do + if node.type ~= "ClassStart" and node.type ~= "AscendClassStart" + and node.isFreeAllocate == nil and node.depends and #node.depends > 1 then + build.spec:Progression():Capture(nil, function() build.spec:DeallocNode(node) end) + break + end + end + assert.is_true(invariantHolds(), "timeline desynced from tree after cascade+respec") + build.spec:Progression():ToggleRespec() + assert.is_true(invariantHolds()) + end) + + it("scrub really changes the allocated set and restores it exactly", function() + allocOneReachable() + local id2 = allocOneReachable() + assert.is_not_nil(id2) + local n = #build.spec.progression.stages + assert.is_true(n >= 2) + local finalCount = count(build.spec:SnapshotAllocIds()) + + build.spec:Progression():ScrubToStage(1) + runCallback("OnFrame") + assert.is_nil(build.spec.allocNodes[id2]) + assert.is_true(count(build.spec:SnapshotAllocIds()) < finalCount) + + build.spec:Progression():ScrubToStage(nil) + runCallback("OnFrame") + assert.is_not_nil(build.spec.allocNodes[id2]) + assert.are.equal(finalCount, count(build.spec:SnapshotAllocIds())) + assert.is_nil(build.spec.progression.scrubStage) + end) + + it("persists the final tree and reopens at the end even when scrubbed", function() + allocOneReachable() + allocOneReachable() + local finalCount = count(build.spec:SnapshotAllocIds()) + + build.spec:Progression():ScrubToStage(1) -- leave it mid-scrub + local xml = build:SaveDB("code") + assert.is_string(xml) + assert.is_truthy(xml:find("", function() + allocOneReachable() + local xml = build:SaveDB("code") + local stripped = xml:gsub(".-", ""):gsub("", "") + loadBuildFromXML(stripped, "legacy") + assert.is_false(build.spec.progression.enabled) + local resaved = build:SaveDB("code") + assert.is_nil(resaved:find("= 1) + local prev = build:EstimateLevelForPoints(0) + assert.are.equal(1, prev) + for k = 1, #stages do + local lvl = tl:LevelAt(build.spec, k) + assert.is_true(type(lvl) == "number" and lvl >= 1 and lvl <= 100) + assert.is_true(lvl >= prev) + prev = lvl + end + end) + + it("heals an unhooked alloc-set change without flattening recorded order", function() + local spec = build.spec + local idA = allocOneReachable() + local idB = allocOneReachable() + assert.is_not_nil(idA) + assert.is_not_nil(idB) + -- unhooked dealloc of the leaf idB, then an unhooked add of a new node + spec:DeallocNode(spec.nodes[idB]) + local idC = allocUnhooked(spec) + assert.is_not_nil(idC) + spec:Progression():Normalize() + -- idA keeps its recorded slot, idB silent-corrected away, idC appended after idA + assert.is_not_nil(stageOf(spec, idA)) + assert.is_nil(stageOf(spec, idB)) + assert.is_not_nil(stageOf(spec, idC)) + assert.is_true(stageIndexOf(spec, idA) < stageIndexOf(spec, idC)) + assert.is_true(invariantHolds()) + end) + + it("is unaffected by an in-place node transform (timeless/Abyss keep the same id)", function() + local spec = build.spec + local id = allocOneReachable() + assert.is_not_nil(id) + local st = stageOf(spec, id) + assert.is_not_nil(st) + -- mimic ReplaceNode: change node CONTENTS but keep .id and .type + local node = spec.nodes[id] + node.dn = "Transformed Node" + node.modList = node.modList or { } + spec:Progression():Normalize() + assert.are.equal(st, stageOf(spec, id)) -- same stage, same id + assert.is_true(invariantHolds()) + -- scrub round-trips cleanly through the transformed node + spec:Progression():ScrubToStage(0) + runCallback("OnFrame") + spec:Progression():ScrubToStage(nil) + runCallback("OnFrame") + assert.is_not_nil(spec.allocNodes[id]) + assert.is_true(invariantHolds()) + end) + + it("rebuilt-from-tree stages scrub through connected prefixes", function() + local spec = build.spec + allocOneReachable() + local id = allocFarNode() + assert.is_not_nil(id) + spec:Progression():RebuildStagesFromTree(true) + local n = #spec.progression.stages + assert.is_true(n >= 3) + local prev = count(spec:Progression():StateAt(0)) + for k = 1, n do + spec:Progression():ScrubToStage(k >= n and nil or k) + runCallback("OnFrame") + local c = count(spec:SnapshotAllocIds()) + -- connected order => every prefix actually allocates (monotonic, never blanks) + assert.is_true(c >= prev, "rebuilt prefix "..k.." disconnected") + prev = c + end + spec:Progression():ScrubToStage(nil) + runCallback("OnFrame") + assert.is_true(invariantHolds()) + end) + + it("SetCompareSpec snaps a scrubbed spec back to its final tree", function() + local spec = build.spec + allocOneReachable() + allocOneReachable() + local finalCount = count(spec:SnapshotAllocIds()) + spec:Progression():ScrubToStage(1) + runCallback("OnFrame") + assert.is_not_nil(spec.progression.scrubStage) + build.treeTab:SetCompareSpec(build.treeTab.activeSpec) + assert.is_nil(spec.progression.scrubStage) + assert.are.equal(finalCount, count(spec:SnapshotAllocIds())) + end) + + it("mid-scrub allocation inserts at the cursor and keeps later progression", function() + local spec = build.spec + local a = allocOneReachable() + local b = allocOneReachable() + local c = allocOneReachable() + assert.is_not_nil(c) + local finalBefore = count(spec:SnapshotAllocIds()) + spec:Progression():ScrubToStage(1) + runCallback("OnFrame") + local newId = allocScrubbedNew(spec) + assert.is_not_nil(newId) + -- inserted right after the cursor; later stages preserved and shifted after it + assert.are.equal(2, stageIndexOf(spec, newId)) + assert.are.equal(2, spec.progression.scrubStage) -- view stays at the inserted point + assert.is_true(stageIndexOf(spec, b) > 2) + assert.is_true(stageIndexOf(spec, c) > stageIndexOf(spec, b)) + spec:Progression():ScrubToStage(nil) + runCallback("OnFrame") + assert.is_true(invariantHolds()) + assert.are.equal(finalBefore + 1, count(spec:SnapshotAllocIds())) + assert.is_not_nil(spec.allocNodes[newId]) + end) + + it("mid-scrub allocation of an already-later node dedupes the later copy", function() + local spec = build.spec + local a = allocOneReachable() + local b = allocOneReachable() + assert.is_not_nil(b) + local finalBefore = count(spec:SnapshotAllocIds()) + local stagesBefore = #spec.progression.stages + spec:Progression():ScrubToStage(1) + runCallback("OnFrame") + local bn = spec.nodes[b] + spec:Progression():CaptureScrubbed(spec:Progression():NodeAllocationOrder(bn), + function() spec:AllocNode(bn) end, false) + local occurrences = 0 + for _, st in ipairs(spec.progression.stages) do + for _, id in ipairs(st.alloc) do if id == b then occurrences = occurrences + 1 end end + end + assert.are.equal(1, occurrences) + spec:Progression():ScrubToStage(nil) + runCallback("OnFrame") + assert.is_true(invariantHolds()) + assert.are.equal(finalBefore, count(spec:SnapshotAllocIds())) + assert.are.equal(stagesBefore, #spec.progression.stages) + end) + + it("a confirmed destructive mid-scrub edit truncates after the cursor", function() + local spec = build.spec + local a = allocOneReachable() + local b = allocOneReachable() + local c = allocOneReachable() + assert.is_not_nil(c) + spec:Progression():ScrubToStage(2) + runCallback("OnFrame") + local bn = spec.nodes[b] + spec:Progression():CaptureScrubbed(nil, function() spec:DeallocNode(bn) end, true) + runCallback("OnFrame") + assert.is_nil(stageOf(spec, b)) -- removed node scrubbed out of history + assert.is_nil(stageOf(spec, c)) -- everything after the cursor discarded + assert.is_nil(spec.allocNodes[c]) + assert.is_true(invariantHolds()) + end) + + -- A not-yet-allocated attribute node reachable from the current tree. + -- Scan the whole reachable graph (.path set) like allocFarNode: on a fresh + -- build no attribute node is an immediate neighbour of the class start. + local function findReachableAttribute(spec) + spec = spec or build.spec + for _, node in pairs(spec.nodes) do + if node.isAttribute and not node.alloc and node.path and #node.path >= 1 then + return node + end + end + end + + it("records an attribute-popup allocation without needing a reload", function() + local spec = build.spec + local node = findReachableAttribute(spec) + assert.is_not_nil(node, "no reachable attribute node found") + -- mimic TreeTab:ModifyAttributePopup's Allocate button + spec:SwitchAttributeNode(node.id, 1) + spec.attributeIndex = 1 + spec:AllocNodeRecorded(node) + assert.is_not_nil(spec.allocNodes[node.id]) + assert.is_not_nil(stageOf(spec, node.id)) -- recorded immediately, no save/reload + assert.is_true(invariantHolds()) + end) + + it("keeps the scrub cursor on the inserted node (does not fly to the end)", function() + local spec = build.spec + allocOneReachable() + local b = allocOneReachable() + allocOneReachable() + assert.is_not_nil(b) + spec:Progression():ScrubToStage(1) + runCallback("OnFrame") + local newId = allocScrubbedNew(spec) + assert.is_not_nil(newId) + -- cursor follows the inserted node and stays scrubbed while later stages remain + assert.is_not_nil(spec.progression.scrubStage) + assert.are.equal(stageIndexOf(spec, newId), spec.progression.scrubStage) + assert.is_true(#spec.progression.stages > spec.progression.scrubStage) + assert.is_true(stageIndexOf(spec, b) > spec.progression.scrubStage) + spec:Progression():ScrubToStage(nil) + runCallback("OnFrame") + assert.is_true(invariantHolds()) + end) +end) diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index 309a805344..be8a4bac4a 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -737,6 +737,10 @@ function ImportTabClass:ImportPassiveTreeAndJewels(charData) end end + -- Imported character tree has no progression history + self.build.timelineEligible = false + self.build.spec:Progression():Disable() + self.build.spec:ImportFromNodeList(charData.class, nil, nil, charPassiveData.alternate_ascendancy or 0, hashes, weaponSets, {}, charPassiveData.mastery_effects or {}, latestTreeVersion) -- workaround to update the ui to last option diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 294782679c..29090e9c98 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -16,6 +16,26 @@ local b_rshift = bit.rshift local band = AND64 -- bit.band local bor = OR64 -- bit.bor +-- Nodes where points are actually spent (excludes class/ascend starts and free-allocate) +local function isTimelineRelevant(node) + return node and node.type ~= "ClassStart" and node.type ~= "AscendClassStart" and node.isFreeAllocate == nil +end + +-- Snapshot weapon-set/mastery/override state (undo + scrub) +local function captureTreeState(self) + local weaponSets = { } + for nodeId, node in pairs(self.allocNodes) do + if node.allocMode and node.allocMode ~= 0 then + weaponSets[nodeId] = node.allocMode + end + end + local masteryEffects = { } + for mastery, effect in pairs(self.masterySelections) do + masteryEffects[mastery] = effect + end + return weaponSets, masteryEffects, copyTable(self.hashOverrides, true) +end + local PassiveSpecClass = newClass("PassiveSpec", "UndoHandler", function(self, build, treeVersion, convert) self.UndoHandler() @@ -91,12 +111,93 @@ function PassiveSpecClass:Init(treeVersion, convert) -- Keys are node IDs, values are the replacement node self.hashOverrides = { } + + -- Init re-runs on tree-version change; keep the timeline. self.progression aliases its data. + if not self.progressionTimeline then + self.progressionTimeline = new("ProgressionTimeline", self) + end + self.progression = self.progressionTimeline.data +end + +function PassiveSpecClass:SnapshotAllocIds() + local ids = { } + for nodeId, node in pairs(self.allocNodes) do + if isTimelineRelevant(node) then + ids[nodeId] = true + end + end + return ids +end + +function PassiveSpecClass:IsTimelineRelevant(node) + return isTimelineRelevant(node) +end + +function PassiveSpecClass:TimelineNode(id) + return self.nodes[id] +end + +-- Allocated nodes the timeline doesn't track (a scrub keeps these as roots) +function PassiveSpecClass:TimelineFixedAllocIds() + local ids = { } + for nodeId, node in pairs(self.allocNodes) do + if not isTimelineRelevant(node) then + t_insert(ids, nodeId) + end + end + return ids +end + +-- Snapshot to survive a scrub; no progression deep-copy (runs per scrub frame) +function PassiveSpecClass:CaptureTimelineSnapshot() + local weaponSets, masteryEffects, hashOverrides = captureTreeState(self) + return { + classId = self.curClassId, + ascendClassId = self.curAscendClassId, + secondaryAscendClassId = self.secondaryAscendClassId, + weaponSets = weaponSets, + hashOverrides = hashOverrides, + masteryEffects = masteryEffects, + treeVersion = self.treeVersion, + } +end + +function PassiveSpecClass:ApplyTimelineState(state, hashList) + self.applyingScrub = true + self:ImportFromNodeList(nil, state.classId, state.ascendClassId, state.secondaryAscendClassId, hashList, state.weaponSets, state.hashOverrides, state.masteryEffects, state.treeVersion) + self.applyingScrub = false +end + +function PassiveSpecClass:IsApplyingTimelineState() + return self.applyingScrub == true +end + +function PassiveSpecClass:IsTimelineEligible() + return self.build and self.build.timelineEligible or false +end + +function PassiveSpecClass:RequestRecompute() + if self.build then + self.build.buildFlag = true + end +end + +function PassiveSpecClass:Progression() + return self.progressionTimeline +end + +-- Allocate a node and record it on the progression timeline (scrub-aware), as a tree click does +function PassiveSpecClass:AllocNodeRecorded(node, altPath) + local prog = self:Progression() + prog:CaptureScrubbed(prog:NodeAllocationOrder(node, altPath), + function() self:AllocNode(node, altPath) end) end function PassiveSpecClass:Load(xml, dbFileName) self.title = xml.attrib.title local weaponSets = {} local url + local progEl for _, node in pairs(xml) do if type(node) == "table" then if node.elem == "URL" then @@ -134,6 +235,8 @@ function PassiveSpecClass:Load(xml, dbFileName) for nodeId in node.attrib.nodes:gmatch("%d+") do weaponSets[tonumber(nodeId)] = weaponSet end + elseif node.elem == "Progression" then + progEl = node end end end @@ -209,16 +312,30 @@ function PassiveSpecClass:Load(xml, dbFileName) elseif url then self:DecodeURL(url) end + + -- No = legacy/imported build; only new eligible builds get a timeline + self.progressionTimeline:Deserialize(progEl) self:ResetUndo() end function PassiveSpecClass:Save(xml) + -- Persist the final tree even if saved mid-scrub + local prog = self.progression + local saveNodeSet + if prog and prog.enabled and prog.scrubStage ~= nil then + saveNodeSet = { } + for nodeId, node in pairs(self.allocNodes) do + if not isTimelineRelevant(node) then saveNodeSet[nodeId] = true end + end + for id in pairs(self.progressionTimeline:StateAt(#prog.stages)) do saveNodeSet[id] = true end + end local allocNodeIdList = { } local weaponSets = {} - for nodeId in pairs(self.allocNodes) do + for nodeId in pairs(saveNodeSet or self.allocNodes) do t_insert(allocNodeIdList, nodeId) - if self.nodes[nodeId].allocMode and self.nodes[nodeId].allocMode ~= 0 then - local weaponSet = self.nodes[nodeId].allocMode + local node = self.nodes[nodeId] + if node and node.allocMode and node.allocMode ~= 0 then + local weaponSet = node.allocMode if not weaponSets[weaponSet] then weaponSets[weaponSet] = { } end @@ -298,6 +415,11 @@ function PassiveSpecClass:Save(xml) end t_insert(xml, overrides) + local progEl = self.progressionTimeline:Serialize() + if progEl then + t_insert(xml, progEl) + end + end function PassiveSpecClass:PostLoad() @@ -335,6 +457,8 @@ function PassiveSpecClass:ImportFromNodeList(className, classId, ascendClassId, self:ReplaceNode(node, override) end end + local hashSet = { } + for _, id in pairs(hashList) do hashSet[id] = true end for _, id in pairs(hashList) do local node = self.nodes[id] if node then @@ -348,9 +472,10 @@ function PassiveSpecClass:ImportFromNodeList(className, classId, ascendClassId, t_insert(self.allocSubgraphNodes, id) end end + -- Only re-allocate subgraph nodes the requested set wants (scrub-safe); no-op in PoE2 for _, id in pairs(self.allocExtendedNodes) do local node = self.nodes[id] - if node then + if node and hashSet[id] then node.alloc = true node.allocMode = weaponSets[id] or 0 self.allocNodes[id] = node @@ -359,6 +484,11 @@ function PassiveSpecClass:ImportFromNodeList(className, classId, ascendClassId, -- Rebuild all the node paths and dependencies self:BuildAllDependsAndPaths() + + -- Resync timeline after rebuild; skipped mid-scrub and during Load + if not self.applyingScrub then + self.progressionTimeline:ReconcileFromTree() + end end function PassiveSpecClass:AllocateDecodedNodes(nodes, isCluster, endian) @@ -1713,6 +1843,7 @@ function PassiveSpecClass:ReconnectNodeToClassStart(node) end function PassiveSpecClass:BuildClusterJewelGraphs() + -- No-op in PoE2 (no cluster jewels). TODO: record subgraph deltas in click order when added. -- Remove old subgraphs for id, subGraph in pairs(self.subGraphs) do for _, node in ipairs(subGraph.nodes) do @@ -1775,6 +1906,11 @@ function PassiveSpecClass:BuildClusterJewelGraphs() -- Rebuild node search cache because the tree might have changed self.build.treeTab.viewer.searchStrCached = "" + + -- Heal timeline if a jewel edit changed the tracked set + if not self.applyingScrub then + self.progressionTimeline:Normalize() + end end function PassiveSpecClass:BuildSubgraph(jewel, parentSocket, id, upSize, importedNodes, importedGroups) @@ -2201,17 +2337,10 @@ end function PassiveSpecClass:CreateUndoState() local allocNodeIdList = { } - local weaponSets = { } for nodeId in pairs(self.allocNodes) do t_insert(allocNodeIdList, nodeId) - if self.nodes[nodeId].allocMode and self.nodes[nodeId].allocMode ~= 0 then - weaponSets[nodeId] = self.nodes[nodeId].allocMode - end - end - local selections = { } - for mastery, effect in pairs(self.masterySelections) do - selections[mastery] = effect end + local weaponSets, masteryEffects, hashOverrides = captureTreeState(self) local classInternalId = self.tree.classes[self.curClassId].integerId local ascendancyInternalId = "" if self.curAscendClassId and self.tree.classes[self.curClassId].classes[self.curAscendClassId] then @@ -2227,13 +2356,17 @@ function PassiveSpecClass:CreateUndoState() secondaryAscendClassId = self.secondaryAscendClassId, hashList = allocNodeIdList, weaponSets = weaponSets, - hashOverrides = copyTable(self.hashOverrides, true), - masteryEffects = selections, - treeVersion = self.treeVersion + hashOverrides = hashOverrides, + masteryEffects = masteryEffects, + treeVersion = self.treeVersion, + -- Deep copy: nested stages would otherwise be shared across undo states + progression = copyTable(self.progression) } end function PassiveSpecClass:RestoreUndoState(state, treeVersion) + -- Restore timeline before the rebuild so ImportFromNodeList's ReconcileFromTree sees it + self.progressionTimeline:AdoptUndoData(state.progression) local classId = state.classId local ascendClassId = state.ascendClassId if treeVersion ~= nil and treeVersion ~= state.treeVersion then diff --git a/src/Classes/PassiveSpecListControl.lua b/src/Classes/PassiveSpecListControl.lua index f227764d8f..28c92091fc 100644 --- a/src/Classes/PassiveSpecListControl.lua +++ b/src/Classes/PassiveSpecListControl.lua @@ -38,6 +38,11 @@ local PassiveSpecListClass = newClass("PassiveSpecListControl", "ListControl", f newSpec:SelectClass(treeTab.build.spec.curClassId) newSpec:SelectAscendClass(treeTab.build.spec.curAscendClassId) newSpec:SelectSecondaryAscendClass(treeTab.build.spec.curSecondaryAscendClassId) + -- New tree gets a timeline if the build is eligible or another tree has one + local cur = treeTab.build.spec + if treeTab.build.timelineEligible or (cur and cur:Progression():IsEnabled()) then + newSpec:Progression():Enable() + end self:RenameSpec(newSpec, "New Tree", true) end) self:UpdateItemsTabPassiveTreeDropdown() diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index 079854468c..d48eaba4ce 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -41,6 +41,10 @@ local PassiveTreeViewClass = newClass("PassiveTreeView", function(self) self.searchStrResults = {} self.showStatDifferences = true self.hoverNode = nil + + -- Progression timeline hover highlights, set by TreeTab each frame + self.progressionHighlight = nil + self.progressionHighlightDealloc = nil end) function PassiveTreeViewClass:Load(xml, fileName) @@ -388,26 +392,46 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) return shouldBlock end + local function recordHoverAlloc() + local altPath = self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath + spec:AllocNodeRecorded(hoverNode, altPath) + spec:AddUndoState() + build.buildFlag = true + end + if treeClick == "LEFT" then if hoverNode then -- User left-clicked on a node + local prog = spec:Progression() if hoverNode.alloc and not shouldBlockGlobalNodeDeallocation(hoverNode) then -- Handle deallocation of allocated nodes - if hoverNode.isAttribute then - -- change to other attribute without needing to deallocate - if hotkeyPressed then - processAttributeHotkeys(true) - -- reload allocated node with new attribute - spec:BuildAllDependsAndPaths() - else -- reset switched node to generic Attribute - spec.hashOverrides[hoverNode.id] = nil + local deallocFn = function() + if hoverNode.isAttribute then + -- change to other attribute without needing to deallocate + if hotkeyPressed then + processAttributeHotkeys(true) + -- reload allocated node with new attribute + spec:BuildAllDependsAndPaths() + else -- reset switched node to generic Attribute + spec.hashOverrides[hoverNode.id] = nil + spec:DeallocNode(hoverNode) + end + else spec:DeallocNode(hoverNode) end + end + if prog:IsScrubbed() then + -- Destructive: later progression was pathed through this node + main:OpenConfirmPopup("Edit Progression", "^7Editing the progression before later allocations.\nThe progression recorded after this point will be disconnected and discarded.", "Continue", function() + prog:CaptureScrubbed(nil, deallocFn, true) + spec:AddUndoState() + build.buildFlag = true + end) else - spec:DeallocNode(hoverNode) + prog:Capture(nil, deallocFn) + spec:AddUndoState() + build.buildFlag = true end - spec:AddUndoState() - build.buildFlag = true else -- Check if the node belongs to a different ascendancy if hoverNode.ascendancyName then @@ -424,6 +448,8 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) end if isDifferentAscendancy then + -- Class/ascendancy switch re-anchors the tree; leave scrub first + if prog:IsScrubbed() then prog:ScrubToFinal() end -- First, check if it's in the current class (same-class switching) for ascendClassId, ascendClass in pairs(spec.curClass.classes) do if ascendClass.id == hoverNode.ascendancyName then @@ -435,6 +461,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) if targetAscendClassId then -- Same-class switching - always allowed spec:SelectAscendClass(targetAscendClassId) + prog:ReconcileFromTree() spec:AddUndoState() spec:SetWindowTitleWithBuildClass() build.buildFlag = true @@ -456,13 +483,14 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) local used = spec:CountAllocNodes() local clickedAscendNodeId = hoverNode and hoverNode.id local function allocateClickedAscendancy() - if not clickedAscendNodeId then - return - end - local targetNode = spec.nodes[clickedAscendNodeId] - if targetNode and not targetNode.alloc then - spec:AllocNode(targetNode) + if clickedAscendNodeId then + local targetNode = spec.nodes[clickedAscendNodeId] + if targetNode and not targetNode.alloc then + spec:AllocNode(targetNode) + end end + -- Class/ascendancy change re-anchors the timeline, not a node delta + prog:ReconcileFromTree() end -- Allow cross-class switching if: no regular points allocated OR tree is connected to target class @@ -508,9 +536,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) if hotkeyPressed then processAttributeHotkeys(hoverNode.isAttribute) end - spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath) - spec:AddUndoState() - build.buildFlag = true + recordHoverAlloc() end end end @@ -518,6 +544,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) elseif treeClick == "RIGHT" then -- User right-clicked on a node if hoverNode then + local prog = spec:Progression() if hoverNode.alloc and (hoverNode.type == "Socket" or hoverNode.containJewelSocket) then local slot = build.itemsTab.sockets[hoverNode.id] if slot:IsEnabled() then @@ -543,9 +570,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) end spec:SwitchAttributeNode(hoverNode.id, spec.attributeIndex or 1) end - spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath) - spec:AddUndoState() - build.buildFlag = true + recordHoverAlloc() end end end @@ -1081,6 +1106,16 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) end end + if (self.progressionHighlight and self.progressionHighlight[nodeId]) or (self.progressionHighlightDealloc and self.progressionHighlightDealloc[nodeId]) then + SetDrawLayer(nil, 30) + if self.progressionHighlight and self.progressionHighlight[nodeId] then + SetDrawColor(0.2, 0.7, 1) + else + SetDrawColor(0.85, 0.25, 0.25) + end + local size = 140 * scale / self.zoom ^ 0.2 + DrawImage(self.highlightRing, scrX - size, scrY - size, size * 2, size * 2) + end if node == hoverNode and (node.type ~= "Socket" or not IsKeyDown("SHIFT")) and not IsKeyDown("CTRL") and not main.popups[1] then -- Draw tooltip SetDrawLayer(nil, 100) diff --git a/src/Classes/ProgressionTimeline.lua b/src/Classes/ProgressionTimeline.lua new file mode 100644 index 0000000000..5fb7cbbdd5 --- /dev/null +++ b/src/Classes/ProgressionTimeline.lua @@ -0,0 +1,554 @@ +-- Path of Building +-- +-- Class: Progression Timeline +-- Allocation-order timeline for the passive tree: capture, reconcile, scrub, respec, +-- serialization. Reaches the tree only via the PassiveSpec host seam. +-- self.data is mutated in place (never reassigned) so the host can alias it. +-- +local pairs = pairs +local ipairs = ipairs +local t_insert = table.insert +local t_remove = table.remove + +local function newProgressionStage(kind) + return { kind = kind == "respec" and "respec" or "progress", alloc = { }, dealloc = { } } +end + +local function newProgression(enabled) + return { enabled = enabled or false, version = 1, scrubStage = nil, respecOpen = false, stages = { } } +end + +local function insertUnique(arr, id) + if not isValueInArray(arr, id) then t_insert(arr, id) end +end + +local function removeFromArray(arr, id) + local removed = false + for k = #arr, 1, -1 do + if arr[k] == id then t_remove(arr, k) removed = true end + end + return removed +end + +-- Author correction: drop an id from every recorded stage +local function scrubIdFromHistory(stages, id) + for _, st in ipairs(stages) do + removeFromArray(st.alloc, id) + removeFromArray(st.dealloc, id) + end +end + +local function idInAnyStageAlloc(stages, id) + for _, st in ipairs(stages) do + if isValueInArray(st.alloc, id) then return true end + end + return false +end + +local function setsEqual(a, b) + for id in pairs(a) do if not b[id] then return false end end + for id in pairs(b) do if not a[id] then return false end end + return true +end + +-- Diff two alloc snapshots; addedList honours orderedIds first, then the rest +local function diffSnapshots(before, after, orderedIds) + local added, addedList, removed = { }, { }, { } + for id in pairs(after) do if not before[id] then added[id] = true end end + for id in pairs(before) do if not after[id] then t_insert(removed, id) end end + if orderedIds then + for _, id in ipairs(orderedIds) do + if added[id] then t_insert(addedList, id) added[id] = nil end + end + end + for id in pairs(added) do t_insert(addedList, id) end + return addedList, removed +end + +local ProgressionTimelineClass = newClass("ProgressionTimeline", function(self, spec) + self.spec = spec + self.data = newProgression(false) + self._finalState = nil +end) + +-- Replace data in place; identity preserved so the host's alias stays valid +function ProgressionTimelineClass:_resetData(tbl) + wipeTable(self.data) + for k, v in pairs(tbl) do self.data[k] = v end + self._finalState = nil +end + +function ProgressionTimelineClass:AdoptUndoData(progData) + self:_resetData(progData and copyTable(progData) or newProgression(false)) + self.data.scrubStage = nil + self.data.respecOpen = false +end + +function ProgressionTimelineClass:StateAt(i) + local set = { } + local stages = self.data.stages + for s = 1, i do + local st = stages[s] + if st then + for _, id in ipairs(st.alloc) do set[id] = true end + for _, id in ipairs(st.dealloc) do set[id] = nil end + end + end + return set +end + +-- Leveling order start->target; node.path is reversed, a trace path is already ordered +function ProgressionTimelineClass:NodeAllocationOrder(node, altPath) + local ids = { } + if altPath then + for _, n in ipairs(altPath) do t_insert(ids, n.id) end + elseif node and node.path then + for k = #node.path, 1, -1 do t_insert(ids, node.path[k].id) end + end + return ids +end + +-- Wrap a tree edit; record the alloc/dealloc delta (orderedIds keeps step order) +function ProgressionTimelineClass:Capture(orderedIds, fn) + local prog = self.data + if not (prog and prog.enabled) or self.spec:IsApplyingTimelineState() then + return fn() + end + -- Record against the final tree; snap there first + self:ScrubToFinal() + local before = self.spec:SnapshotAllocIds() + local r = fn() + local after = self.spec:SnapshotAllocIds() + self:ReconcileDelta(before, after, orderedIds) + return r +end + +-- Mid-scrub edit at the cursor. truncate=false inserts a stage per added node (later +-- stages shift, dupes drop); truncate=true drops everything after the cursor. +function ProgressionTimelineClass:CaptureScrubbed(orderedIds, fn, truncate) + local prog = self.data + if not (prog and prog.enabled) or self.spec:IsApplyingTimelineState() or prog.scrubStage == nil then + -- Not scrubbed: fall back to final-tree recording + return self:Capture(orderedIds, fn) + end + local cursor = prog.scrubStage + local before = self.spec:SnapshotAllocIds() + local r = fn() + local after = self.spec:SnapshotAllocIds() + + local addedList, removed = diffSnapshots(before, after, orderedIds) + + local stages = prog.stages + -- Dealloc/swap mid-scrub is an author correction: scrub those ids out of history + for _, id in ipairs(removed) do + scrubIdFromHistory(stages, id) + end + + if truncate then + for j = #stages, cursor + 1, -1 do t_remove(stages, j) end + for _, id in ipairs(addedList) do + local st = newProgressionStage("progress") + st.alloc[1] = id + t_insert(stages, st) + end + else + for offset, id in ipairs(addedList) do + local st = newProgressionStage("progress") + st.alloc[1] = id + t_insert(stages, cursor + offset, st) + end + -- The earlier inserted entry wins; drop any later occurrence of the same node. + for k = cursor + #addedList + 1, #stages do + local st = stages[k] + if st.kind == "progress" then + for _, id in ipairs(addedList) do removeFromArray(st.alloc, id) end + end + end + end + local targetCursor = cursor + #addedList + + self:ScrubToFinal() + self:Normalize() + local n = #prog.stages + -- Normalize/PruneEmptyStages may have shifted stages; re-find the inserted node + -- so the cursor stays on it instead of collapsing to the final tree + local target = targetCursor + if #addedList > 0 then + local lastId = addedList[#addedList] + for i = n, 1, -1 do + if isValueInArray(prog.stages[i].alloc, lastId) then target = i break end + end + end + if target > n then target = n end + self:ScrubToStage(target < n and target or nil) + return r +end + +-- Drop empty stages, except the currently-open respec block +function ProgressionTimelineClass:PruneEmptyStages() + local prog = self.data + local stages = prog.stages + for k = #stages, 1, -1 do + local st = stages[k] + local openRespec = prog.respecOpen and k == #stages and st.kind == "respec" + if not openRespec and #st.alloc == 0 and (st.kind ~= "respec" or #st.dealloc == 0) then + t_remove(stages, k) + end + end +end + +function ProgressionTimelineClass:GetActiveRespecStage() + local prog = self.data + local last = prog.respecOpen and prog.stages[#prog.stages] + return (last and last.kind == "respec") and last or nil +end + +function ProgressionTimelineClass:ReconcileDelta(before, after, orderedIds) + local prog = self.data + local stages = prog.stages + local respecStage = self:GetActiveRespecStage() + + local addedList, removed = diffSnapshots(before, after, orderedIds) + + for _, id in ipairs(removed) do + if not respecStage then + -- author correction: scrub it out of history + scrubIdFromHistory(stages, id) + elseif isValueInArray(respecStage.alloc, id) then + -- added then removed within this same respec: net zero + removeFromArray(respecStage.alloc, id) + else + insertUnique(respecStage.dealloc, id) + end + end + + local function applyAdd(id) + if not respecStage then + if not idInAnyStageAlloc(stages, id) then + local st = newProgressionStage("progress") + st.alloc[1] = id + t_insert(stages, st) + end + elseif removeFromArray(respecStage.dealloc, id) then + -- re-allocating a node this respec refunded cancels the refund + elseif not idInAnyStageAlloc(stages, id) then + t_insert(respecStage.alloc, id) + end + end + for _, id in ipairs(addedList) do applyAdd(id) end + + self:Normalize() +end + +-- Allocated ids in connected leveling order: greedy walk via node.linked so every +-- prefix stays connected. Priority: authored order, then pathDist, then id. +function ProgressionTimelineClass:OrderedAllocIds(allocIds, preferOrder) + local seen, pref = { }, { } + if preferOrder then + for _, st in ipairs(self.data.stages) do + for _, id in ipairs(st.alloc) do + if allocIds[id] and not seen[id] then seen[id] = true t_insert(pref, id) end + end + end + end + local rest = { } + for id in pairs(allocIds) do if not seen[id] then t_insert(rest, id) end end + table.sort(rest, function(a, b) + local na, nb = self.spec:TimelineNode(a), self.spec:TimelineNode(b) + local da, db = na and na.pathDist or 0, nb and nb.pathDist or 0 + if da ~= db then return da < db end + return a < b + end) + for _, id in ipairs(rest) do t_insert(pref, id) end + + -- Seed reach with the allocated connection roots + local reach, placed, ordered = { }, { }, { } + for _, id in ipairs(self.spec:TimelineFixedAllocIds()) do reach[id] = true end + local function adjacentToReach(id) + local node = self.spec:TimelineNode(id) + if not node or not node.linked then return false end + for _, other in ipairs(node.linked) do + if other.id and reach[other.id] then return true end + end + return false + end + local progress = true + while progress do + progress = false + for _, id in ipairs(pref) do + if not placed[id] and adjacentToReach(id) then + placed[id] = true + reach[id] = true + t_insert(ordered, id) + progress = true + end + end + end + -- Disconnected leftovers keep priority order + for _, id in ipairs(pref) do + if not placed[id] then t_insert(ordered, id) end + end + return ordered +end + +-- Flat rebuild: one progress entry per node, drops respec blocks (divergence recovery) +function ProgressionTimelineClass:RebuildStagesFromTree(preferOrder) + local ids = self:OrderedAllocIds(self.spec:SnapshotAllocIds(), preferOrder) + local stages = { } + for _, id in ipairs(ids) do + local st = newProgressionStage("progress") + st.alloc[1] = id + t_insert(stages, st) + end + self.data.stages = stages +end + +-- Heal a replay-vs-tree divergence: append missing allocated ids in connected order, +-- keeping recorded order + respec; only a hopeless case falls back to a full rebuild. +function ProgressionTimelineClass:ReconcileIncremental() + local prog = self.data + local allocIds = self.spec:SnapshotAllocIds() + local present = self:StateAt(#prog.stages) + local missing = { } + for id in pairs(allocIds) do if not present[id] then missing[id] = true end end + if next(missing) then + for _, id in ipairs(self:OrderedAllocIds(allocIds, true)) do + if missing[id] then + local st = newProgressionStage("progress") + st.alloc[1] = id + t_insert(prog.stages, st) + end + end + end + self:PruneEmptyStages() + if not setsEqual(self:StateAt(#prog.stages), allocIds) then + self:RebuildStagesFromTree(true) + prog.respecOpen = false + end +end + +-- Enforce the invariant: stages replay to the real allocated set; drop empty tree, +-- non-meaningful respec blocks and orphaned entries; rebuild per-node if still off. +function ProgressionTimelineClass:Normalize() + local prog = self.data + if not (prog and prog.enabled) then return end + local allocIds = self.spec:SnapshotAllocIds() + + if next(allocIds) == nil then + -- Keep an open respec block mid-recording so further edits group into it + if not prog.respecOpen and #prog.stages > 0 then prog.stages = { } end + return + end + + -- A respec survives only if it refunds a node allocated before it AND adds one kept in + -- the final tree; a refund-only/orphan block is clutter. Open block exempt. cur = state before k. + local cur, dropRespec = { }, { } + for k = 1, #prog.stages do + local st = prog.stages[k] + if st.kind == "respec" and not (prog.respecOpen and k == #prog.stages) then + local refundsReal, addsKept = false, false + for _, id in ipairs(st.dealloc) do if cur[id] then refundsReal = true break end end + for _, id in ipairs(st.alloc) do if allocIds[id] then addsKept = true break end end + if not (refundsReal and addsKept) then dropRespec[k] = true end + end + for _, id in ipairs(st.alloc) do cur[id] = true end + for _, id in ipairs(st.dealloc) do cur[id] = nil end + end + for k = #prog.stages, 1, -1 do + if dropRespec[k] then t_remove(prog.stages, k) end + end + + local refunded = { } + for _, st in ipairs(prog.stages) do + if st.kind == "respec" then + for _, id in ipairs(st.dealloc) do refunded[id] = true end + end + end + for _, st in ipairs(prog.stages) do + if st.kind ~= "respec" then + for j = #st.alloc, 1, -1 do + local id = st.alloc[j] + if not allocIds[id] and not refunded[id] then t_remove(st.alloc, j) end + end + end + end + self:PruneEmptyStages() + + -- If stages no longer replay to the allocated tree, heal incrementally + if not setsEqual(self:StateAt(#prog.stages), allocIds) then + self:ReconcileIncremental() + end +end + +-- Resync after a wholesale rebuild (load/undo/convert/class switch); no-op if off or mid-scrub +function ProgressionTimelineClass:ReconcileFromTree() + local prog = self.data + if not (prog and prog.enabled) or self.spec:IsApplyingTimelineState() then return end + prog.respecOpen = false + prog.scrubStage = nil + + if #prog.stages == 0 then + self:RebuildStagesFromTree(false) + return + end + -- Drop ids no longer timeline-relevant; ids absent from the tree are left untouched + for _, st in ipairs(prog.stages) do + for k = #st.alloc, 1, -1 do + local node = self.spec:TimelineNode(st.alloc[k]) + if node and not self.spec:IsTimelineRelevant(node) then t_remove(st.alloc, k) end + end + for k = #st.dealloc, 1, -1 do + local node = self.spec:TimelineNode(st.dealloc[k]) + if node and not self.spec:IsTimelineRelevant(node) then t_remove(st.dealloc, k) end + end + end + self:Normalize() +end + +function ProgressionTimelineClass:Disable() + self:_resetData(newProgression(false)) +end + +function ProgressionTimelineClass:Reset() + local prog = self.data + if not prog then return end + prog.stages = { } + prog.scrubStage = nil + prog.respecOpen = false + self._finalState = nil +end + +-- Toggle a respec block; while open refunds + re-allocs group into one stage +function ProgressionTimelineClass:ToggleRespec() + local prog = self.data + if not (prog and prog.enabled) then return end + self:ScrubToFinal() + if prog.respecOpen then + prog.respecOpen = false + self:PruneEmptyStages() + else + t_insert(prog.stages, newProgressionStage("respec")) + prog.respecOpen = true + end +end + +-- Rebuild the allocated set to stage i (nil/last = final) +function ProgressionTimelineClass:ScrubToStage(i) + local prog = self.data + if not (prog and prog.enabled) then return end + if prog.respecOpen then + -- Scrubbing ends recording: close any open respec block + prog.respecOpen = false + self:PruneEmptyStages() + end + local n = #prog.stages + if i == nil or i >= n then i = nil end + if i == prog.scrubStage then return end + local hashList = { } + for _, id in ipairs(self.spec:TimelineFixedAllocIds()) do + t_insert(hashList, id) + end + for id in pairs(self:StateAt(i or n)) do + t_insert(hashList, id) + end + local state = self._finalState + if not state then + state = self.spec:CaptureTimelineSnapshot() + if prog.scrubStage == nil then + self._finalState = state + end + end + self.spec:ApplyTimelineState(state, hashList) + prog.scrubStage = i + if i == nil then + self._finalState = nil + end + self.spec:RequestRecompute() +end + +function ProgressionTimelineClass:ScrubToFinal() + local prog = self.data + if prog and prog.enabled and prog.scrubStage ~= nil then + self:ScrubToStage(nil) + end +end + +-- Enable and seed from the current allocation +function ProgressionTimelineClass:Enable() + local prog = self.data + if not prog or prog.enabled then return end + prog.enabled = true + prog.version = 1 + prog.scrubStage = nil + prog.respecOpen = false + prog.stages = { } + self._finalState = nil + self:ReconcileFromTree() +end + +-- Enable only for a genuinely new build (never loaded/imported) +function ProgressionTimelineClass:EnableIfEligible() + if self.spec:IsTimelineEligible() then + self:Enable() + end +end + +function ProgressionTimelineClass:IsEnabled() + local prog = self.data + return prog and prog.enabled or false +end + +function ProgressionTimelineClass:IsScrubbed() + local prog = self.data + return prog and prog.enabled and prog.scrubStage ~= nil or false +end + +function ProgressionTimelineClass:GetStage(i) + local prog = self.data + return prog and prog.stages[i] or nil +end + +-- nil when off (caller skips the element) +function ProgressionTimelineClass:Serialize() + local prog = self.data + if not (prog and prog.enabled) then return nil end + local progEl = { elem = "Progression", attrib = { version = tostring(prog.version or 1) } } + for _, st in ipairs(prog.stages) do + t_insert(progEl, { + elem = "Stage", + attrib = { + kind = st.kind or "progress", + alloc = table.concat(st.alloc, ","), + dealloc = table.concat(st.dealloc, ","), + } + }) + end + return progEl +end + +-- Rebuild from a element; nil => legacy/imported build +function ProgressionTimelineClass:Deserialize(progEl) + if not progEl then + self:EnableIfEligible() + return + end + local ver = tonumber(progEl.attrib.version) or 1 + if ver > 1 then + ConPrintf("[PassiveSpec] Unsupported progression version %s; timeline disabled for this tree.", tostring(ver)) + self:_resetData(newProgression(false)) + return + end + local stages = { } + for _, child in ipairs(progEl) do + if child.elem == "Stage" then + -- legacy level/label attrs ignored + local st = newProgressionStage(child.attrib.kind) + for id in (child.attrib.alloc or ""):gmatch("%d+") do t_insert(st.alloc, tonumber(id)) end + for id in (child.attrib.dealloc or ""):gmatch("%d+") do t_insert(st.dealloc, tonumber(id)) end + t_insert(stages, st) + end + end + self:_resetData(newProgression(true)) + self.data.stages = stages + self:ReconcileFromTree() +end diff --git a/src/Classes/TimelineControl.lua b/src/Classes/TimelineControl.lua new file mode 100644 index 0000000000..2d91982e13 --- /dev/null +++ b/src/Classes/TimelineControl.lua @@ -0,0 +1,373 @@ +-- Path of Building +-- +-- Class: Timeline Control +-- Passive-tree progression scrubber; scrubbing really re-allocates nodes. +-- Scrub buttons are sibling controls on TreeTab. +-- +local t_insert = table.insert +local m_min = math.min +local m_max = math.max +local m_floor = math.floor +local m_ceil = math.ceil + +-- 0/1 regular point + weapon-set bucket for a node (ascendancy = no point) +local function pointBucket(node) + if not node or node.ascendancyName then return 0, 0, 0 end + return 1, node.allocMode == 1 and 1 or 0, node.allocMode == 2 and 1 or 0 +end + +-- Must track buildMode:EstimatePlayerProgress +local function levelFromCounts(build, reg, ws1, ws2, extra) + return build:EstimateLevelForPoints(reg - extra - m_min(ws1, ws2)) +end + +-- node -> category for colouring / notable-jump +local function nodeCategory(node) + if not node then return "minor" end + if node.ascendancyName then return "ascend" end + if node.type == "Keystone" then return "keystone" end + if node.type == "Notable" then return "notable" end + if node.allocMode and node.allocMode ~= 0 then return "weapon" end + return "minor" +end + +-- category -> display (colour, text code, label); one row each so none half-added +local catDef = { + minor = { rgb = { 0.70, 0.70, 0.70 }, code = "^xB0B0B0", label = "Minor" }, + weapon = { rgb = { 1, 0.55, 0.15 }, code = "^xFF8C26", label = "Weapon-set" }, + notable = { rgb = { 0.45, 0.55, 1 }, code = "^x7088FF", label = "Notable" }, + keystone = { rgb = { 1, 0.85, 0.2 }, code = "^xFFD933", label = "Keystone" }, + ascend = { rgb = { 0.30, 0.85, 0.35 }, code = "^x4CD959", label = "Ascendancy" }, + respec = { rgb = { 0.90, 0.20, 0.20 }, code = "^xE53333", label = "Respec" }, +} +-- Safe lookup: Draw runs every frame +local function catOf(c) + return catDef[c] or catDef.minor +end + +local TimelineControlClass = newClass("TimelineControl", "Control", "TooltipHost", function(self, anchor, rect, treeTab) + self.Control(anchor, rect) + self.TooltipHost() + self.treeTab = treeTab + self.build = treeTab.build + self.hoverStage = nil + self.tooltipFunc = function(tooltip, stageIndex) + self:StageTooltip(tooltip, stageIndex) + end +end) + +function TimelineControlClass:GetProg() + local spec = self.build.spec + if spec then + local timeline = spec:Progression() + if timeline:IsEnabled() then + return spec, timeline.data, timeline + end + end +end + +-- Character level at stage k; matches buildMode:EstimatePlayerProgress +function TimelineControlClass:LevelAt(spec, k) + local count, ws1, ws2 = 0, 0, 0 + for id in pairs(spec:Progression():StateAt(k)) do + local r, w1, w2 = pointBucket(spec.nodes[id]) + count, ws1, ws2 = count + r, ws1 + w1, ws2 + w2 + end + local extra = self.build.calcsTab and self.build.calcsTab.mainOutput + and self.build.calcsTab.mainOutput.ExtraPoints or 0 + return levelFromCounts(self.build, count, ws1, ws2, extra) +end + +function TimelineControlClass:StageCategory(spec, stage) + if stage.kind == "respec" then return "respec" end + return nodeCategory(spec.nodes[stage.alloc[1]]) +end + +function TimelineControlClass:StageNode(spec, stage) + return stage.alloc[1] and spec.nodes[stage.alloc[1]] or nil +end + +function TimelineControlClass:StageTooltip(tooltip, stageIndex) + tooltip:Clear() + local spec, prog = self:GetProg() + if not prog then return end + local stage = prog.stages[stageIndex] + if not stage then return end + local lvl = (self._frameLevels and self._frameLevels[stageIndex]) or self:LevelAt(spec, stageIndex) + if stage.kind == "respec" then + tooltip:AddLine(16, catDef.respec.code.."Respec - entry "..stageIndex) + tooltip:AddSeparator(6) + tooltip:AddLine(14, "^7Refunds "..#stage.dealloc..", adds "..#stage.alloc) + tooltip:AddLine(14, "^7~ Level "..lvl) + else + local node = self:StageNode(spec, stage) + local cat = self:StageCategory(spec, stage) + tooltip:AddLine(16, catOf(cat).code..(node and node.dn or "?")) + tooltip:AddSeparator(6) + tooltip:AddLine(14, catOf(cat).code..catOf(cat).label.."^7 ~ Level "..lvl) + if node and node.allocMode and node.allocMode ~= 0 then + tooltip:AddLine(14, catDef.weapon.code.."Weapon Set "..node.allocMode) + end + tooltip:AddLine(13, "^7Entry "..stageIndex.." / "..#prog.stages) + end +end + +-- Apply a scrub to index i (0 = nothing taken, #stages = final tree) +function TimelineControlClass:ScrubTo(i) + local _, prog, tl = self:GetProg() + if not prog then return end + -- ScrubToStage closes any open respec block itself. + local n = #prog.stages + i = m_max(0, m_min(i, n)) + tl:ScrubToStage(i >= n and nil or i) +end + +-- End a drag: apply the deferred target, clear state +function TimelineControlClass:CommitDrag() + local _, prog = self:GetProg() + if prog and self.pendingScrub and self.pendingScrub ~= self:CursorIndex(prog) then + self:ScrubTo(self.pendingScrub) + end + self.dragging = false + self.pendingScrub = nil +end + +function TimelineControlClass:CursorIndex(prog) + return prog.scrubStage or #prog.stages +end + +function TimelineControlClass:ScrubStep(dir) + local _, prog = self:GetProg() + if not prog then return end + self:ScrubTo(self:CursorIndex(prog) + dir) +end + +function TimelineControlClass:StepNotable(dir) + local spec, prog = self:GetProg() + if not prog then return end + local n = #prog.stages + local i = self:CursorIndex(prog) + repeat + i = i + dir + if i <= 0 or i >= n then break end + until self:StageCategory(spec, prog.stages[i]) ~= "minor" + self:ScrubTo(i) +end + +function TimelineControlClass:IsMouseOver() + return self:IsShown() and self:IsMouseInBounds() +end + +-- Track geometry; ty is the bar baseline (markers above, level labels below) +function TimelineControlClass:TrackGeom() + local x, y = self:GetPos() + local width, height = self:GetSize() + local tx = x + 12 + local tw = width - 24 + local ty = y + height - 22 + return tx, ty, tw +end + +function TimelineControlClass:StageAtX(cx) + local _, prog = self:GetProg() + if not prog or #prog.stages == 0 then return nil end + local tx, _, tw = self:TrackGeom() + local n = #prog.stages + local f = m_max(0, m_min((cx - tx) / tw, 1)) + -- ceil: cursor is in cell i when f in ((i-1)/n, i/n] + return m_max(1, m_min(m_ceil(f * n), n)) +end + +function TimelineControlClass:Draw(viewPort) + local x, y = self:GetPos() + local width, height = self:GetSize() + local spec, prog = self:GetProg() + + SetDrawLayer(nil, 0) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, x, y, width, height) + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, x + 1, y + 1, width - 2, height - 2) + + if not prog then + SetDrawColor(1, 1, 1) + DrawString(x + 8, y + 6, "LEFT", 14, "VAR", "^7No passive progression for this tree.") + return + end + + local n = #prog.stages + local curIdx = self:CursorIndex(prog) + -- While dragging the bar tracks the target; real re-alloc catches up on settle/mouse-up + local viewIdx = (self.dragging and self.pendingScrub) or curIdx + local cx = select(1, GetCursorPos()) + local hover = self:IsMouseInBounds() and self:StageAtX(cx) or nil + self.hoverStage = hover + local infoIdx = m_max(1, m_min(hover or curIdx, m_max(1, n))) + + -- One pass building per-stage cat/level/L10; readout, bar, tooltip all reuse these + local cats, levels, grid = { }, { }, { } + if n > 0 then + local extra = self.build.calcsTab and self.build.calcsTab.mainOutput + and self.build.calcsTab.mainOutput.ExtraPoints or 0 + local present, reg, ws1, ws2 = { }, 0, 0, 0 + local function addId(id, sign) + local r, w1, w2 = pointBucket(spec.nodes[id]) + reg = reg + r * sign + ws1 = ws1 + w1 * sign + ws2 = ws2 + w2 * sign + end + local lastBucket = 0 + for i = 1, n do + local stage = prog.stages[i] + for _, id in ipairs(stage.alloc) do if not present[id] then present[id] = true addId(id, 1) end end + for _, id in ipairs(stage.dealloc) do if present[id] then present[id] = nil addId(id, -1) end end + levels[i] = levelFromCounts(self.build, reg, ws1, ws2, extra) + cats[i] = self:StageCategory(spec, stage) + local bucket = m_floor(levels[i] / 10) + if bucket > lastBucket then + lastBucket = bucket + t_insert(grid, { i = i, label = "L" .. (bucket * 10) }) + end + end + end + self._frameLevels = n > 0 and levels or nil + + local readout + if n == 0 then + readout = "^7Allocate nodes to build the passive progression" + else + local stage = prog.stages[infoIdx] + local cat, lvl = cats[infoIdx], levels[infoIdx] + if stage.kind == "respec" then + readout = string.format("^7Entry %d / %d %s[Respec]^7 refund %d / add %d ~ Lvl %d", + infoIdx, n, catDef.respec.code, #stage.dealloc, #stage.alloc, lvl) + else + local node = self:StageNode(spec, stage) + local ws = (node and node.allocMode and node.allocMode ~= 0) and (" "..catDef.weapon.code.."[WS"..node.allocMode.."]") or "" + readout = string.format("^7Point %d / %d %s%s ^7- %s%s%s ^7~ Lvl %d", + infoIdx, n, catOf(cat).code, node and node.dn or "?", + catOf(cat).code, catOf(cat).label, ws, lvl) + end + end + if prog.respecOpen then + readout = readout .. " ^xDD4444recording respec" + elseif prog.scrubStage ~= nil then + readout = readout .. " ^x33AAFF(scrubbed back - edits insert here)" + end + SetDrawColor(1, 1, 1) + DrawString(x + 12, y + 5, "LEFT", 16, "VAR", readout) + + do + local order = { "minor", "notable", "keystone", "ascend", "weapon", "respec" } + local lx = x + width - 10 + for j = #order, 1, -1 do + local c = order[j] + lx = lx - DrawStringWidth(12, "VAR", catOf(c).label) + SetDrawColor(1, 1, 1) + DrawString(lx, y + 7, "LEFT", 12, "VAR", "^7"..catOf(c).label) + lx = lx - 8 + local rgb = catOf(c).rgb + SetDrawColor(rgb[1], rgb[2], rgb[3]) + DrawImage(nil, lx, y + 8, 7, 9) + lx = lx - 14 + end + end + + -- progress bar: one cell per node + local tx, ty, tw = self:TrackGeom() + local barTop = ty - 20 + local barH = ty - barTop + SetDrawColor(0.13, 0.13, 0.13) + DrawImage(nil, tx, barTop, tw, barH) + if n > 0 then + for i = 1, n do + local x0 = m_floor(tx + tw * (i - 1) / n + 0.5) + local x1 = m_floor(tx + tw * i / n + 0.5) + local w = m_max(1, x1 - x0) + local rgb = catOf(cats[i]).rgb + local dim = (i > viewIdx) and 0.38 or 1 + SetDrawColor(rgb[1] * dim, rgb[2] * dim, rgb[3] * dim) + DrawImage(nil, x0, barTop, w, barH) + SetDrawColor(0, 0, 0) + DrawImage(nil, x1 - 1, barTop, 1, barH) + if cats[i] == "respec" and w >= 8 then + SetDrawColor(1, 1, 1) + DrawString(x0 + w / 2, barTop + barH / 2 - 6, "CENTER", 11, "VAR", "^7R") + end + end + for _, g in ipairs(grid) do + local gx = m_floor(tx + tw * (g.i - 1) / n + 0.5) + SetDrawColor(1, 1, 1) + DrawImage(nil, gx, barTop, 1, barH) + DrawString(gx + 2, ty + 4, "LEFT", 11, "VAR", "^8" .. g.label) + end + if hover then + local hx0 = m_floor(tx + tw * (hover - 1) / n + 0.5) + local hx1 = m_floor(tx + tw * hover / n + 0.5) + SetDrawColor(0.6, 0.85, 1) + DrawImage(nil, hx0, barTop - 2, m_max(1, hx1 - hx0), 2) + DrawImage(nil, hx0, ty, m_max(1, hx1 - hx0), 2) + end + local kx = m_floor(tx + tw * viewIdx / n + 0.5) + SetDrawColor(1, 1, 1) + DrawImage(nil, kx - 1, barTop - 4, 3, barH + 8) + main:DrawArrow(kx, barTop - 5, 9, 6, "DOWN") + end + + if self.dragging and IsKeyDown("LEFTBUTTON") then + local seg = self:StageAtX(select(1, GetCursorPos())) + if seg then + if seg == self.pendingScrub then + -- settled: do the real re-allocation + if seg ~= curIdx then self:ScrubTo(seg) end + else + -- still moving: defer the recompute + self.pendingScrub = seg + end + end + elseif self.dragging then + self:CommitDrag() + end + + if hover then + -- anchor tooltip at cursor column so it flips above the bottom bar + local cuX = select(1, GetCursorPos()) + SetDrawLayer(nil, 100) + self:DrawTooltip(cuX, y, 8, height, viewPort, hover) + SetDrawLayer(nil, 0) + end +end + +function TimelineControlClass:OnKeyDown(key) + if not self:IsShown() or not self:IsEnabled() then return end + if key == "LEFTBUTTON" then + -- Not over the track: release focus for the sibling buttons + if not self:IsMouseInBounds() then + self.dragging = false + return + end + local seg = self:StageAtX(select(1, GetCursorPos())) + if seg then + self.dragging = true + self.pendingScrub = seg + self:ScrubTo(seg) + end + end + return self +end + +function TimelineControlClass:OnKeyUp(key) + if not self:IsShown() or not self:IsEnabled() then return end + if key == "LEFTBUTTON" then + -- Release focus on button-up; apply the final dragged position + self:CommitDrag() + return + elseif self:IsMouseInBounds() and (key == "WHEELUP" or key == "RIGHT") then + self:ScrubStep(1) + elseif self:IsMouseInBounds() and (key == "WHEELDOWN" or key == "LEFT") then + self:ScrubStep(-1) + else + return + end + return self +end diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 48743874fd..de73852fca 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -41,6 +41,7 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) self.specList = { } self.specList[1] = new("PassiveSpec", build, latestTreeVersion) + self.specList[1]:Progression():EnableIfEligible() self:SetActiveSpec(1) self:SetCompareSpec(1) @@ -145,6 +146,7 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) wipeTable(self.build.spec.hashOverrides) -- reset attribute nodes to "Attribute" self.build.spec:ResetNodes() self.build.spec:BuildAllDependsAndPaths() + self.build.spec:Progression():Reset() self.build.spec:AddUndoState() self.build.buildFlag = true main:ClosePopup() @@ -263,6 +265,9 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) self.controls.powerReport = new("ButtonControl", { "LEFT", self.controls.treeHeatMapStatSelect, "RIGHT" }, { 8, 0, 150, 20 }, function() return self.controls.powerReportList.shown and "Hide Power Report" or "Show Power Report" end, function() self.controls.powerReportList.shown = not self.controls.powerReportList.shown + if self.controls.powerReportList.shown then + self.showTimeline = false + end end) -- Power Report List @@ -277,6 +282,66 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build) end end) self.controls.powerReportList.shown = false + + -- Timeline drawer; mutually exclusive with the power report drawer + self.showTimeline = true + self.controls.timelineToggle = new("ButtonControl", { "LEFT", self.controls.nodePowerMaxDepthSelect, "RIGHT", 20, 0 }, { 0, 0, 200, 20 }, + function() return self.showTimeline and "Hide Passive Progression" or "Show Passive Progression" end, function() + self.showTimeline = not self.showTimeline + if self.showTimeline then + self.controls.powerReportList.shown = false + end + end) + self.controls.timelineToggle.tooltipText = "Show or hide the passive progression for this tree." + self.controls.timelineToggle.shown = function() + local spec = self.build.spec + return spec and spec:Progression():IsEnabled() and not self.viewer.showHeatMap + end + + local timelineYPos = self.controls.treeHeatMap.y == 0 and self.controls.specSelect.height + 4 or self.controls.specSelect.height * 2 + 8 + self.controls.timeline = new("TimelineControl", { "TOPLEFT", self.controls.specSelect, "BOTTOMLEFT" }, { 0, timelineYPos, 700, 62 }, self) + self.controls.timeline.shown = false + + -- Scrubber buttons; auto-hidden with the strip via the anchor chain + self.controls.timelineFirst = new("ButtonControl", { "TOPLEFT", self.controls.timeline, "BOTTOMLEFT", 0, 12 }, { 0, 0, 26, 20 }, "|<", function() + self.controls.timeline:ScrubTo(0) + end) + self.controls.timelineFirst.tooltipText = "Jump to the start (nothing allocated)" + self.controls.timelinePrevNotable = new("ButtonControl", { "LEFT", self.controls.timelineFirst, "RIGHT" }, { 4, 0, 28, 20 }, "<<", function() + self.controls.timeline:StepNotable(-1) + end) + self.controls.timelinePrevNotable.tooltipText = "Previous notable / keystone / ascendancy / respec" + self.controls.timelinePrev = new("ButtonControl", { "LEFT", self.controls.timelinePrevNotable, "RIGHT" }, { 4, 0, 26, 20 }, "<", function() + self.controls.timeline:ScrubStep(-1) + end) + self.controls.timelinePrev.tooltipText = "Step back one node" + self.controls.timelineNext = new("ButtonControl", { "LEFT", self.controls.timelinePrev, "RIGHT" }, { 4, 0, 26, 20 }, ">", function() + self.controls.timeline:ScrubStep(1) + end) + self.controls.timelineNext.tooltipText = "Step forward one node" + self.controls.timelineNextNotable = new("ButtonControl", { "LEFT", self.controls.timelineNext, "RIGHT" }, { 4, 0, 28, 20 }, ">>", function() + self.controls.timeline:StepNotable(1) + end) + self.controls.timelineNextNotable.tooltipText = "Next notable / keystone / ascendancy / respec" + self.controls.timelineLive = new("ButtonControl", { "LEFT", self.controls.timelineNextNotable, "RIGHT" }, { 4, 0, 26, 20 }, ">|", function() + self.controls.timeline:ScrubTo(1 / 0) + end) + self.controls.timelineLive.tooltipText = "Jump to the final tree" + self.controls.timelineRespec = new("ButtonControl", { "LEFT", self.controls.timelineLive, "RIGHT" }, { 14, 0, 110, 20 }, + function() + local _, prog = self.controls.timeline:GetProg() + return prog and prog.respecOpen and "End Respec" or "Mark Respec" + end, function() + local spec, prog, tl = self.controls.timeline:GetProg() + if prog then + tl:ToggleRespec() + spec:AddUndoState() + self.build.buildFlag = true + end + end) + self.controls.timelineRespec.tooltipText = "Start or end a respec. Refunds and re-allocations\z + \nmade while active are grouped into a single entry." + -- Progress callback from the CalcsTab power builder coroutine self.powerBuilderToastActive = false self.lastProgressToastUpdate = 0 @@ -400,9 +465,11 @@ function TreeTabClass:Draw(viewPort, inputEvents) linesHeight = 0 self.controls.treeSearch:SetAnchor("LEFT", self.controls.versionSelect, "RIGHT", 8, 0) self.controls.powerReportList:SetAnchor("TOPLEFT", self.controls.specSelect, "BOTTOMLEFT", 0, self.controls.specSelect.height + 6) + self.controls.timeline:SetAnchor("TOPLEFT", self.controls.specSelect, "BOTTOMLEFT", 0, self.controls.specSelect.height + 6) else self.controls.treeSearch:SetAnchor("TOPLEFT", self.controls.specSelect, "BOTTOMLEFT", 0, 4) self.controls.powerReportList:SetAnchor("TOPLEFT", self.controls.treeSearch, "BOTTOMLEFT", 0, self.controls.treeSearch.height + 6) + self.controls.timeline:SetAnchor("TOPLEFT", self.controls.treeSearch, "BOTTOMLEFT", 0, self.controls.treeSearch.height + 6) end -- Check second line @@ -413,6 +480,7 @@ function TreeTabClass:Draw(viewPort, inputEvents) linesHeight = linesHeight * 2 self.controls.treeHeatMap:SetAnchor("TOPLEFT", self.controls.treeSearch, "BOTTOMLEFT", 124, 4) self.controls.powerReportList:SetAnchor("TOPLEFT", self.controls.treeHeatMap, "BOTTOMLEFT", -124, self.controls.treeHeatMap.height + 6) + self.controls.timeline:SetAnchor("TOPLEFT", self.controls.treeHeatMap, "BOTTOMLEFT", -124, self.controls.treeHeatMap.height + 6) end -- determine positions for convert line of controls @@ -427,7 +495,17 @@ function TreeTabClass:Draw(viewPort, inputEvents) self.controls.specConvertText:SetAnchor("BOTTOMLEFT", self.controls.specSelect, "TOPLEFT", 0, -38) end - local bottomDrawerHeight = self.controls.powerReportList.shown and 194 or 0 + -- Size the timeline drawer to fit within the tree viewport + local spec = self.build.spec + local timelineActive = self.showTimeline and not self.controls.powerReportList.shown + and spec and spec:Progression():IsEnabled() + self.controls.timeline.shown = timelineActive and true or false + local timelineDrawerHeight = 0 + if timelineActive then + self.controls.timeline.width = viewPort.width - 16 + timelineDrawerHeight = 140 + end + local bottomDrawerHeight = (self.controls.powerReportList.shown and 194 or 0) + timelineDrawerHeight self.controls.specSelect.y = -bottomDrawerHeight - linesHeight local treeViewPort = { x = viewPort.x, y = viewPort.y, width = viewPort.width, height = viewPort.height - (self.showConvert and 64 + bottomDrawerHeight + linesHeight or 32 + bottomDrawerHeight + linesHeight)} @@ -435,6 +513,8 @@ function TreeTabClass:Draw(viewPort, inputEvents) self.viewer:Focus(self.jumpToX, self.jumpToY, treeViewPort, self.build) self.jumpToNode = false end + self:UpdateTimelineHighlights() + self.viewer.compareSpec = self.isComparing and self.specList[self.activeCompareSpec] or nil self.viewer:Draw(self.build, treeViewPort, inputEvents) @@ -532,8 +612,26 @@ function TreeTabClass:Save(xml) end end +-- Highlight the hovered timeline stage's nodes on the tree +function TreeTabClass:UpdateTimelineHighlights() + self.viewer.progressionHighlight, self.viewer.progressionHighlightDealloc = nil, nil + if not (self.controls.timeline.shown and self.controls.timeline.hoverStage) then return end + local spec = self.build.spec + local stage = spec and spec:Progression():GetStage(self.controls.timeline.hoverStage) + if not stage then return end + local allocSet, deallocSet = { }, { } + for _, id in ipairs(stage.alloc) do allocSet[id] = true end + for _, id in ipairs(stage.dealloc) do deallocSet[id] = true end + self.viewer.progressionHighlight = allocSet + self.viewer.progressionHighlightDealloc = deallocSet +end + function TreeTabClass:SetActiveSpec(specId) local prevSpec = self.build.spec + if prevSpec then + -- Don't leave a spec stuck in a scrub preview when switching away from it + prevSpec:Progression():ScrubToFinal() + end self.activeSpec = m_min(specId, #self.specList) local curSpec = self.specList[self.activeSpec] data.setJewelRadiiGlobally(curSpec.treeVersion) @@ -573,7 +671,10 @@ end function TreeTabClass:SetCompareSpec(specId) self.activeCompareSpec = m_min(specId, #self.specList) local curSpec = self.specList[self.activeCompareSpec] - + if curSpec then + -- Don't compare against a spec frozen mid-scrub; show its final tree + curSpec:Progression():ScrubToFinal() + end self.compareSpec = curSpec end @@ -837,13 +938,13 @@ function TreeTabClass:ModifyAttributePopup(hoverNode) controls.save = new("ButtonControl", nil, {-50, 65, 80, 20}, "Allocate", function() spec:SwitchAttributeNode(hoverNode.id, controls.attrSelect.selIndex) spec.attributeIndex = controls.attrSelect.selIndex - spec:AllocNode(hoverNode, spec.tracePath and hoverNode == spec.tracePath[#spec.tracePath] and spec.tracePath) + spec:AllocNodeRecorded(hoverNode, spec.tracePath and hoverNode == spec.tracePath[#spec.tracePath] and spec.tracePath) spec:AddUndoState() self.build.buildFlag = true main:ClosePopup() end) controls.close = new("ButtonControl", nil, {50, 65, 80, 20}, "Cancel", function() - spec:DeallocNode(hoverNode) + spec:Progression():Capture(nil, function() spec:DeallocNode(hoverNode) end) main:ClosePopup() end) controls.hotkeyTooltip = new("LabelControl", nil, {0, 100, 0, 16}, @@ -866,7 +967,7 @@ function TreeTabClass:SaveMasteryPopup(node, listControl) self.build.spec.tree:ProcessStats(node) self.build.spec.masterySelections[node.id] = effect.id if not node.alloc then - self.build.spec:AllocNode(node, self.viewer.tracePath and node == self.viewer.tracePath[#self.viewer.tracePath] and self.viewer.tracePath) + self.build.spec:AllocNodeRecorded(node, self.viewer.tracePath and node == self.viewer.tracePath[#self.viewer.tracePath] and self.viewer.tracePath) end self.build.spec:AddUndoState() self.modFlag = true diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index fca8789b4f..273a8e0a0e 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -47,6 +47,8 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin self.viewMode = "TREE" self.characterLevel = m_min(m_max(main.defaultCharLevel or 1, 1), 100) self.targetVersion = liveTargetVersion + -- Only genuinely new builds get a timeline (not loaded/imported/converted) + self.timelineEligible = not dbFileName and not buildXML and not convertBuild self.characterLevelAutoMode = main.defaultCharLevel == 1 or main.defaultCharLevel == nil if buildXML then if self:LoadDB(buildXML, "Unnamed build") then @@ -829,6 +831,17 @@ function buildMode:SyncLoadouts() return treeList, itemList, skillList, configList end +-- Character level for `points` passive points; caller pre-nets bonus/weapon-set points +function buildMode:EstimateLevelForPoints(points) + if not self.acts or not self.acts[1] then return 1 end + local level, act = 1, 0 + repeat + act = act + 1 + level = m_min(m_max(points + 1 - self.acts[act].questPoints, self.acts[act].level), 100) + until act == self.maxActs or level <= self.acts[act + 1].level + return level +end + function buildMode:EstimatePlayerProgress() local PointsUsed, AscUsed, SecondaryAscUsed, socketsUsed, weaponSet1Used, weaponSet2Used = self.spec:CountAllocNodes() local extra = self.calcsTab.mainOutput and self.calcsTab.mainOutput.ExtraPoints or 0 @@ -836,11 +849,8 @@ function buildMode:EstimatePlayerProgress() local extraWeaponSets = self.calcsTab.mainOutput and self.calcsTab.mainOutput.PassivePointsToWeaponSetPoints or 0 local usedMax, ascMax, secondaryAscMax, level, act = 99 + maxWeaponSets + extra, 8, 8, 1, 0 - repeat - act = act + 1 - level = m_min(m_max(PointsUsed + 1 - self.acts[act].questPoints - extra - m_min(weaponSet1Used, weaponSet2Used), self.acts[act].level), 100) - until act == self.maxActs or level <= self.acts[act + 1].level - + level = self:EstimateLevelForPoints(PointsUsed - extra - m_min(weaponSet1Used, weaponSet2Used)) + if self.characterLevelAutoMode and self.characterLevel ~= level then self.characterLevel = level self.controls.characterLevel:SetText(self.characterLevel) @@ -2228,6 +2238,13 @@ end function buildMode:SaveDB(fileName) local dbXML = { elem = "PathOfBuilding2" } + -- Scrub cursor is transient; persist the final tree + if self.treeTab then + for _, spec in ipairs(self.treeTab.specList) do + spec:Progression():ScrubToFinal() + end + end + -- Save Build section first do local node = { elem = "Build" }