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:
funman300
2026-07-14 12:50:47 -07:00
parent 8ce5619273
commit de67970a15
3 changed files with 165 additions and 49 deletions
+21 -8
View File
@@ -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** 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 place a clash changes a number; `JCA.score` stays pure, because a trap
must never make a card look like a combo. must never make a card look like a combo.
- Copy advisor: `JCA.copy_source(card)` returns what an owned Blueprint or - Copy advisor: `JCA.copy_source(card)` `target, status, chain`. Reports what
Brainstorm is *actually* copying, from the live board order — Blueprint an owned Blueprint/Brainstorm is *actually* copying, from the live board order
takes the joker to its right, Brainstorm the leftmost — plus whether (Blueprint takes the joker to its right, Brainstorm the leftmost). Copiers
vanilla will copy it at all (29 centers carry `blueprint_compat = false`). **chain**`SMODS.blueprint_effect` recurses (`utils.lua:2269`) and
The tooltip names the target, or says it is copying nothing. Do not Blueprint's own `blueprint_compat` is true, so Blueprint→Blueprint→Baron
rebuild this with the `a and b or c` idiom: for a rightmost Blueprint the really gives two Barons; the chain is walked to the joker actually copied,
right-hand slot is nil and the idiom falls through to the leftmost joker, capped at the board size like vanilla, and a copier ring yields nothing.
confidently reporting the wrong card. `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)`, - Public API for other mods: `JCA.register(key, gives, wants, opts)`,
`JCA.register_pair(a, b, blurb)` (sets `JCA._pairs_dirty` so the `JCA.register_pair(a, b, blurb)` (sets `JCA._pairs_dirty` so the
Combos tab re-sorts), `JCA.register_clash(a, b, warning)`. Unknown Combos tab re-sorts), `JCA.register_clash(a, b, warning)`. Unknown
+92 -38
View File
@@ -159,36 +159,65 @@ end
-- Copy jokers are positional, and the board order is right there in -- 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 -- 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); -- rather than guess. Blueprint copies the joker to its right (card.lua:4663);
-- Brainstorm copies the leftmost one (card.lua:2322), which is nothing when it -- Brainstorm copies the leftmost one, which is nothing when it is itself leftmost.
-- is itself leftmost.
-- --
-- Returns: the copied card (or nil), and whether vanilla will actually let it be -- Copiers CHAIN: Blueprint's own blueprint_compat is true, and
-- copied -- 29 jokers carry `blueprint_compat = false` and yield nothing at all. -- 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'} local COPIERS = {j_blueprint = 'right', j_brainstorm = 'leftmost'}
function JCA.copy_source(card) function JCA.copy_source(card)
if not (G.jokers and G.jokers.cards and card.config and card.config.center) then if not (G.jokers and G.jokers.cards and card.config and card.config.center) then
return nil return nil, 'none', {}
end end
local mode = COPIERS[card.config.center.key] if not COPIERS[card.config.center.key] then return nil, 'none', {} end
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
-- NOT the `a and b or c` idiom: for a rightmost Blueprint cards[idx+1] is nil, local cards = G.jokers.cards
-- and the idiom would fall through to `or cards[1]` and confidently report it local chain, seen, current = {}, {}, card
-- copying the LEFTMOST joker.
local target -- Vanilla caps the recursion at the board size (utils.lua:2255); a
if mode == 'right' then target = cards[idx + 1] else target = cards[1] end -- Blueprint/Brainstorm ring would otherwise loop forever and yields nothing.
if not target or target == card then return nil end for _ = 1, #cards + 1 do
local center = target.config and target.config.center if seen[current] then return nil, 'none', chain end
if not center then return nil end seen[current] = true
-- blueprint_compat is vanilla's own flag for "this joker cannot be copied".
return target, center.blueprint_compat ~= false 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 == current then return nil, 'none', chain end
local center = target.config and target.config.center
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 end
-- Ceremonial Dagger is positional too, and it is the dangerous one: when the -- 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] local target = cards[idx + 1]
if not target or not (target.config and target.config.center) then return nil end if not target or not (target.config and target.config.center) then return nil end
local eternal = target.ability and target.ability.eternal -- Same reasoning as JCA.is_eternal: read the sticker, never call
if SMODS and SMODS.is_eternal then -- SMODS.is_eternal() from an advisor path.
local ok, res = pcall(SMODS.is_eternal, target, card) return target, JCA.is_eternal(target)
if ok then eternal = res end
end
return target, not not eternal
end end
-- Sell advisor: on a full board, the owned joker contributing the least -- Sell advisor: on a full board, the owned joker contributing the least
@@ -244,6 +270,20 @@ local function keep_score(card, cards)
return total return total
end 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() function JCA.weakest_link()
if not (G.jokers and G.jokers.cards) then return nil end if not (G.jokers and G.jokers.cards) then return nil end
local cards = G.jokers.cards local cards = G.jokers.cards
@@ -253,7 +293,7 @@ function JCA.weakest_link()
for _, c in ipairs(cards) do for _, c in ipairs(cards) do
if c.config and c.config.center and c.config.center.set == 'Joker' then if c.config and c.config.center and c.config.center.set == 'Joker' then
local total = keep_score(c, cards) 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 worst, worst_total = c, total
end end
if not best_total or total > best_total then best_total = 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 -- The board order decides it, so only an owned joker has an answer. These are
-- explanations, not verdicts, so learning mode keeps them. -- explanations, not verdicts, so learning mode keeps them.
if card.area == G.jokers then 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. -- Copy jokers. A Blueprint in the wrong slot is a dead card that looks fine.
local target, copyable = JCA.copy_source(card) if COPIERS[key] then
if not target then local target, status, chain = JCA.copy_source(card)
if COPIERS[key] then -- 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' text_row(key == 'j_brainstorm'
and 'Copying nothing - it IS the leftmost joker' and 'Copying nothing - it IS the leftmost joker'
or 'Copying nothing - no joker to its right', G.C.RED) or 'Copying nothing - no joker to its right', G.C.RED)
end 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 end
-- Ceremonial Dagger. This one is about to eat a joker, permanently, at the -- Ceremonial Dagger. This one is about to eat a joker, permanently, at the
+52 -3
View File
@@ -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. -- and yield nothing at all when copied.
local uncopyable = field('j_blueprint', 'j_splash') local uncopyable = field('j_blueprint', 'j_splash')
uncopyable[2].config.center.blueprint_compat = false 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(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') 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') section('Engine: Ceremonial Dagger names its victim')