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) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-14 12:24:33 -07:00
parent bf0672a438
commit e688de0efb
4 changed files with 226 additions and 19 deletions
+18 -2
View File
@@ -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`
+86 -2
View File
@@ -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
+37 -15
View File
@@ -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;
+85
View File
@@ -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')
--------------------------------------------------------------------------------