From de67970a1569d33d2c2a6777e40d0ce407b496b7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 14 Jul 2026 12:50:47 -0700 Subject: [PATCH] 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) --- CLAUDE.md | 29 ++++++++---- main.lua | 130 ++++++++++++++++++++++++++++++++++++++---------------- test.lua | 55 +++++++++++++++++++++-- 3 files changed, 165 insertions(+), 49 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4e9c59f..c01ac63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/main.lua b/main.lua index f078627..fbbf904 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/test.lua b/test.lua index 2759364..c5d9fa2 100644 --- a/test.lua +++ b/test.lua @@ -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')