Skip to content

Commit fc0fd7a

Browse files
mcagnionclaude
andcommitted
Fix stat comparison for radius jewels (timeless, Thread of Hope, etc.)
- Fix stat diff tooltip for removing equipped radius jewels: use jewelRadiusIndex instead of hardcoded radius, revert conquered nodes via removeNodes/addNodes override with hashOverrides support - Fix stat diff for removing Thread of Hope / Impossible Escape: remove nodes only reachable through the jewel and their transitive dependents - Add 7 tests covering radius jewel stat comparison Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d126ab7 commit fc0fd7a

2 files changed

Lines changed: 357 additions & 1 deletion

File tree

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
-- Helper: BFS from class start to find and allocate path to nearest jewel socket
2+
local function allocatePathToSocket(spec)
3+
local classStart
4+
for _, node in pairs(spec.allocNodes) do
5+
if node.type == "ClassStart" then
6+
classStart = node
7+
break
8+
end
9+
end
10+
if not classStart then return nil end
11+
12+
local queue = { classStart }
13+
local visited = { [classStart.id] = true }
14+
local parent = { }
15+
local targetSocket
16+
local head = 1
17+
18+
while head <= #queue do
19+
local current = queue[head]
20+
head = head + 1
21+
22+
if current.isJewelSocket then
23+
targetSocket = current
24+
break
25+
end
26+
27+
for _, linked in ipairs(current.linked) do
28+
if not visited[linked.id] and linked.type ~= "Mastery" and linked.type ~= "AscendClassStart" then
29+
visited[linked.id] = true
30+
parent[linked.id] = current
31+
queue[#queue + 1] = linked
32+
end
33+
end
34+
end
35+
36+
if not targetSocket then return nil end
37+
38+
-- Trace path back and allocate all nodes
39+
local current = targetSocket
40+
while current do
41+
current.alloc = true
42+
spec.allocNodes[current.id] = current
43+
current = parent[current.id]
44+
end
45+
46+
return targetSocket
47+
end
48+
49+
-- Helper: find allocated non-socket non-keystone nodes in a socket's radius
50+
local function findAllocatedNodesInRadius(spec, socketNode, radiusIndex)
51+
local result = { }
52+
local inRadius = socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex]
53+
for nodeId in pairs(inRadius or { }) do
54+
local node = spec.nodes[nodeId]
55+
if node and node.alloc and node.type ~= "Socket" and node.type ~= "Keystone"
56+
and node.type ~= "ClassStart" and node.type ~= "AscendClassStart" then
57+
result[#result + 1] = node
58+
end
59+
end
60+
return result
61+
end
62+
63+
-- Helper: index in nodesInRadius for a named jewel radius (e.g. "Large").
64+
-- Reads data.jewelRadius rather than hardcoding the index, so the tests stay
65+
-- correct if the radius table is ever reordered.
66+
local function radiusIndexFor(label)
67+
for index, info in ipairs(data.jewelRadius) do
68+
if info.label == label then
69+
return index
70+
end
71+
end
72+
end
73+
74+
-- Helper: standard test prelude — allocate a path to the nearest jewel socket,
75+
-- run the build pipeline once, and return the spec + socket.
76+
local function setupAllocatedSocket()
77+
local spec = build.spec
78+
local socketNode = allocatePathToSocket(spec)
79+
spec:BuildAllDependsAndPaths()
80+
runCallback("OnFrame")
81+
assert.is_truthy(socketNode, "Should find a jewel socket")
82+
return spec, socketNode
83+
end
84+
85+
-- Helper: install an existing item into a jewel socket, bypassing
86+
-- BuildClusterJewelGraphs. Returns the slot.
87+
local function equipJewelInSocket(item, socketNode)
88+
build.itemsTab:AddItem(item, true)
89+
build.spec.jewels[socketNode.id] = item.id
90+
local slot = build.itemsTab.sockets[socketNode.id]
91+
if slot then
92+
slot.selItemId = item.id
93+
end
94+
return slot
95+
end
96+
97+
-- Helper: minimal Thread of Hope item. Uses "Radius: Variable" + a single
98+
-- variant with "Only affects Passives in Large Ring", which is the real in-game
99+
-- parsing path: the mod sets jewelData.radiusIndex (an annular ring index, not
100+
-- the same as the full-circle index 3 that "Radius: Large" would produce).
101+
local function newThreadOfHope()
102+
return new("Item", "Rarity: UNIQUE\n" ..
103+
"Thread of Hope\n" ..
104+
"Crimson Jewel\n" ..
105+
"Variant: Large Ring\n" ..
106+
"Selected Variant: 1\n" ..
107+
"Radius: Variable\n" ..
108+
"Implicits: 0\n" ..
109+
"Only affects Passives in Large Ring\n" ..
110+
"Passives in Radius can be Allocated without being connected to your Tree\n")
111+
end
112+
113+
-- Helper: equip a Thread of Hope in a socket and return the item.
114+
local function equipThreadOfHope(socketNode)
115+
local item = newThreadOfHope()
116+
equipJewelInSocket(item, socketNode)
117+
return item
118+
end
119+
120+
-- Helper: minimal Lethal Pride item — only the lines the parser needs to
121+
-- populate jewelData.conqueredBy and jewelRadiusIndex. Variants and flavour
122+
-- text are intentionally omitted; the tests exercise behavior, not the parser
123+
-- against the full serialized form.
124+
local function newLethalPride()
125+
return new("Item", "Rarity: UNIQUE\n" ..
126+
"Lethal Pride\n" ..
127+
"Timeless Jewel\n" ..
128+
"Radius: Large\n" ..
129+
"Implicits: 0\n" ..
130+
"Commanded leadership over 10000 warriors under Kaom\n")
131+
end
132+
133+
-- Helper: simulate a Karui Timeless conquest on an allocated node by replacing
134+
-- its modList with a known +100 Life mod. The LUT binary files do not load in
135+
-- the headless test environment, so the real BuildAllDependsAndPaths conquest
136+
-- path cannot run. This must be called *after* runCallback("OnFrame"), or BADP
137+
-- will reset the modList back to the original tree node modList.
138+
local function simulateKaruiConquest(node)
139+
node.conqueredBy = { id = 10000, conqueror = { id = 1, type = "karui" } }
140+
node.modList = new("ModList")
141+
node.modList:NewMod("Life", "BASE", 100, "Timeless Jewel")
142+
end
143+
144+
-- Helper: find the first unallocated, non-Mastery, non-Keystone node in a
145+
-- socket's radius that is reachable through an intuitiveLeapLike jewel.
146+
local function findIntuitiveLeapTarget(spec, socketNode, radiusIndex)
147+
local inRadius = socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex]
148+
for nodeId in pairs(inRadius or { }) do
149+
local node = spec.nodes[nodeId]
150+
if node and not node.alloc and #node.intuitiveLeapLikesAffecting > 0
151+
and node.type ~= "Mastery" and node.type ~= "Keystone" then
152+
return node
153+
end
154+
end
155+
return nil
156+
end
157+
158+
-- Helper: true iff any tooltip line contains the given needle (literal match).
159+
local function tooltipContains(tooltip, needle)
160+
for _, line in ipairs(tooltip.lines) do
161+
if (line.text or ""):find(needle, 1, true) then
162+
return true
163+
end
164+
end
165+
return false
166+
end
167+
168+
describe("TestRadiusJewelStatDiff", function()
169+
before_each(function()
170+
newBuild()
171+
end)
172+
173+
teardown(function() end)
174+
175+
it("Lethal Pride item parses conqueredBy correctly", function()
176+
local item = newLethalPride()
177+
build.itemsTab:AddItem(item, true)
178+
179+
assert.is_truthy(item.jewelData, "Item should have jewelData")
180+
assert.is_truthy(item.jewelData.conqueredBy, "Item should have conqueredBy")
181+
assert.are.equals(10000, item.jewelData.conqueredBy.id)
182+
assert.are.equals("karui", item.jewelData.conqueredBy.conqueror.type)
183+
assert.is_truthy(item.jewelRadiusIndex, "Item should have jewelRadiusIndex")
184+
end)
185+
186+
it("Thread of Hope item parses intuitiveLeapLike correctly", function()
187+
local item = newThreadOfHope()
188+
build.itemsTab:AddItem(item, true)
189+
190+
assert.is_truthy(item.jewelData, "Item should have jewelData")
191+
assert.is_truthy(item.jewelData.intuitiveLeapLike, "Item should have intuitiveLeapLike")
192+
assert.is_truthy(item.jewelRadiusIndex, "Item should have jewelRadiusIndex")
193+
end)
194+
195+
it("calcFunc removeNodes/addNodes changes output for allocated nodes", function()
196+
local spec, socketNode = setupAllocatedSocket()
197+
198+
local nodesInRadius = findAllocatedNodesInRadius(spec, spec.nodes[socketNode.id], radiusIndexFor("Large"))
199+
assert.is_true(#nodesInRadius > 0, "Should have allocated nodes in radius")
200+
201+
local calcFunc = build.calcsTab:GetMiscCalculator(build)
202+
local testNode = nodesInRadius[1]
203+
local origNode = spec.tree.nodes[testNode.id]
204+
205+
assert.is_truthy(calcFunc({ removeNodes = { [testNode] = true } }),
206+
"calcFunc with removeNodes should return output")
207+
assert.is_truthy(calcFunc({ removeNodes = { [testNode] = true }, addNodes = { [origNode] = true } }),
208+
"calcFunc with removeNodes+addNodes should return output")
209+
end)
210+
211+
it("timeless jewel comparison: removeNodes/addNodes on conquered nodes changes output", function()
212+
local spec, socketNode = setupAllocatedSocket()
213+
214+
local nodesInRadius = findAllocatedNodesInRadius(spec, spec.nodes[socketNode.id], radiusIndexFor("Large"))
215+
assert.is_true(#nodesInRadius > 0, "Should have allocated nodes in radius")
216+
217+
local conqueredNode = nodesInRadius[1]
218+
local origNode = spec.tree.nodes[conqueredNode.id]
219+
simulateKaruiConquest(conqueredNode)
220+
221+
-- Snapshot the state including the simulated conquest, then revert
222+
-- the conquered node back to the original tree node via override.
223+
local calcFunc, calcBase = build.calcsTab:GetMiscCalculator(build)
224+
local output = calcFunc({
225+
removeNodes = { [conqueredNode] = true },
226+
addNodes = { [origNode] = true },
227+
})
228+
229+
assert.are_not.equals(calcBase.Life, output.Life,
230+
"Reverting conquered node should change Life output")
231+
end)
232+
233+
it("Thread of Hope enables allocation of unconnected nodes", function()
234+
local spec, socketNode = setupAllocatedSocket()
235+
236+
local item = equipThreadOfHope(socketNode)
237+
spec:BuildAllDependsAndPaths()
238+
runCallback("OnFrame")
239+
240+
local targetNode = findIntuitiveLeapTarget(spec, spec.nodes[socketNode.id], item.jewelRadiusIndex)
241+
assert.is_truthy(targetNode, "Should find an unallocated node affected by Thread of Hope")
242+
243+
spec:AllocNode(targetNode)
244+
spec:BuildAllDependsAndPaths()
245+
runCallback("OnFrame")
246+
247+
assert.is_true(targetNode.alloc, "Node should be allocated")
248+
assert.is_false(targetNode.connectedToStart, "Node should not be connected to start")
249+
250+
local nodesInLeapRadius = spec:NodesInIntuitiveLeapLikeRadius(spec.nodes[socketNode.id])
251+
local found = false
252+
for _, node in ipairs(nodesInLeapRadius) do
253+
if node.id == targetNode.id then
254+
found = true
255+
break
256+
end
257+
end
258+
assert.is_true(found, "NodesInIntuitiveLeapLikeRadius should include the allocated node")
259+
end)
260+
261+
it("Thread of Hope removal comparison removes dependent nodes via override", function()
262+
local spec, socketNode = setupAllocatedSocket()
263+
264+
local item = equipThreadOfHope(socketNode)
265+
spec:BuildAllDependsAndPaths()
266+
runCallback("OnFrame")
267+
268+
local targetNode = findIntuitiveLeapTarget(spec, spec.nodes[socketNode.id], item.jewelRadiusIndex)
269+
assert.is_truthy(targetNode, "Should find a node to allocate through Thread of Hope")
270+
271+
spec:AllocNode(targetNode)
272+
spec:BuildAllDependsAndPaths()
273+
runCallback("OnFrame")
274+
275+
-- Build override like ItemsTab does: remove nodes only reachable through the jewel
276+
local override = { removeNodes = { } }
277+
for _, node in ipairs(spec:NodesInIntuitiveLeapLikeRadius(spec.nodes[socketNode.id])) do
278+
if not node.connectedToStart then
279+
override.removeNodes[node] = true
280+
end
281+
end
282+
283+
assert.is_truthy(override.removeNodes[spec.nodes[targetNode.id]],
284+
"Node allocated through Thread of Hope should be in removeNodes")
285+
286+
local calcFunc = build.calcsTab:GetMiscCalculator(build)
287+
assert.is_truthy(calcFunc(override), "calcFunc with removeNodes should return output")
288+
end)
289+
290+
it("AddItemTooltip emits a remove-comparison block for an equipped Timeless jewel", function()
291+
local spec, socketNode = setupAllocatedSocket()
292+
293+
local item = newLethalPride()
294+
local slot = equipJewelInSocket(item, socketNode)
295+
assert.is_truthy(slot, "Should find a slot for the jewel socket")
296+
297+
local nodesInRadius = findAllocatedNodesInRadius(spec, spec.nodes[socketNode.id], item.jewelRadiusIndex)
298+
assert.is_true(#nodesInRadius > 0, "Should have allocated nodes in jewel radius")
299+
simulateKaruiConquest(nodesInRadius[1])
300+
301+
local tooltip = new("Tooltip")
302+
build.itemsTab:AddItemTooltip(tooltip, item, slot)
303+
304+
assert.is_true(tooltipContains(tooltip, "Removing this item"),
305+
"tooltip should contain a 'Removing this item' comparison header")
306+
end)
307+
308+
end)

src/Classes/ItemsTab.lua

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4026,7 +4026,55 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode)
40264026
for _, compareSlot in pairs(compareSlots) do
40274027
if not main.slotOnlyTooltips or (slot and (slot.nodeId == compareSlot.nodeId or slot.slotName == compareSlot.slotName)) or not slot or slot == compareSlot then
40284028
local selItem = self.items[compareSlot.selItemId]
4029-
local output = calcFunc({ repSlotName = compareSlot.slotName, repItem = item ~= selItem and item or nil})
4029+
local override = { repSlotName = compareSlot.slotName, repItem = item ~= selItem and item or nil }
4030+
-- When removing a timeless jewel, also revert conquered nodes to originals
4031+
if compareSlot.nodeId and item == selItem and selItem
4032+
and selItem.jewelData and selItem.jewelData.conqueredBy then
4033+
override.removeNodes = { }
4034+
override.addNodes = { }
4035+
local socketNode = self.build.spec.nodes[compareSlot.nodeId]
4036+
local radiusIndex = selItem.jewelRadiusIndex
4037+
if socketNode and socketNode.nodesInRadius and radiusIndex and socketNode.nodesInRadius[radiusIndex] then
4038+
for nodeId in pairs(socketNode.nodesInRadius[radiusIndex]) do
4039+
local specNode = self.build.spec.nodes[nodeId]
4040+
local origNode = self.build.spec.hashOverrides[nodeId] or self.build.spec.tree.nodes[nodeId]
4041+
if specNode and origNode and specNode.conqueredBy
4042+
and self.build.spec.allocNodes[nodeId] then
4043+
override.removeNodes[specNode] = true
4044+
override.addNodes[origNode] = true
4045+
end
4046+
end
4047+
end
4048+
end
4049+
-- When removing an intuitiveLeapLike jewel, also remove nodes only reachable through it
4050+
if compareSlot.nodeId and item == selItem and selItem and selItem.jewelData
4051+
and (selItem.jewelData.intuitiveLeapLike or selItem.jewelData.impossibleEscapeKeystone) then
4052+
override.removeNodes = override.removeNodes or { }
4053+
local spec = self.build.spec
4054+
local nodesToRemove = spec:NodesInIntuitiveLeapLikeRadius(spec.nodes[compareSlot.nodeId])
4055+
for _, node in ipairs(nodesToRemove) do
4056+
if not node.connectedToStart then
4057+
override.removeNodes[node] = true
4058+
end
4059+
end
4060+
-- Also remove transitive dependents
4061+
local queue = { }
4062+
for node in pairs(override.removeNodes) do
4063+
t_insert(queue, node)
4064+
end
4065+
local i = 1
4066+
while i <= #queue do
4067+
local node = queue[i]
4068+
for _, dep in ipairs(node.depends or { }) do
4069+
if not override.removeNodes[dep] then
4070+
override.removeNodes[dep] = true
4071+
t_insert(queue, dep)
4072+
end
4073+
end
4074+
i = i + 1
4075+
end
4076+
end
4077+
local output = calcFunc(override)
40304078
local header
40314079
if item == selItem then
40324080
header = "^7Removing this item from "..compareSlot.label.." will give you:"

0 commit comments

Comments
 (0)