Skip to content

Commit 5fa15f5

Browse files
mcagnionclaude
andcommitted
feat(trade): simulate bench craft on free affix slots during evaluation
Port of feature/bench-craft-eval onto current origin/dev. Adapted to upstream PathOfBuildingCommunity#9691 changes (removed enchantInSort reference, adjusted popup control anchoring for new layout). Fix pre-existing space indentation on upstream TradeQuery.lua line 629. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 98597ac commit 5fa15f5

4 files changed

Lines changed: 335 additions & 3 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
-- Tests for the bench craft evaluation feature:
2+
-- - Free slot detection: affixMax from rarity, explicit mod counting
3+
-- - Craft filtering: craft.types[item.type] gate
4+
-- - Skip conditions: corrupted/mirrored items
5+
-- - Weight selection: best bench craft wins, nil when no improvement
6+
7+
local function makeHelm(explicits, corrupted, mirrored)
8+
local lines = { "Rarity: Rare", "Test Helm", "Vine Circlet" }
9+
for _, mod in ipairs(explicits or {}) do
10+
table.insert(lines, mod)
11+
end
12+
if corrupted then table.insert(lines, "Corrupted") end
13+
if mirrored then table.insert(lines, "Mirrored") end
14+
return table.concat(lines, "\n")
15+
end
16+
17+
local function makeRing(explicits, corrupted, mirrored)
18+
local lines = { "Rarity: Rare", "Test Ring", "Coral Ring" }
19+
for _, mod in ipairs(explicits or {}) do
20+
table.insert(lines, mod)
21+
end
22+
if corrupted then table.insert(lines, "Corrupted") end
23+
if mirrored then table.insert(lines, "Mirrored") end
24+
return table.concat(lines, "\n")
25+
end
26+
27+
-- Mirrors affixMax logic in GetResultEvaluation
28+
-- prefixes.limit and suffixes.limit are deltas on top of the rarity default
29+
local function getAffixMax(item)
30+
if item.rarity == "RARE" then
31+
local defaultHalf = (item.type == "Jewel") and 2 or 3
32+
return (item.prefixes.limit or 0) + defaultHalf + (item.suffixes.limit or 0) + defaultHalf
33+
elseif item.rarity == "MAGIC" then
34+
return (item.prefixes.limit or 0) + 1 + (item.suffixes.limit or 0) + 1
35+
end
36+
return 0
37+
end
38+
39+
-- Mirrors explicit count logic in GetResultEvaluation (all mods, including crafted)
40+
local function countExplicit(item)
41+
return #(item.explicitModLines or {})
42+
end
43+
44+
describe("Bench Craft Eval — free slot detection", function()
45+
-- Pass: 3 mods on a rare leaves 3 free slots detected
46+
-- Fail: affixMax computed wrong or explicit count wrong → bench craft skipped when slots are free
47+
it("detects free slots on a rare with 3 mods", function()
48+
local item = new("Item", makeHelm({
49+
"+50 to maximum Life",
50+
"+30% to Fire Resistance",
51+
"+25% to Cold Resistance",
52+
}))
53+
local affixMax = getAffixMax(item)
54+
local explicit = countExplicit(item)
55+
assert.are.equal(6, affixMax)
56+
assert.are.equal(3, explicit)
57+
assert.is_true(explicit < affixMax)
58+
end)
59+
60+
-- Pass: 6 mods on a rare → no free slots
61+
-- Fail: wrong count → bench craft attempted on a full item
62+
it("detects no free slots on a full rare (6 mods)", function()
63+
local item = new("Item", makeHelm({
64+
"+50 to maximum Life",
65+
"+30% to Fire Resistance",
66+
"+25% to Cold Resistance",
67+
"+40 to Strength",
68+
"+35% to Lightning Resistance",
69+
"10% increased Attack Speed",
70+
}))
71+
local affixMax = getAffixMax(item)
72+
local explicit = countExplicit(item)
73+
assert.are.equal(6, affixMax)
74+
assert.are.equal(6, explicit)
75+
assert.is_false(explicit < affixMax)
76+
end)
77+
78+
-- Pass: 1 mod on a magic item leaves 1 free slot
79+
-- Fail: magic item gets wrong affixMax (6) → bench craft attempted too eagerly
80+
it("affixMax is 2 for magic items", function()
81+
local lines = "Rarity: Magic\nTest\nVine Circlet\n+50 to maximum Life"
82+
local item = new("Item", lines)
83+
local affixMax = getAffixMax(item)
84+
assert.are.equal(2, affixMax)
85+
assert.is_true(countExplicit(item) < affixMax)
86+
end)
87+
88+
-- Pass: unique item gets affixMax 0 → bench craft never triggered
89+
-- Fail: unique items get bench-crafted → nonsensical evaluation
90+
it("affixMax is 0 for unique items", function()
91+
local lines = "Rarity: Unique\nShav\nShavronnes Wrappings\n+50 to maximum Life"
92+
local item = new("Item", lines)
93+
local affixMax = getAffixMax(item)
94+
assert.are.equal(0, affixMax)
95+
end)
96+
97+
-- Pass: Eldritch implicit "+1 prefix allowed" raises affixMax to 7 on a rare helmet
98+
-- Fail: fixed affixMax=6 used → item with Eldritch +1 prefix has 7 mods but bench craft still triggered
99+
it("affixMax accounts for +1 prefix modifiers allowed (Eldritch implicit)", function()
100+
local item = new("Item", makeHelm({
101+
"+50 to maximum Life",
102+
"+30% to Fire Resistance",
103+
"+25% to Cold Resistance",
104+
"+40 to Strength",
105+
"+35% to Lightning Resistance",
106+
"10% increased Attack Speed",
107+
"+1 Prefix Modifier allowed",
108+
}))
109+
local affixMax = getAffixMax(item)
110+
-- prefixes.limit = 1 (delta), suffixes.limit = nil (0 delta), so affixMax = (0+1+3) + (0+3) = 7
111+
assert.are.equal(7, affixMax)
112+
-- 7 mods on the item → no free slot
113+
assert.is_false(countExplicit(item) < affixMax)
114+
end)
115+
116+
-- Pass: crafted mod counts as an occupied slot → prevents simulating a second crafted mod
117+
-- Fail: crafted mod excluded → item with 5 regular + 1 crafted = 5 counted, bench craft attempted, simulation adds 7th mod
118+
it("counts crafted mods as occupying an explicit slot", function()
119+
local item = new("Item", makeHelm({
120+
"+50 to maximum Life",
121+
"+30% to Fire Resistance",
122+
"{crafted}10% increased Attack Speed",
123+
}))
124+
local explicit = countExplicit(item)
125+
-- All 3 mods (including the crafted one) occupy slots
126+
assert.are.equal(3, explicit)
127+
end)
128+
end)
129+
130+
describe("Bench Craft Eval — skip conditions", function()
131+
-- Pass: corrupted flag detected → bench craft simulation skipped
132+
-- Fail: corrupted items get simulated → invalid crafting on corrupted item shown to user
133+
it("marks corrupted item correctly", function()
134+
local item = new("Item", makeHelm({ "+50 to maximum Life" }, true))
135+
assert.is_true(item.corrupted)
136+
end)
137+
138+
-- Pass: mirrored flag detected → bench craft simulation skipped
139+
-- Fail: mirrored items get simulated → invalid crafting on mirrored item shown to user
140+
it("marks mirrored item correctly", function()
141+
local item = new("Item", makeHelm({ "+50 to maximum Life" }, false, true))
142+
assert.is_true(item.mirrored)
143+
end)
144+
145+
-- Pass: normal item is neither corrupted nor mirrored
146+
-- Fail: normal item wrongly flagged → bench craft silently skipped on craftable items
147+
it("normal rare item is not corrupted or mirrored", function()
148+
local item = new("Item", makeHelm({ "+50 to maximum Life" }))
149+
assert.is_not_true(item.corrupted)
150+
assert.is_not_true(item.mirrored)
151+
end)
152+
end)
153+
154+
describe("Bench Craft Eval — craft type filtering", function()
155+
-- Mirrors the craft.types[item.type] gate in GetResultEvaluation
156+
157+
local function craftAppliesToType(craft, itemType)
158+
return craft.types ~= nil and craft.types[itemType] == true
159+
end
160+
161+
-- Pass: craft with matching type is accepted
162+
-- Fail: gate broken → wrong item types get bench-crafted, causing invalid simulations
163+
it("accepts a craft whose types include the item type", function()
164+
local craft = { types = { Helmet = true, Gloves = true }, type = "Suffix" }
165+
assert.is_true(craftAppliesToType(craft, "Helmet"))
166+
assert.is_true(craftAppliesToType(craft, "Gloves"))
167+
end)
168+
169+
-- Pass: craft without matching type is rejected
170+
-- Fail: all crafts tried on all items → incorrect evaluation with weapon-only mods on armour
171+
it("rejects a craft whose types do not include the item type", function()
172+
local craft = { types = { Weapon = true }, type = "Prefix" }
173+
assert.is_false(craftAppliesToType(craft, "Helmet"))
174+
end)
175+
176+
-- Pass: craft with nil types is safely rejected
177+
-- Fail: nil types crash the gate → runtime error during bench craft loop
178+
it("safely rejects craft with nil types", function()
179+
local craft = { type = "Prefix" }
180+
assert.is_false(craftAppliesToType(craft, "Helmet"))
181+
end)
182+
end)
183+
184+
describe("Bench Craft Eval — weight selection", function()
185+
-- Mirrors the best-weight selection loop in GetResultEvaluation
186+
187+
local function simulateBenchCraftSelection(baseWeight, crafts)
188+
local weight = baseWeight
189+
local benchCraft = nil
190+
for _, craft in ipairs(crafts) do
191+
if craft.craftWeight > weight then
192+
weight = craft.craftWeight
193+
benchCraft = craft.label
194+
end
195+
end
196+
return weight, benchCraft
197+
end
198+
199+
-- Pass: best craft is selected when it beats the base weight
200+
-- Fail: wrong craft selected → user shown a suboptimal bench craft hint
201+
it("selects the craft with the highest weight above base", function()
202+
local weight, benchCraft = simulateBenchCraftSelection(1.0, {
203+
{ label = "adds life (Prefix)", craftWeight = 1.2 },
204+
{ label = "adds es (Prefix)", craftWeight = 1.5 },
205+
{ label = "adds armour (Suffix)", craftWeight = 1.1 },
206+
})
207+
assert.are.equal(1.5, weight)
208+
assert.are.equal("adds es (Prefix)", benchCraft)
209+
end)
210+
211+
-- Pass: benchCraft is nil when no craft beats base weight
212+
-- Fail: a worse craft is stored → tooltip shows misleading hint that doesn't improve the item
213+
it("returns nil benchCraft when no craft improves the base weight", function()
214+
local weight, benchCraft = simulateBenchCraftSelection(1.5, {
215+
{ label = "adds life (Prefix)", craftWeight = 1.2 },
216+
{ label = "adds es (Prefix)", craftWeight = 1.3 },
217+
})
218+
assert.are.equal(1.5, weight)
219+
assert.is_nil(benchCraft)
220+
end)
221+
222+
-- Pass: empty craft list leaves weight and benchCraft unchanged
223+
-- Fail: crash or wrong defaults → bench craft code fails on items with no applicable mods
224+
it("handles empty craft list gracefully", function()
225+
local weight, benchCraft = simulateBenchCraftSelection(1.0, {})
226+
assert.are.equal(1.0, weight)
227+
assert.is_nil(benchCraft)
228+
end)
229+
230+
-- Pass: single improving craft is always selected
231+
-- Fail: off-by-one in > comparison → craft equal to base is wrongly selected
232+
it("requires strictly greater weight to select a craft", function()
233+
local weight, benchCraft = simulateBenchCraftSelection(1.0, {
234+
{ label = "equal craft (Prefix)", craftWeight = 1.0 },
235+
})
236+
assert.are.equal(1.0, weight)
237+
assert.is_nil(benchCraft) -- equal weight does not trigger selection
238+
end)
239+
240+
-- Pass: only the highest craft wins over all others
241+
-- Fail: first-wins logic → suboptimal craft shown when a later one is better
242+
it("always picks the globally best craft, not just the first improving one", function()
243+
local weight, benchCraft = simulateBenchCraftSelection(1.0, {
244+
{ label = "craft_a (Prefix)", craftWeight = 1.3 },
245+
{ label = "craft_b (Prefix)", craftWeight = 1.8 },
246+
{ label = "craft_c (Suffix)", craftWeight = 1.6 },
247+
})
248+
assert.are.equal(1.8, weight)
249+
assert.are.equal("craft_b (Prefix)", benchCraft)
250+
end)
251+
end)
252+
253+
describe("Bench Craft Eval — item parsing with crafted mod", function()
254+
-- Pass: crafted mod line added to explicitModLines is flagged correctly
255+
-- Fail: crafted flag missing → crafted mod counted as an occupied explicit slot
256+
it("item with a crafted mod has the crafted flag set on that mod line", function()
257+
local item = new("Item", makeRing({
258+
"+50 to maximum Life",
259+
"{crafted}+35% to Cold Resistance",
260+
}))
261+
local craftedCount = 0
262+
for _, modLine in ipairs(item.explicitModLines or {}) do
263+
if modLine.crafted then
264+
craftedCount = craftedCount + 1
265+
end
266+
end
267+
assert.are.equal(1, craftedCount)
268+
end)
269+
270+
-- Pass: total explicit count (including crafted) is 2
271+
-- Fail: wrong total → logic relying on total explicit count is broken
272+
it("total explicitModLines count includes crafted mods", function()
273+
local item = new("Item", makeRing({
274+
"+50 to maximum Life",
275+
"{crafted}+35% to Cold Resistance",
276+
}))
277+
assert.are.equal(2, #item.explicitModLines)
278+
end)
279+
end)

spec/System/TestTradeQueryGenerator_spec.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
describe("TradeQueryGenerator", function()
2-
local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {} })
2+
local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {}, GetTradeStatusOption = function() return "online" end })
33

