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
+92 -38
View File
@@ -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 target
if mode == 'right' then target = cards[idx + 1] else target = cards[1] end
if not target or target == card then return nil 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
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 == 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
-- 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
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