Skip to content

Commit f161d15

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 9 tests covering radius jewel stat comparison Ported onto upstream #9744 sort-based compareSlot loop: override logic now lives inside getReplacedItemAndOutput() instead of the old inline for loop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e2bb84f commit f161d15

2 files changed

Lines changed: 426 additions & 1 deletion

File tree

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
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: minimal Impossible Escape item. Uses "Radius: Small" and targets
114+
-- a specific keystone. The parser populates both impossibleEscapeKeystone
115+
-- and impossibleEscapeKeystones from the "in Radius of X" mod.
116+
local function newImpossibleEscape(keystoneName)
117+
return new("Item", "Rarity: UNIQUE\n" ..
118+
"Impossible Escape\n" ..
119+
"Viridian Jewel\n" ..
120+
"Radius: Small\n" ..
121+
"Implicits: 0\n" ..
122+
"Passive Skills in Radius of " .. keystoneName .. " can be Allocated without being connected to your Tree\n")
123+
end
124+
125+
-- Helper: equip a Thread of Hope in a socket and return the item.
126+
local function equipThreadOfHope(socketNode)
127+
local item = newThreadOfHope()
128+
equipJewelInSocket(item, socketNode)
129+
return item
130+
end
131+
132+
-- Helper: minimal Lethal Pride item — only the lines the parser needs to
133+
-- populate jewelData.conqueredBy and jewelRadiusIndex. Variants and flavour
134+
-- text are intentionally omitted; the tests exercise behavior, not the parser
135+
-- against the full serialized form.
136+
local function newLethalPride()
137+
return new("Item", "Rarity: UNIQUE\n" ..
138+
"Lethal Pride\n" ..
139+
"Timeless Jewel\n" ..
140+
"Radius: Large\n" ..
141+
"Implicits: 0\n" ..
142+
"Commanded leadership over 10000 warriors under Kaom\n")
143+
end
144+
145+
-- Helper: simulate a Karui Timeless conquest on an allocated node by replacing
146+
-- its modList with a known +100 Life mod. The LUT binary files do not load in
147+
-- the headless test environment, so the real BuildAllDependsAndPaths conquest
148+
-- path cannot run. This must be called *after* runCallback("OnFrame"), or BADP
149+
-- will reset the modList back to the original tree node modList.
150+
local function simulateKaruiConquest(node)
151+
node.conqueredBy = { id = 10000, conqueror = { id = 1, type = "karui" } }
152+
node.modList = new("ModList")
153+
node.modList:NewMod("Life", "BASE", 100, "Timeless Jewel")
154+
end
155+
156+
-- Helper: find the first unallocated, non-Mastery, non-Keystone node in a
157+
-- socket's radius that is reachable through an intuitiveLeapLike jewel.
158+
local function findIntuitiveLeapTarget(spec, socketNode, radiusIndex)
159+
local inRadius = socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex]
160+
for nodeId in pairs(inRadius or { }) do
161+
local node = spec.nodes[nodeId]
162+
if node and not node.alloc and #node.intuitiveLeapLikesAffecting > 0
163+
and node.type ~= "Mastery" and node.type ~= "Keystone" then
164+
return node
165+
end
166+
end
167+
return nil
168+
end
169+
170+
-- Helper: true iff any tooltip line contains the given needle (literal match).
171+
local function tooltipContains(tooltip, needle)
172+
for _, line in ipairs(tooltip.lines) do
173+
if (line.text or ""):find(needle, 1, true) then
174+
return true
175+
end
176+
end
177+
return false
178+
end
179+
180+
describe("TestRadiusJewelStatDiff", function()
181+
before_each(function()
182+
newBuild()
183+
end)
184+
185+
teardown(function() end)
186+
187+
it("Lethal Pride item parses conqueredBy correctly", function()
188+
local item = newLethalPride()
189+
build.itemsTab:AddItem(item, true)
190+
191+
assert.is_truthy(item.jewelData, "Item should have jewelData")
192+
assert.is_truthy(item.jewelData.conqueredBy, "Item should have conqueredBy")
193+
assert.are.equals(10000, item.jewelData.conqueredBy.id)
194+
assert.are.equals("karui", item.jewelData.conqueredBy.conqueror.type)
195+
assert.is_truthy(item.jewelRadiusIndex, "Item should have jewelRadiusIndex")
196+
end)
197+
198+
it("Thread of Hope item parses intuitiveLeapLike correctly", function()
199+
local item = newThreadOfHope()
200+
build.itemsTab:AddItem(item, true)
201+
202+
assert.is_truthy(item.jewelData, "Item should have jewelData")
203+
assert.is_truthy(item.jewelData.intuitiveLeapLike, "Item should have intuitiveLeapLike")
204+
assert.is_truthy(item.jewelRadiusIndex, "Item should have jewelRadiusIndex")
205+
end)
206+
207+
it("calcFunc removeNodes/addNodes changes output for allocated nodes", function()
208+
local spec, socketNode = setupAllocatedSocket()
209+
210+
local nodesInRadius = findAllocatedNodesInRadius(spec, spec.nodes[socketNode.id], radiusIndexFor("Large"))
211+
assert.is_true(#nodesInRadius > 0, "Should have allocated nodes in radius")
212+
213+
local calcFunc = build.calcsTab:GetMiscCalculator(build)
214+
local testNode = nodesInRadius[1]
215+
local origNode = spec.tree.nodes[testNode.id]
216+
217+
assert.is_truthy(calcFunc({ removeNodes = { [testNode] = true } }),
218+
"calcFunc with removeNodes should return output")
219+
assert.is_truthy(calcFunc({ removeNodes = { [testNode] = true }, addNodes = { [origNode] = true } }),
220+
"calcFunc with removeNodes+addNodes should return output")
221+
end)
222+
223+
it("timeless jewel comparison: removeNodes/addNodes on conquered nodes changes output", function()
224+
local spec, socketNode = setupAllocatedSocket()
225+
226+
local nodesInRadius = findAllocatedNodesInRadius(spec, spec.nodes[socketNode.id], radiusIndexFor("Large"))
227+
assert.is_true(#nodesInRadius > 0, "Should have allocated nodes in radius")
228+
229+
local conqueredNode = nodesInRadius[1]
230+
local origNode = spec.tree.nodes[conqueredNode.id]
231+
simulateKaruiConquest(conqueredNode)
232+
233+
-- Snapshot the state including the simulated conquest, then revert
234+
-- the conquered node back to the original tree node via override.
235+
local calcFunc, calcBase = build.calcsTab:GetMiscCalculator(build)
236+
local output = calcFunc({
237+
removeNodes = { [conqueredNode] = true },
238+
addNodes = { [origNode] = true },
239+
})
240+
241+
assert.are_not.equals(calcBase.Life, output.Life,
242+
"Reverting conquered node should change Life output")
243+
end)
244+
245+
it("Thread of Hope enables allocation of unconnected nodes", function()
246+
local spec, socketNode = setupAllocatedSocket()
247+
248+
local item = equipThreadOfHope(socketNode)
249+
spec:BuildAllDependsAndPaths()
250+
runCallback("OnFrame")
251+
252+
local targetNode = findIntuitiveLeapTarget(spec, spec.nodes[socketNode.id], item.jewelRadiusIndex)
253+
assert.is_truthy(targetNode, "Should find an unallocated node affected by Thread of Hope")
254+
255+
spec:AllocNode(targetNode)
256+
spec:BuildAllDependsAndPaths()
257+
runCallback("OnFrame")
258+
259+
assert.is_true(targetNode.alloc, "Node should be allocated")
260+
assert.is_false(targetNode.connectedToStart, "Node should not be connected to start")
261+
262+
local nodesInLeapRadius = spec:NodesInIntuitiveLeapLikeRadius(spec.nodes[socketNode.id])
263+
local found = false
264+
for _, node in ipairs(nodesInLeapRadius) do
265+
if node.id == targetNode.id then
266+
found = true
267+
break
268+
end
269+
end
270+
assert.is_true(found, "NodesInIntuitiveLeapLikeRadius should include the allocated node")
271+
end)
272+
273+
it("Thread of Hope removal comparison removes dependent nodes via override", function()
274+
local spec, socketNode = setupAllocatedSocket()
275+
276+
local item = equipThreadOfHope(socketNode)
277+
spec:BuildAllDependsAndPaths()
278+
runCallback("OnFrame")
279+
280+
local targetNode = findIntuitiveLeapTarget(spec, spec.nodes[socketNode.id], item.jewelRadiusIndex)
281+
assert.is_truthy(targetNode, "Should find a node to allocate through Thread of Hope")
282+
283+
spec:AllocNode(targetNode)
284+
spec:BuildAllDependsAndPaths()
285+
runCallback("OnFrame")
286+
287+
-- Build override like ItemsTab does: remove nodes only reachable through the jewel
288+
local override = { removeNodes = { } }
289+
for _, node in ipairs(spec:NodesInIntuitiveLeapLikeRadius(spec.nodes[socketNode.id])) do
290+
if not node.connectedToStart then
291+
override.removeNodes[node] = true
292+
end
293+
end
294+
295+
assert.is_truthy(override.removeNodes[spec.nodes[targetNode.id]],
296+
"Node allocated through Thread of Hope should be in removeNodes")
297+
298+
local calcFunc = build.calcsTab:GetMiscCalculator(build)
299+
assert.is_truthy(calcFunc(override), "calcFunc with removeNodes should return output")
300+
end)
301+
302+
it("Impossible Escape parses and targets a keystone correctly", function()
303+
local item = newImpossibleEscape("Iron Reflexes")
304+
build.itemsTab:AddItem(item, true)
305+
306+
assert.is_truthy(item.jewelData, "IE should have jewelData")
307+
assert.is_truthy(item.jewelData.impossibleEscapeKeystone, "IE should have impossibleEscapeKeystone")
308+
assert.are.equals("iron reflexes", item.jewelData.impossibleEscapeKeystone)
309+
assert.is_truthy(item.jewelData.impossibleEscapeKeystones, "IE should have impossibleEscapeKeystones")
310+
assert.is_truthy(item.jewelData.impossibleEscapeKeystones["iron reflexes"],
311+
"IE should target Iron Reflexes")
312+
end)
313+
314+
it("Impossible Escape removal comparison removes dependent nodes via override", function()
315+
local spec, socketNode = setupAllocatedSocket()
316+
317+
-- IE works via a keystone's radius, not the socket's radius.
318+
-- Find a keystone that has unallocated nodes in its radius for the IE's radius index.
319+
local item = newImpossibleEscape("Iron Reflexes")
320+
equipJewelInSocket(item, socketNode)
321+
spec:BuildAllDependsAndPaths()
322+
runCallback("OnFrame")
323+
324+
-- IE populates intuitiveLeapLikesAffecting on nodes in the keystone's radius,
325+
-- not the socket's radius. Search all spec nodes for an affected target.
326+
local targetNode
327+
for _, node in pairs(spec.nodes) do
328+
if not node.alloc and #node.intuitiveLeapLikesAffecting > 0
329+
and node.type ~= "Mastery" and node.type ~= "Keystone" then
330+
targetNode = node
331+
break
332+
end
333+
end
334+
if not targetNode then
335+
pending("No allocatable node in Impossible Escape radius for this tree layout")
336+
return
337+
end
338+
339+
spec:AllocNode(targetNode)
340+
spec:BuildAllDependsAndPaths()
341+
runCallback("OnFrame")
342+
343+
-- Build override like ItemsTab does
344+
local override = { removeNodes = { } }
345+
local nodesToRemove = spec:NodesInIntuitiveLeapLikeRadius(spec.nodes[socketNode.id])
346+
for _, node in ipairs(nodesToRemove) do
347+
if not node.connectedToStart then
348+
override.removeNodes[node] = true
349+
end
350+
end
351+
352+
assert.is_truthy(override.removeNodes[spec.nodes[targetNode.id]],
353+
"Node allocated through Impossible Escape should be in removeNodes")
354+
355+
local calcFunc = build.calcsTab:GetMiscCalculator(build)
356+
assert.is_truthy(calcFunc(override), "calcFunc with removeNodes should return output")
357+
end)
358+
359+
it("AddItemTooltip emits a remove-comparison block for an equipped Timeless jewel", function()
360+
local spec, socketNode = setupAllocatedSocket()
361+
362+
local item = newLethalPride()
363+
local slot = equipJewelInSocket(item, socketNode)
364+
assert.is_truthy(slot, "Should find a slot for the jewel socket")
365+
366+
local nodesInRadius = findAllocatedNodesInRadius(spec, spec.nodes[socketNode.id], item.jewelRadiusIndex)
367+
assert.is_true(#nodesInRadius > 0, "Should have allocated nodes in jewel radius")
368+
simulateKaruiConquest(nodesInRadius[1])
369+
370+
local tooltip = new("Tooltip")
371+
build.itemsTab:AddItemTooltip(tooltip, item, slot)
372+
373+
assert.is_true(tooltipContains(tooltip, "Removing this item"),
374+
"tooltip should contain a 'Removing this item' comparison header")
375+
end)
376+
377+
end)

0 commit comments

Comments
 (0)