44
describe("ProcessMod", function()
55
-- Pass: Mod line maps correctly to trade stat entry without error

src/Classes/TradeQuery.lua

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList)
624624
for row_idx in pairs(self.resultTbl) do
625625
self:UpdateControlsWithItems(row_idx)
626626
end
627-
end)
627+
end)
628628
controls.cancel = new("ButtonControl", { "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function()
629629
if previousSelectionList and #previousSelectionList > 0 then
630630
self.statSortSelectionList = copyTable(previousSelectionList, true)
@@ -763,7 +763,38 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba
763763

764764
local output = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = item }))
765765
local weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList)
766-
result.evaluation = {{ output = output, weight = weight }}
766+
local benchCraft = nil
767+
if self.slotTables[row_idx].considerBenchCraft and not item.corrupted and not item.mirrored then
768+
local affixMax = 0
769+
if item.rarity == "RARE" then
770+
local defaultHalf = (item.type == "Jewel") and 2 or 3
771+
affixMax = (item.prefixes.limit or 0) + defaultHalf + (item.suffixes.limit or 0) + defaultHalf
772+
elseif item.rarity == "MAGIC" then
773+
affixMax = (item.prefixes.limit or 0) + 1 + (item.suffixes.limit or 0) + 1
774+
end
775+
if affixMax > 0 then
776+
local explicitCount = #(item.explicitModLines or {})
777+
if explicitCount < affixMax then
778+
for _, craft in ipairs(self.itemsTab.build.data.masterMods or {}) do
779+
if craft.types[item.type] then
780+
local craftedItem = new("Item", result.item_string)
781+
for _, line in ipairs(craft) do
782+
t_insert(craftedItem.explicitModLines, { line = line, modTags = craft.modTags, crafted = true })
783+
end
784+
craftedItem:BuildAndParseRaw()
785+
local craftOutput = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = craftedItem }))
786+
local craftWeight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, craftOutput, self.statSortSelectionList)
787+
if craftWeight > weight then
788+
weight = craftWeight
789+
output = craftOutput
790+
benchCraft = table.concat(craft, "/") .. " ^8(" .. craft.type .. ")"
791+
end
792+
end
793+
end
794+
end
795+
end
796+
end
797+
result.evaluation = {{ output = output, weight = weight, benchCraft = benchCraft }}
767798
end
768799
return result.evaluation
769800
end
@@ -1117,6 +1148,13 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro
11171148
addMegalomaniacCompareToTooltipIfApplicable(tooltip, pb_index)
11181149
tooltip:AddSeparator(10)
11191150
tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency))
1151+
if result.evaluation then
1152+
local eval = result.evaluation[1]
1153+
if eval and eval.benchCraft then
1154+
tooltip:AddSeparator(6)
1155+
tooltip:AddLine(16, "^8Bench: ^7" .. eval.benchCraft)
1156+
end
1157+
end
11201158
end
11211159
controls["importButton"..row_idx] = new("ButtonControl", { "TOPLEFT", controls["resultDropdown"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Import Item", function()
11221160
self.itemsTab:CreateDisplayItemFromRaw(self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string)

src/Classes/TradeQueryGenerator.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,11 @@ function TradeQueryGeneratorClass:FinishQuery()
11881188
errMsg = "Could not generate search, found no mods to search for"
11891189
end
11901190

1191+
-- Propagate considerBenchCraft to the slot table so result evaluation can use it
1192+
if self.requesterContext and self.requesterContext.slotTbl then
1193+
self.requesterContext.slotTbl.considerBenchCraft = options.considerBenchCraft
1194+
end
1195+
11911196
local queryJson = dkjson.encode(queryTable)
11921197
self.requesterCallback(self.requesterContext, queryJson, errMsg)
11931198

@@ -1237,6 +1242,13 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb
12371242
updateLastAnchor(controls.includeMirrored)
12381243
end
12391244

1245+
if not isJewelSlot and not isAbyssalJewelSlot and not context.slotTbl.unique then
1246+
controls.considerBenchCraft = new("CheckBoxControl", {"TOPRIGHT",lastItemAnchor,"BOTTOMRIGHT"}, {0, 5, 18}, "Bench Craft:", function(state) end)
1247+
controls.considerBenchCraft.state = (self.lastConsiderBenchCraft == true)
1248+
controls.considerBenchCraft.tooltipText = "Simulates adding the best available bench craft to a free affix slot when evaluating results."
1249+
updateLastAnchor(controls.considerBenchCraft)
1250+
end
1251+
12401252
if not isJewelSlot and not isAbyssalJewelSlot and includeScourge then
12411253
controls.includeScourge = new("CheckBoxControl", {"TOPRIGHT",lastItemAnchor,"BOTTOMRIGHT"}, {0, 5, 18}, "Scourge Mods:", function(state) end)
12421254
controls.includeScourge.state = (self.lastIncludeScourge == nil or self.lastIncludeScourge == true)
@@ -1443,6 +1455,9 @@ Remove: %s will be removed from the search results.]], term, term, term)
14431455
if controls.includeAllWEMods then
14441456
options.includeAllWEMods = controls.includeAllWEMods.state
14451457
end
1458+
if controls.considerBenchCraft then
1459+
self.lastConsiderBenchCraft, options.considerBenchCraft = controls.considerBenchCraft.state, controls.considerBenchCraft.state
1460+
end
14461461
options.statWeights = statWeights
14471462

14481463
self:StartQuery(slot, options)

0 commit comments

Comments
 (0)