Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
503 changes: 503 additions & 0 deletions spec/System/PassiveProgression_spec.lua

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/Classes/ImportTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 148 additions & 15 deletions src/Classes/PassiveSpec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -209,16 +312,30 @@ function PassiveSpecClass:Load(xml, dbFileName)
elseif url then
self:DecodeURL(url)
end

-- No <Progression> = 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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/Classes/PassiveSpecListControl.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading