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" }