Stop giving advice the game will not let you follow
Four fixes, all traced to the running source. * The sell advisor could tell you to sell an ETERNAL joker, which Card:can_sell_card refuses outright (card.lua:1993). It now never names one, though they still count toward the best score -- they are part of the build you are keeping. Advice you cannot take is worse than none. * Copiers CHAIN. SMODS.blueprint_effect recurses (utils.lua:2269) and Blueprint's own blueprint_compat is true, so Blueprint -> Blueprint -> Baron really does give two Barons. The tooltip used to answer "Copying Blueprint", the one reply that helps nobody; it now walks the chain to the joker actually copied and shows the route. Capped at the board size like vanilla, and a Blueprint/Brainstorm ring resolves to nothing instead of hanging. * blueprint_compat is tested for TRUTH by vanilla (utils.lua:2254), not for `~= false` as I had it. A modded joker that simply omits the flag is NOT copyable, and this mod supports other mods' jokers by design, so saying otherwise was a lie about someone else's content. * A debuffed joker copies as nothing (same line), and a debuffed joker does nothing at all this blind -- both now say so. Scoring is left alone: a debuff is temporary and the advisor advises on the build, so hiding a debuffed partner from "Active combos" would misrepresent it. Also: never call SMODS.is_eternal() from an advisor path. It runs a full SMODS.calculate_context per call (utils.lua:3079) -- far too heavy for a hover -- and outside a live run it reported EVERY joker as Eternal, which silently disabled the sell advisor. The smoke harness caught that; the stub could not have. 721 pass locally, 12 in-game. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,14 +115,27 @@ Three-layer split; `JCA` is the single global namespace:
|
||||
another one's payoff is the one you want to cut. This is the **only**
|
||||
place a clash changes a number; `JCA.score` stays pure, because a trap
|
||||
must never make a card look like a combo.
|
||||
- Copy advisor: `JCA.copy_source(card)` returns what an owned Blueprint or
|
||||
Brainstorm is *actually* copying, from the live board order — Blueprint
|
||||
takes the joker to its right, Brainstorm the leftmost — plus whether
|
||||
vanilla will copy it at all (29 centers carry `blueprint_compat = false`).
|
||||
The tooltip names the target, or says it is copying nothing. Do not
|
||||
rebuild this with the `a and b or c` idiom: for a rightmost Blueprint the
|
||||
right-hand slot is nil and the idiom falls through to the leftmost joker,
|
||||
confidently reporting the wrong card.
|
||||
- Copy advisor: `JCA.copy_source(card)` → `target, status, chain`. Reports what
|
||||
an owned Blueprint/Brainstorm is *actually* copying, from the live board order
|
||||
(Blueprint takes the joker to its right, Brainstorm the leftmost). Copiers
|
||||
**chain** — `SMODS.blueprint_effect` recurses (`utils.lua:2269`) and
|
||||
Blueprint's own `blueprint_compat` is true, so Blueprint→Blueprint→Baron
|
||||
really gives two Barons; the chain is walked to the joker actually copied,
|
||||
capped at the board size like vanilla, and a copier ring yields nothing.
|
||||
`status` is `ok`/`none`/`incompatible`/`debuffed`. Test `blueprint_compat`
|
||||
for **truth**, not `~= false` — vanilla does (`utils.lua:2254`), so a modded
|
||||
joker that omits the flag is *not* copyable. A debuffed target copies as
|
||||
nothing. Do not rebuild the neighbour lookup with the `a and b or c` idiom:
|
||||
for a rightmost Blueprint the right-hand slot is nil and the idiom falls
|
||||
through to the leftmost joker, confidently reporting the wrong card.
|
||||
- `JCA.is_eternal(card)` reads `card.ability.eternal` — the flag vanilla itself
|
||||
uses for the badge (`card.lua:1141`). **Never call `SMODS.is_eternal()` from
|
||||
an advisor path**: it runs a full `SMODS.calculate_context` per call
|
||||
(`utils.lua:3079`), far too heavy for a hover, and outside a live run it
|
||||
reported *every* joker as Eternal, silently disabling the sell advisor.
|
||||
Eternal jokers cannot be sold at all (`Card:can_sell_card`, `card.lua:1993`),
|
||||
so `weakest_link` never names one — advice you are not allowed to take is
|
||||
worse than none — though they still count toward the best score.
|
||||
- Public API for other mods: `JCA.register(key, gives, wants, opts)`,
|
||||
`JCA.register_pair(a, b, blurb)` (sets `JCA._pairs_dirty` so the
|
||||
Combos tab re-sorts), `JCA.register_clash(a, b, warning)`. Unknown
|
||||
|
||||
@@ -159,36 +159,65 @@ end
|
||||
|
||||
-- Copy jokers are positional, and the board order is right there in
|
||||
-- G.jokers.cards -- so the advisor can name what a copier is ACTUALLY copying
|
||||
-- rather than guess. Blueprint copies the joker to its right (card.lua:2306);
|
||||
-- Brainstorm copies the leftmost one (card.lua:2322), which is nothing when it
|
||||
-- is itself leftmost.
|
||||
-- rather than guess. Blueprint copies the joker to its right (card.lua:4663);
|
||||
-- Brainstorm copies the leftmost one, which is nothing when it is itself leftmost.
|
||||
--
|
||||
-- Returns: the copied card (or nil), and whether vanilla will actually let it be
|
||||
-- copied -- 29 jokers carry `blueprint_compat = false` and yield nothing at all.
|
||||
-- Copiers CHAIN: Blueprint's own blueprint_compat is true, and
|
||||
-- SMODS.blueprint_effect recurses into the copied card (utils.lua:2269), so
|
||||
-- Blueprint -> Blueprint -> Baron really does give you two Barons. Answering
|
||||
-- "Copying Blueprint" would be the one reply that tells the player nothing, so we
|
||||
-- walk the chain to the joker whose ability actually gets copied.
|
||||
--
|
||||
-- Returns: target, status, chain
|
||||
-- target - the joker ultimately copied (nil when the chain yields nothing)
|
||||
-- status - 'ok' | 'none' | 'incompatible' | 'debuffed'
|
||||
-- chain - the copiers walked through on the way there (Blueprints in between)
|
||||
local COPIERS = {j_blueprint = 'right', j_brainstorm = 'leftmost'}
|
||||
|
||||
function JCA.copy_source(card)
|
||||
if not (G.jokers and G.jokers.cards and card.config and card.config.center) then
|
||||
return nil
|
||||
return nil, 'none', {}
|
||||
end
|
||||
local mode = COPIERS[card.config.center.key]
|
||||
if not mode then return nil end
|
||||
local cards, idx = G.jokers.cards, nil
|
||||
for i, c in ipairs(cards) do
|
||||
if c == card then idx = i break end
|
||||
end
|
||||
if not idx then return nil end
|
||||
if not COPIERS[card.config.center.key] then return nil, 'none', {} end
|
||||
|
||||
-- NOT the `a and b or c` idiom: for a rightmost Blueprint cards[idx+1] is nil,
|
||||
-- and the idiom would fall through to `or cards[1]` and confidently report it
|
||||
-- copying the LEFTMOST joker.
|
||||
local cards = G.jokers.cards
|
||||
local chain, seen, current = {}, {}, card
|
||||
|
||||
-- Vanilla caps the recursion at the board size (utils.lua:2255); a
|
||||
-- Blueprint/Brainstorm ring would otherwise loop forever and yields nothing.
|
||||
for _ = 1, #cards + 1 do
|
||||
if seen[current] then return nil, 'none', chain end
|
||||
seen[current] = true
|
||||
|
||||
local mode = COPIERS[current.config.center.key]
|
||||
local idx
|
||||
for i, c in ipairs(cards) do
|
||||
if c == current then idx = i break end
|
||||
end
|
||||
if not idx then return nil, 'none', chain end
|
||||
|
||||
-- NOT the `a and b or c` idiom: for a rightmost Blueprint cards[idx+1] is
|
||||
-- nil, and the idiom would fall through to `or cards[1]` and confidently
|
||||
-- report it copying the LEFTMOST joker.
|
||||
local target
|
||||
if mode == 'right' then target = cards[idx + 1] else target = cards[1] end
|
||||
if not target or target == card then return nil end
|
||||
if not target or target == current then return nil, 'none', chain end
|
||||
|
||||
local center = target.config and target.config.center
|
||||
if not center then return nil end
|
||||
-- blueprint_compat is vanilla's own flag for "this joker cannot be copied".
|
||||
return target, center.blueprint_compat ~= false
|
||||
if not center then return nil, 'none', chain end
|
||||
|
||||
-- Vanilla tests blueprint_compat for TRUTH, not for `~= false`
|
||||
-- (utils.lua:2254). A modded joker that simply omits the flag is NOT
|
||||
-- copyable, and saying otherwise would be a lie about someone else's mod.
|
||||
if not center.blueprint_compat then return target, 'incompatible', chain end
|
||||
-- A debuffed joker is copied as nothing at all (same line).
|
||||
if target.debuff then return target, 'debuffed', chain end
|
||||
|
||||
if not COPIERS[center.key] then return target, 'ok', chain end
|
||||
chain[#chain + 1] = target -- another copier: follow it
|
||||
current = target
|
||||
end
|
||||
return nil, 'none', chain
|
||||
end
|
||||
|
||||
-- Ceremonial Dagger is positional too, and it is the dangerous one: when the
|
||||
@@ -211,12 +240,9 @@ function JCA.dagger_victim(card)
|
||||
local target = cards[idx + 1]
|
||||
if not target or not (target.config and target.config.center) then return nil end
|
||||
|
||||
local eternal = target.ability and target.ability.eternal
|
||||
if SMODS and SMODS.is_eternal then
|
||||
local ok, res = pcall(SMODS.is_eternal, target, card)
|
||||
if ok then eternal = res end
|
||||
end
|
||||
return target, not not eternal
|
||||
-- Same reasoning as JCA.is_eternal: read the sticker, never call
|
||||
-- SMODS.is_eternal() from an advisor path.
|
||||
return target, JCA.is_eternal(target)
|
||||
end
|
||||
|
||||
-- Sell advisor: on a full board, the owned joker contributing the least
|
||||
@@ -244,6 +270,20 @@ local function keep_score(card, cards)
|
||||
return total
|
||||
end
|
||||
|
||||
-- Eternal jokers CANNOT be sold: Card:can_sell_card bails on them outright
|
||||
-- (card.lua:1993). Advice you are not allowed to take is worse than no advice, so
|
||||
-- an Eternal joker is never the sell candidate -- but it still counts towards the
|
||||
-- best score, because it is genuinely part of the build you are keeping.
|
||||
-- Read the sticker directly, and do NOT call SMODS.is_eternal(): it runs a full
|
||||
-- SMODS.calculate_context on every call (utils.lua:3079), which is far too heavy
|
||||
-- for something a hover invokes once per joker -- and it does not answer honestly
|
||||
-- outside a live run. In the smoke harness it reported EVERY joker as Eternal,
|
||||
-- which silenced the sell advisor completely. `ability.eternal` is the same flag
|
||||
-- vanilla reads to draw the Eternal badge (card.lua:1141).
|
||||
function JCA.is_eternal(card)
|
||||
return not not (card.ability and card.ability.eternal)
|
||||
end
|
||||
|
||||
function JCA.weakest_link()
|
||||
if not (G.jokers and G.jokers.cards) then return nil end
|
||||
local cards = G.jokers.cards
|
||||
@@ -253,7 +293,7 @@ function JCA.weakest_link()
|
||||
for _, c in ipairs(cards) do
|
||||
if c.config and c.config.center and c.config.center.set == 'Joker' then
|
||||
local total = keep_score(c, cards)
|
||||
if not worst_total or total < worst_total then
|
||||
if not JCA.is_eternal(c) and (not worst_total or total < worst_total) then
|
||||
worst, worst_total = c, total
|
||||
end
|
||||
if not best_total or total > best_total then best_total = total end
|
||||
@@ -466,19 +506,33 @@ local function tooltip_rows(card)
|
||||
-- The board order decides it, so only an owned joker has an answer. These are
|
||||
-- explanations, not verdicts, so learning mode keeps them.
|
||||
if card.area == G.jokers then
|
||||
-- A debuffed joker does nothing at all this blind, whatever its combos say.
|
||||
if card.debuff then
|
||||
text_row('Debuffed - does nothing this blind', G.C.RED)
|
||||
end
|
||||
|
||||
-- Copy jokers. A Blueprint in the wrong slot is a dead card that looks fine.
|
||||
local target, copyable = JCA.copy_source(card)
|
||||
if not target then
|
||||
if COPIERS[key] then
|
||||
local target, status, chain = JCA.copy_source(card)
|
||||
-- Chained copiers: "Copying Blueprint -> Baron" beats "Copying
|
||||
-- Blueprint", which is the one answer that helps nobody.
|
||||
local via = ''
|
||||
for _, link in ipairs(chain) do
|
||||
via = via .. name_of(link.config.center.key) .. ' -> '
|
||||
end
|
||||
local tname = target and name_of(target.config.center.key)
|
||||
|
||||
if status == 'ok' then
|
||||
text_row('Copying ' .. via .. tname, G.C.GREEN)
|
||||
elseif status == 'incompatible' then
|
||||
text_row('Copying ' .. via .. tname .. ' - which cannot be copied', G.C.RED)
|
||||
elseif status == 'debuffed' then
|
||||
text_row('Copying ' .. via .. tname .. ' - debuffed, so nothing', G.C.RED)
|
||||
else
|
||||
text_row(key == 'j_brainstorm'
|
||||
and 'Copying nothing - it IS the leftmost joker'
|
||||
or 'Copying nothing - no joker to its right', G.C.RED)
|
||||
end
|
||||
elseif not copyable then
|
||||
text_row('Copying ' .. name_of(target.config.center.key)
|
||||
.. ' - which cannot be copied', G.C.RED)
|
||||
else
|
||||
text_row('Copying ' .. name_of(target.config.center.key), G.C.GREEN)
|
||||
end
|
||||
|
||||
-- Ceremonial Dagger. This one is about to eat a joker, permanently, at the
|
||||
|
||||
@@ -295,12 +295,61 @@ eq(JCA.copy_source(G.jokers.cards[1]), nil, 'a non-copier has no copy source')
|
||||
-- and yield nothing at all when copied.
|
||||
local uncopyable = field('j_blueprint', 'j_splash')
|
||||
uncopyable[2].config.center.blueprint_compat = false
|
||||
local target, copyable = JCA.copy_source(uncopyable[1])
|
||||
local target, status = JCA.copy_source(uncopyable[1])
|
||||
eq(target, uncopyable[2], 'it still reports what sits in the copied slot')
|
||||
eq(copyable, false, 'and flags that vanilla will not copy it (blueprint_compat)')
|
||||
eq(status, 'incompatible', 'and flags that vanilla will not copy it (blueprint_compat)')
|
||||
|
||||
-- Vanilla tests the flag for TRUTH, so a modded joker that omits it is NOT
|
||||
-- copyable. `~= false` would wrongly call this one fine.
|
||||
local modded = field('j_blueprint', 'j_baron')
|
||||
modded[2].config.center.blueprint_compat = nil
|
||||
eq(select(2, JCA.copy_source(modded[1])), 'incompatible',
|
||||
'a joker with no blueprint_compat flag at all is treated as uncopyable')
|
||||
|
||||
local fine = field('j_blueprint', 'j_baron')
|
||||
eq(select(2, JCA.copy_source(fine[1])), true, 'a copyable target reports as copyable')
|
||||
fine[2].config.center.blueprint_compat = true
|
||||
eq(select(2, JCA.copy_source(fine[1])), 'ok', 'a copyable target reports as ok')
|
||||
|
||||
-- A debuffed joker is copied as nothing (SMODS.blueprint_effect bails on it).
|
||||
local debuffed = field('j_blueprint', 'j_baron')
|
||||
debuffed[2].config.center.blueprint_compat = true
|
||||
debuffed[2].debuff = true
|
||||
eq(select(2, JCA.copy_source(debuffed[1])), 'debuffed',
|
||||
'copying a debuffed joker yields nothing')
|
||||
|
||||
-- Copiers CHAIN: Blueprint -> Blueprint -> Baron really does give two Barons, so
|
||||
-- the answer must be Baron, not "a Blueprint".
|
||||
local chained = field('j_blueprint', 'j_blueprint', 'j_baron')
|
||||
for _, c in ipairs(chained) do c.config.center.blueprint_compat = true end
|
||||
local final, st, via = JCA.copy_source(chained[1])
|
||||
eq(final, chained[3], 'the chain resolves to the joker actually copied')
|
||||
eq(st, 'ok', 'and reports it as live')
|
||||
eq(#via, 1, 'and records the copier walked through on the way')
|
||||
eq(via[1], chained[2], 'which is the middle Blueprint')
|
||||
|
||||
-- A copier ring copies nothing, and must not hang.
|
||||
local ring = field('j_brainstorm', 'j_blueprint')
|
||||
for _, c in ipairs(ring) do c.config.center.blueprint_compat = true end
|
||||
eq(JCA.copy_source(ring[2]), nil, 'a Blueprint/Brainstorm ring resolves to nothing')
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
section('Engine: the sell advisor respects what you can actually sell')
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Eternal jokers CANNOT be sold (Card:can_sell_card, card.lua:1993). Telling the
|
||||
-- player to sell one is advice they are not allowed to take.
|
||||
--
|
||||
-- 8 Ball scores 0 here and Scary Face 1, so 8 Ball is the natural cut -- until it
|
||||
-- turns out to be Eternal, and the advice has to move to the next one down.
|
||||
local et = field('j_8_ball', 'j_scary_face', 'j_baron', 'j_mime')
|
||||
G.jokers.config.card_limit = 4
|
||||
eq(JCA.weakest_link(), et[1], 'normally it flags the lowest-scoring joker')
|
||||
|
||||
et[1].ability = {eternal = true}
|
||||
local pick = JCA.weakest_link()
|
||||
ok(pick ~= et[1], 'but never an Eternal one, because it cannot be sold',
|
||||
pick and pick.config.center.key or 'nil')
|
||||
eq(pick, et[2], 'it falls through to the next sellable candidate instead')
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
section('Engine: Ceremonial Dagger names its victim')
|
||||
|
||||
Reference in New Issue
Block a user