From e688de0efba5c131aaee9e65c9de78aa69005b32 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 14 Jul 2026 12:24:33 -0700 Subject: [PATCH] Fix enabler phrasing, wake two silent jokers, read the board order * The tooltip mis-taught every pure enabler. JCA.explain used a cluster tag's `both` text whenever EITHER side was a full member, so Four Fingers next to Runner claimed "both reward Straights" -- Four Fingers does not reward Straights, it makes them reachable. `both` now requires both sides, and every cluster tag carries give/want phrasings. * Blue Joker and Abstract Joker scored 0 against all 150 jokers. Blue Joker scales on #G.deck.cards, so it wants card_gen (DNA, Marble, Certificate); Abstract scales on #G.jokers.cards, so it wants joker_gen (Riff-Raff is +6 Mult on its own). Both verified in card.lua. Steel and Glass Joker look like the same fix but are NOT: they need Steel/Glass specifically, and the only enhance_gen givers make Gold and Stone, so tagging them would invent a synergy that does not exist. * Copy jokers are positional and the board order was there all along. JCA.copy_source reports what an owned Blueprint/Brainstorm is actually copying, including when the target is one of the 29 centers vanilla refuses to copy (blueprint_compat = false), or when it is copying nothing at all. * The sell advisor now counts clashes when ranking what to cut. JCA.score stays pure -- a trap must never make a card look like a combo -- but a joker eating another one's payoff is exactly the one to sell. The suite caught a real bug while writing this: `(mode == 'right') and cards[idx+1] or cards[1]` falls through to the LEFTMOST joker when the right-hand slot is nil, so a rightmost Blueprint would have named the wrong card. 673 pass here, 9 in-game via the smoke harness. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 20 ++++++++++-- main.lua | 88 +++++++++++++++++++++++++++++++++++++++++++++++++-- synergies.lua | 52 +++++++++++++++++++++--------- test.lua | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 014f574..c140af1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,9 +88,21 @@ Three-layer split; `JCA` is the single global namespace: All hook bodies are pcall-wrapped so a scoring bug can never crash a run — keep it that way. - Sell advisor: `JCA.weakest_link()` (full board + ≥3 jokers + a strict - minimum required, else nil) flags the lowest-total owned joker with a + minimum required, else nil) flags the lowest-scoring owned joker with a red verdict row; suppressed by learning mode, toggled by - `config.sell_advisor`. + `config.sell_advisor`. Its ranking subtracts `CLASH_PENALTY` (odd, so it + always breaks a tie) per clash with an owned joker — the joker eating + 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. - 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 @@ -147,6 +159,10 @@ Three-layer split; `JCA` is the single global namespace: `spectral_gen`): put the same tag in *both* `gives` and `wants` of every member so members mutually reinforce (2+2 = 4). Pure enablers (e.g. Four Fingers, Smeared, Pareidolia, DNA) only `give`; pure payoffs only `want`. + A cluster tag needs all three `TAG_TEXT` phrasings, not just `both`: `both` is + only true when *both* jokers sit on both sides of the tag, and `JCA.explain` + requires that before using it. Four Fingers does not "reward Straights" — it + makes them reachable. The suite enforces the three phrasings. - A tag needs **both sides to exist, on at least two different jokers**: something must give it, something must want it, or it is dead data that can never change a score — and if it is also a theme, an achievement nobody can earn. `lua test.lua` diff --git a/main.lua b/main.lua index 8a62e30..6b3689a 100644 --- a/main.lua +++ b/main.lua @@ -87,8 +87,13 @@ function JCA.explain(hovered, partner) for _, t in ipairs(data.tag_order) do local text = data.tag_text[t] if text then + -- "both reward Straights" only reads true when BOTH jokers sit on both + -- sides of the tag. A pure enabler (Four Fingers, DNA) gives it without + -- wanting it, so it gets the give/want phrasing instead — it does not + -- reward the hand, it makes the hand reachable. local cluster = text.both - and ((A.gives[t] and A.wants[t]) or (B.gives[t] and B.wants[t])) + and (A.gives[t] and A.wants[t]) + and (B.gives[t] and B.wants[t]) if B.gives[t] and A.wants[t] then return cluster and text.both or text.give elseif A.gives[t] and B.wants[t] then @@ -152,10 +157,65 @@ function JCA.is_recommended(card) return total >= JCA.config.threshold 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. +-- +-- 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. +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 + 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 + + -- 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 +end + -- Sell advisor: on a full board, the owned joker contributing the least -- total synergy to the rest is the natural cut when something better shows -- up. Returns nil when slots are free, the board is tiny, or every joker -- scores the same (nothing stands out to cut). +-- +-- Clashes count here, and ONLY here. JCA.score stays pure -- an anti-synergy +-- never changes a synergy score or a recommendation (a trap must never make a +-- card look like a combo). But when picking which joker to cut, a joker that is +-- actively eating another one's payoff is exactly the one you want gone, so a +-- clash with an owned joker is a penalty against keeping it. The penalty is odd +-- so it always breaks a tie against a clashing joker. +local CLASH_PENALTY = 3 + +local function keep_score(card, cards) + local _, total = JCA.partners_for(card) + local key = card.config.center.key + for _, other in ipairs(cards) do + local center = other ~= card and other.config and other.config.center + if center and JCA.clash_blurb[pair_key(key, center.key)] then + total = total - CLASH_PENALTY + end + end + return total +end + function JCA.weakest_link() if not (G.jokers and G.jokers.cards) then return nil end local cards = G.jokers.cards @@ -164,7 +224,7 @@ function JCA.weakest_link() local worst, worst_total, best_total for _, c in ipairs(cards) do if c.config and c.config.center and c.config.center.set == 'Joker' then - local _, total = JCA.partners_for(c) + local total = keep_score(c, cards) if not worst_total or total < worst_total then worst, worst_total = c, total end @@ -279,6 +339,10 @@ local TAG_LABEL = { diamonds = 'Diamonds', spades = 'Spades', clubs = 'Clubs', } +-- Exposed so the test suite can check every catalog tag has a label: a tag with no +-- TAG_LABEL entry silently vanishes from learning mode's "Looking for:" hints. +JCA.tag_label = TAG_LABEL + -- Labels for a joker's tag set, in stable data.tag_order order. local function tag_labels(tag_set) local labels = {} @@ -369,6 +433,26 @@ local function tooltip_rows(card) end local caution = in_buy_area(card) and JCA.cautions[key] if caution then text_row('Caution: ' .. caution, G.C.RED) end + + -- Copy jokers: say what this one is copying RIGHT NOW. The board order decides + -- it, and a Blueprint in the wrong slot is a dead card that looks fine. This is + -- an explanation, not a verdict, so learning mode keeps it. + if card.area == G.jokers then + local target, copyable = JCA.copy_source(card) + if not target then + if COPIERS[key] then + 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 + end + -- Sell advisor verdict (owned jokers only; suppressed in learning mode -- like the other verdicts). if JCA.config.sell_advisor and not JCA.config.learning_mode diff --git a/synergies.lua b/synergies.lua index 678e88d..762291c 100644 --- a/synergies.lua +++ b/synergies.lua @@ -54,7 +54,9 @@ j('j_misprint', {'mult'}) j('j_raised_fist', {'mult'}, {'retrig_held','hand_size'}) j('j_chaos', {}) j('j_scary_face', {'chips'}, {'faces','retrig_scoring'}) -j('j_abstract', {'mult'}) +-- Abstract Joker's Mult is the joker count (`#G.jokers.cards`, card.lua:4119), so +-- anything that hands you spare Jokers feeds it. Riff-Raff is +6 Mult on its own. +j('j_abstract', {'mult'}, {'joker_gen'}) j('j_delayed_grat', {'money'}) j('j_gros_michel', {'mult'}) j('j_even_steven', {'mult'}, {'retrig_scoring'}) @@ -67,7 +69,9 @@ j('j_egg', {'sell_value'}) j('j_runner', {'chips','straight'}, {'straight'}) j('j_ice_cream', {'chips'}) j('j_splash', {'retrig_scoring'}) -- makes every played card score -j('j_blue_joker', {'chips'}) +-- Blue Joker's Chips scale with the cards left in the deck (`#G.deck.cards`, +-- card.lua:4311), so every joker that permanently adds cards grows it. +j('j_blue_joker', {'chips'}, {'card_gen'}) j('j_faceless', {'money'}, {'faces','discards_up'}) j('j_green_joker', {'mult'}) j('j_superposition', {'tarot_gen','straight'}, {'straight'}) @@ -323,26 +327,44 @@ local TAG_TEXT = { probability = {give = 'improves listed odds', want = 'relies on listed odds'}, money = {give = 'earns money', want = 'scales with your money'}, tarot_gen = {give = 'creates Tarot cards', want = 'rewards Tarot use'}, - planet = {both = 'shares the Planet economy'}, - spectral_gen = {both = 'shares the Spectral theme'}, + planet = {both = 'shares the Planet economy', + give = 'creates Planet cards', want = 'rewards Planet use'}, + spectral_gen = {both = 'shares the Spectral theme', + give = 'creates Spectral cards', want = 'rewards Spectral use'}, gold_gen = {give = 'creates Gold cards', want = 'pays off Gold cards'}, stone_gen = {give = 'adds Stone cards', want = 'pays off Stone cards'}, enhance_gen = {give = 'enhances cards', want = 'feeds on enhancements'}, card_gen = {give = 'adds cards to the deck', want = 'grows as the deck grows'}, - joker_gen = {give = 'creates spare Jokers', want = 'profits when Jokers sell'}, + -- The want text has to fit every wanter: Campfire sells the spares, Swashbuckler + -- sums their sell value, Abstract Joker just counts them. + joker_gen = {give = 'creates spare Jokers', want = 'rewards spare Jokers'}, sell_value = {give = 'raises sell values', want = 'turns sell value into Mult'}, copy_target = {give = 'is a prime copy target', want = 'copies your best Joker'}, suit_flex = {give = 'makes suits count as others', want = 'cares which suits score'}, - pair = {both = 'both reward Pairs'}, - two_pair = {both = 'both reward Two Pair'}, - three_kind = {both = 'both reward Three of a Kind'}, - four_kind = {both = 'both reward Four of a Kind'}, - straight = {both = 'both reward Straights'}, - flush = {both = 'both reward Flushes'}, - hearts = {both = 'both love Hearts'}, - diamonds = {both = 'both love Diamonds'}, - spades = {both = 'both love Spades'}, - clubs = {both = 'both love Clubs'}, + -- Cluster tags need all three phrasings. `both` is only right when BOTH jokers + -- sit on both sides of the tag; a pure enabler (Four Fingers, DNA) gives the + -- tag without wanting it, and "both reward Straights" would be a lie -- Four + -- Fingers does not reward Straights, it makes them reachable. + pair = {both = 'both reward Pairs', + give = 'makes Pairs easier', want = 'rewards Pairs'}, + two_pair = {both = 'both reward Two Pair', + give = 'makes Two Pair easier', want = 'rewards Two Pair'}, + three_kind = {both = 'both reward Three of a Kind', + give = 'makes Three of a Kind easier', want = 'rewards Three of a Kind'}, + four_kind = {both = 'both reward Four of a Kind', + give = 'makes Four of a Kind easier', want = 'rewards Four of a Kind'}, + straight = {both = 'both reward Straights', + give = 'makes Straights easier', want = 'rewards Straights'}, + flush = {both = 'both reward Flushes', + give = 'makes Flushes easier', want = 'rewards Flushes'}, + hearts = {both = 'both love Hearts', + give = 'turns cards into Hearts', want = 'rewards Hearts'}, + diamonds = {both = 'both love Diamonds', + give = 'turns cards into Diamonds', want = 'rewards Diamonds'}, + spades = {both = 'both love Spades', + give = 'turns cards into Spades', want = 'rewards Spades'}, + clubs = {both = 'both love Clubs', + give = 'turns cards into Clubs', want = 'rewards Clubs'}, } -- Order in which tags are considered when picking the one reason to show; diff --git a/test.lua b/test.lua index 727d5f4..f3bcddb 100644 --- a/test.lua +++ b/test.lua @@ -237,6 +237,91 @@ for _, t in ipairs(data.tag_order) do ok(members[t] or ENGINE_TAGS[t], ('TAG_ORDER tag %q is used by at least one joker'):format(t)) end +-------------------------------------------------------------------------------- +section('Data: tooltip phrasing') +-------------------------------------------------------------------------------- + +-- A cluster tag needs all three phrasings. "both reward Straights" is only true +-- when BOTH jokers reward them; a pure enabler (Four Fingers, DNA) gives the tag +-- without wanting it and must not be described as rewarding the hand. +for _, t in ipairs(sorted_keys(data.tag_text)) do + local text = data.tag_text[t] + if text.both then + ok(text.give and text.want, + ('cluster tag %q has give/want phrasings, not just "both"'):format(t), + 'an enabler paired with a payoff would otherwise be told it "rewards" the hand') + end +end + +-- Four Fingers ENABLES straights (it does not reward them); Runner rewards them. +eq(JCA.explain('j_four_fingers', 'j_runner'), 'rewards Straights', + 'a payoff partner is described as rewarding the hand') +eq(JCA.explain('j_runner', 'j_four_fingers'), 'makes Straights easier', + 'an enabler partner is described as enabling, not rewarding') +eq(JCA.explain('j_dna', 'j_family'), 'rewards Four of a Kind', + 'The Family rewards Four of a Kind') +eq(JCA.explain('j_family', 'j_dna'), 'makes Four of a Kind easier', + 'DNA enables Four of a Kind') +-- Two full cluster members still get the "both" phrasing. +eq(JCA.explain('j_jolly', 'j_sly'), 'both reward Pairs', + 'two payoffs still read as "both"') + +for _, t in ipairs(sorted_keys(data.theme_info)) do + ok(JCA.tag_label[t], ('theme %q has a TAG_LABEL for learning-mode hints'):format(t)) +end + +-------------------------------------------------------------------------------- +section('Engine: copy jokers are positional') +-------------------------------------------------------------------------------- + +-- Blueprint copies the joker to its RIGHT, Brainstorm the LEFTMOST. The board +-- order decides what they actually copy, and a copier in the wrong slot is dead. +local bp_board = field('j_baron', 'j_blueprint', 'j_mime') +eq(JCA.copy_source(bp_board[2]), bp_board[3], 'Blueprint copies the joker to its right') + +field('j_baron', 'j_mime', 'j_blueprint') +eq(JCA.copy_source(G.jokers.cards[3]), nil, 'a rightmost Blueprint copies nothing') + +local bs_board = field('j_baron', 'j_brainstorm', 'j_mime') +eq(JCA.copy_source(bs_board[2]), bs_board[1], 'Brainstorm copies the leftmost joker') + +field('j_brainstorm', 'j_baron') +eq(JCA.copy_source(G.jokers.cards[1]), nil, 'a leftmost Brainstorm copies nothing') + +field('j_baron', 'j_mime') +eq(JCA.copy_source(G.jokers.cards[1]), nil, 'a non-copier has no copy source') + +-- vanilla's own "cannot be copied" flag: 29 jokers carry blueprint_compat = false +-- 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]) +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)') + +local fine = field('j_blueprint', 'j_baron') +eq(select(2, JCA.copy_source(fine[1])), true, 'a copyable target reports as copyable') + +-------------------------------------------------------------------------------- +section('Engine: the sell advisor weighs clashes') +-------------------------------------------------------------------------------- + +-- Clashes must never touch a synergy score... +local pre = JCA.score('j_vampire', 'j_ticket') +ok(pre >= 0, 'a clashing pair still scores by its tags alone') +ok(JCA.clash_blurb[ + ('j_ticket' < 'j_vampire') and 'j_ticket|j_vampire' or 'j_vampire|j_ticket'], + 'Vampire + Golden Ticket is a known clash') + +-- ...but on a full board, the joker stuck in a clash is the one to cut, even +-- when a joker with no synergy at all is sitting next to it. +local board = field('j_vampire', 'j_ticket', 'j_baron', 'j_mime') +G.jokers.config.card_limit = 4 +local cut = JCA.weakest_link() +ok(cut == board[1] or cut == board[2], + 'the sell advisor flags one of the two clashing jokers', + cut and cut.config.center.key or 'nil') + -------------------------------------------------------------------------------- section('Data: themes are reachable') --------------------------------------------------------------------------------