Files
funman300 4a86a3e1ff Give Perkeo its consumable wants; make theme fragility a decision
Perkeo duplicates a RANDOM held consumable (card.lua:2788), so Spectral
and Planet supply feed it exactly like Tarot supply always did - and the
spectral_gen theme gains the third member it needed (the Seance +
Perkeo negative-spectral archetype, now discoverable three ways).

The other five two-member themes (diamonds, hearts, four_kind, gold_gen,
stone_gen) are vanilla reality: no third joker exists. They get
FRAGILE_OK verdicts in audit.lua, rendered in AUDIT.md, and the audit
now fails on a fragile theme with no verdict - or a stale verdict for a
theme that grew - the same discipline as the silent-joker list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:46:41 -07:00

359 lines
16 KiB
Lua

-- Joker Combo Advisor - database audit.
--
-- lua audit.lua print the report
-- lua audit.lua --write print it and refresh AUDIT.md
--
-- test.lua is the GATE: it fails on things that are broken (dead tags, themes
-- nobody can discover, bad keys). This is the MAP: it reports things that are
-- merely suspicious, and would be noise as a test failure -- chiefly the jokers
-- the advisor is silent about.
--
-- The point of committing AUDIT.md is that the analysis does not have to be redone
-- from scratch every time. A joker scoring 0 against all 150 is not automatically a
-- bug; some jokers genuinely have no combo. The VERDICTS table below records which
-- ones we have already looked at and what we concluded, so each audit only surfaces
-- what is genuinely new or still open.
--------------------------------------------------------------------------------
-- Verdicts: what we have already decided about jokers the advisor is silent on.
--
-- settled - looked at it; it should stay silent, and here is why.
-- candidate - a real lead, NOT yet verified against the game source. Check
-- ~/.local/share/Balatro/Mods/lovely/dump before acting on one.
--------------------------------------------------------------------------------
local VERDICTS = {
-- Genuinely no joker-to-joker combo. These are the right answer, not a gap.
j_joker = {'settled', 'plain +Mult; interacts with nothing'},
j_misprint = {'settled', 'random +Mult; interacts with nothing'},
j_half = {'settled', '+Mult on small hands; no joker cares'},
j_credit_card= {'settled', 'lets you go negative; a money buffer, not a combo'},
j_mr_bones = {'settled', 'death insurance; no joker interaction'},
j_chicot = {'settled', 'disables the boss blind; no joker interaction'},
j_luchador = {'settled', 'sell to disable a boss; no joker interaction'},
j_diet_cola = {'settled', 'sell for a Double Tag; tags are outside our model'},
j_throwback = {'settled', 'xMult per skipped blind; no joker interaction'},
j_red_card = {'settled', '+Mult per skipped booster; no joker interaction'},
j_loyalty_card = {'settled', 'xMult every 6 hands; no joker interaction'},
j_ride_the_bus = {'settled', 'scales on face-free hands; the traps are CLASHES'},
j_ice_cream = {'settled', 'decays; the trap is a CAUTION'},
j_popcorn = {'settled', 'decays; the trap is a CAUTION'},
j_stuntman = {'settled', 'big chips, -2 hand size; the trap is a CAUTION'},
j_gros_michel= {'settled', 'self-destructs; the trap is a CAUTION'},
j_cavendish = {'settled', 'the reward for Gros Michel dying, not a partner'},
-- Deliberately silent, and this one is a TRAP for a future editor:
j_steel_joker = {'settled',
'wants STEEL cards. The only enhance_gen givers make Gold (Midas) and ' ..
'Stone (Marble), so tagging it enhance_gen would invent a synergy that ' ..
'does not exist. No vanilla joker creates Steel cards. Leave it alone.'},
j_glass = {'settled',
'wants GLASS cards, and no vanilla joker creates them. Same trap as ' ..
'Steel Joker: enhance_gen is too coarse a tag to use here.'},
-- Chased and resolved. These now have pairs, clashes or an advisor line, so
-- they no longer appear as silent -- kept here as the record of the decision.
j_ceremonial = {'settled',
'RESOLVED: it destroys the joker to its right at blind select ' ..
'(card.lua:2945), skipping Eternals. JCA.dagger_victim now names the ' ..
'joker about to die, in the tooltip, while there is still time to reorder.'},
j_erosion = {'settled',
'RESOLVED: pays per card missing from the deck (starting_deck_size - ' ..
'#G.playing_cards, card.lua:4318) -- the exact inverse of card_gen. Now ' ..
'clashes with DNA/Marble/Certificate, and pairs with Trading Card, which ' ..
'permanently destroys the card it discards.'},
j_ramen = {'settled',
'RESOLVED: paired with Burglar. It decays per discarded card and is ' ..
'DESTROYED at x1 (card.lua:3175); Burglar zeroes discards, so it never ' ..
'decays and never dies.'},
j_green_joker = {'settled', 'RESOLVED: paired with Burglar (no discards, only gains)'},
j_flash = {'settled', 'RESOLVED: paired with Chaos the Clown (free rerolls grow it)'},
j_chaos = {'settled', 'RESOLVED: paired with Flash Card; no longer inert'},
j_square = {'settled',
'Not actually a gap -- it was paired with Four Fingers all along. It has ' ..
'no TAGS, which is what made the old silent-check flag it. That check now ' ..
'excludes jokers with a famous pair.'},
j_mystic_summit = {'settled', 'already paired with Burglar (zero discards, max Mult)'},
-- Chased and REJECTED. Do not "fix" these; the reasoning is the point.
j_card_sharp = {'settled',
'REJECTED as a clash with Obelisk. Card Sharp rewards replaying a hand ' ..
'and Obelisk punishes replaying your MOST played hand -- but neither ' ..
'destroys the other precondition; the player\'s hand choice does. That is ' ..
'an opposed incentive, not an opposed effect. Flagging it would dilute the ' ..
'clash signal, which only earns trust while every entry is mechanical. ' ..
'LATER RESOLVED the other way: paired with Burglar and wants hands_up -- ' ..
'its x3 needs the same hand twice in one round (played_this_round > 1, ' ..
'card.lua:4465, reset per round at state_events.lua:252), and +3 hands ' ..
'makes that routine.'},
j_supernova = {'settled', 'REJECTED as an Obelisk clash for the same reason as j_card_sharp'},
j_obelisk = {'settled',
'The trap is real but it is a CAUTION ("resets on your most played hand"), ' ..
'not a set of pairwise clashes. See j_card_sharp.'},
j_baseball = {'settled',
'xMult per Uncommon joker owned. A rarity tag would have exactly one ' ..
'payoff, and every giver would be "is uncommon" -- noise, not synergy.'},
j_ring_master = {'settled',
'Showman allows duplicates in the shop pool. We have no tag for pool ' ..
'effects, and one joker does not justify inventing one.'},
}
--------------------------------------------------------------------------------
-- Stubs: just enough game for the engine to load.
--------------------------------------------------------------------------------
SMODS = {load_file = function(f) return loadfile(f) end,
current_mod = {config = dofile('config.lua')}}
Card = {generate_UIBox_ability_table = function() end, update = function() end}
G = {UIT = {ROOT = 'ROOT', R = 'R', T = 'T'},
C = {UI = {TEXT_DARK = 'd'}, GREEN = 'g', CLEAR = 'c', RED = 'r', GOLD = 'go'},
STAGES = {RUN = 1}, FUNCS = {}}
local data = dofile('synergies.lua')
dofile('main.lua')
local ENGINE = {mult = true, chips = true, xmult = true}
local out = {}
local function w(fmt, ...)
out[#out + 1] = select('#', ...) > 0 and fmt:format(...) or fmt
end
local function sorted(t)
local keys = {}
for k in pairs(t) do keys[#keys + 1] = k end
table.sort(keys)
return keys
end
--------------------------------------------------------------------------------
-- Gather
--------------------------------------------------------------------------------
local givers, wanters, members = {}, {}, {}
local n_jokers = 0
for key, e in pairs(JCA.db) do
n_jokers = n_jokers + 1
for t in pairs(e.gives) do
givers[t] = (givers[t] or 0) + 1
members[t] = members[t] or {}; members[t][key] = true
end
for t in pairs(e.wants) do
wanters[t] = (wanters[t] or 0) + 1
members[t] = members[t] or {}; members[t][key] = true
end
end
local in_pair = {}
for _, p in ipairs(data.pairs) do in_pair[p[1]] = true; in_pair[p[2]] = true end
-- "Silent": nothing but engine roles to give, nothing wanted, AND no famous pair.
-- Such a joker can only ever earn the generic +1, so the advisor has nothing to
-- say about it against any of the 150.
--
-- The pair check matters: without it this list flags jokers the advisor speaks up
-- about perfectly well (Square Joker has no tags but has been paired with Four
-- Fingers all along), and a lead gets "found" that was never lost.
local silent = {}
for key, e in pairs(JCA.db) do
local has_theme, wants = false, 0
for t in pairs(e.gives) do if not ENGINE[t] then has_theme = true end end
for _ in pairs(e.wants) do wants = wants + 1 end
if not has_theme and wants == 0 and not in_pair[key] then
silent[#silent + 1] = key
end
end
table.sort(silent)
--------------------------------------------------------------------------------
-- Report
--------------------------------------------------------------------------------
w('# Synergy database audit')
w('')
w('Generated by `lua audit.lua --write`. Do not edit by hand.')
w('')
w('`test.lua` is the gate (it fails on what is broken). This is the map: it reports')
w('what is merely *suspicious*, and records what we already decided so the analysis')
w('does not get redone from scratch every session.')
w('')
w('## Totals')
w('')
w('| | |')
w('|---|---|')
w('| Jokers in the database | %d |', n_jokers)
w('| Famous pairs | %d |', #data.pairs)
w('| Clashes | %d |', #(data.clashes or {}))
w('| Cautions | %d |', (function() local n = 0 for _ in pairs(data.cautions or {}) do n = n + 1 end return n end)())
w('| Themes | %d |', (function() local n = 0 for _ in pairs(data.theme_info or {}) do n = n + 1 end return n end)())
w('| Jokers in no famous pair | %d |', (function()
local n = 0
for k in pairs(JCA.db) do if not in_pair[k] then n = n + 1 end end
return n
end)())
w('')
--------------------------------------------------------------------------------
w('## Jokers the advisor is silent about')
w('')
w('These give nothing but engine roles (Mult/Chips/xMult) and want nothing, so they')
w('score at most the generic +1 against any joker in the game. That is a legitimate')
w('answer for some of them -- but it should be a decision, not an accident.')
w('')
local buckets = {settled = {}, candidate = {}, unreviewed = {}}
for _, key in ipairs(silent) do
local v = VERDICTS[key]
local bucket = v and v[1] or 'unreviewed'
table.insert(buckets[bucket], key)
end
w('| | count |')
w('|---|---|')
w('| Silent, reviewed, staying that way | %d |', #buckets.settled)
w('| Silent, but a lead worth chasing | %d |', #buckets.candidate)
w('| **Silent and never looked at** | **%d** |', #buckets.unreviewed)
w('')
if #buckets.unreviewed > 0 then
w('### Never looked at')
w('')
w('New arrivals. Each one scores ~0 against all %d jokers; work out whether that', n_jokers)
w('is correct and add a verdict to `VERDICTS` in `audit.lua` either way.')
w('')
for _, key in ipairs(buckets.unreviewed) do w('- `%s`', key) end
w('')
end
w('### Leads worth chasing')
w('')
w('Unverified. Check each against the Lovely dump before acting on it -- a wrong')
w('synergy is worse than a missing one.')
w('')
for _, key in ipairs(buckets.candidate) do
w('- **`%s`** — %s', key, VERDICTS[key][2])
end
w('')
w('### Reviewed, and deliberately silent')
w('')
for _, key in ipairs(buckets.settled) do
w('- **`%s`** — %s', key, VERDICTS[key][2])
end
w('')
--------------------------------------------------------------------------------
-- Two-member themes, reviewed and accepted: vanilla simply has no third joker.
-- A fragile theme NOT in this table fails the audit -- either find a member or
-- record why none exists. A stale entry (theme grew past two) also fails, so
-- the table never drifts from the data.
local FRAGILE_OK = {
diamonds = 'Greedy Joker and Rough Gem are the only Diamond payoffs in vanilla.',
hearts = 'Lusty Joker and Bloodstone are the only Heart payoffs in vanilla.',
four_kind = 'DNA enables it, The Family pays it; no other vanilla joker cares.',
gold_gen = 'Midas Mask makes Gold, Golden Ticket cashes it - the whole economy.',
stone_gen = 'Marble adds Stones, Stone Joker counts them; Vampire eats them rather than wanting them.',
}
w('## Tag health')
w('')
w('A tag with one member can never fire (it takes two jokers to make a synergy).')
w('A tag with two is fragile: drop either joker and the theme dies.')
w('')
w('| tag | givers | wanters | jokers | theme? |')
w('|---|---|---|---|---|')
local fragile_unreviewed, fragile_stale = {}, {}
local fragile_counts = {}
for _, t in ipairs(sorted(members)) do
if not ENGINE[t] then
local n = 0
for _ in pairs(members[t]) do n = n + 1 end
fragile_counts[t] = n
if data.theme_info[t] and n <= 2 and not FRAGILE_OK[t] then
fragile_unreviewed[#fragile_unreviewed + 1] = t
end
w('| `%s` | %d | %d | %s | %s |', t, givers[t] or 0, wanters[t] or 0,
n <= 2 and ('**' .. n .. '**') or tostring(n),
data.theme_info[t] and 'yes' or '-')
end
end
for t in pairs(FRAGILE_OK) do
if (fragile_counts[t] or 0) > 2 then fragile_stale[#fragile_stale + 1] = t end
end
w('')
w('### Fragile by design')
w('')
w('Two-member themes reviewed and accepted: vanilla has no third joker to add.')
w('')
for _, t in ipairs(sorted(FRAGILE_OK)) do
if (fragile_counts[t] or 0) <= 2 then
w('- **`%s`** — %s', t, FRAGILE_OK[t])
end
end
w('')
--------------------------------------------------------------------------------
-- Which jokers carry the most weight? Losing one of these costs the most edges.
w('## Load-bearing jokers')
w('')
w('The jokers that appear in the most scoring relationships. If one of these is')
w('mis-tagged, the error is spread across the whole database.')
w('')
local edges = {}
for a in pairs(JCA.db) do
local n = 0
for b in pairs(JCA.db) do
if a ~= b and JCA.score(a, b) >= 2 then n = n + 1 end
end
edges[#edges + 1] = {key = a, n = n}
end
table.sort(edges, function(x, y)
if x.n ~= y.n then return x.n > y.n end
return x.key < y.key
end)
w('| joker | partners scoring >= 2 |')
w('|---|---|')
for i = 1, 12 do w('| `%s` | %d |', edges[i].key, edges[i].n) end
w('')
local isolated = {}
for _, e in ipairs(edges) do
if e.n == 0 then isolated[#isolated + 1] = e.key end
end
w('Jokers with **no** partner scoring >= 2 anywhere in the game: %d', #isolated)
w('')
--------------------------------------------------------------------------------
local text = table.concat(out, '\n') .. '\n'
print(text)
if arg[1] == '--write' then
local f = assert(io.open('AUDIT.md', 'w'))
f:write(text)
f:close()
io.stderr:write('wrote AUDIT.md\n')
end
-- A joker that scores 0 against the entire game should be a decision, not an
-- accident, so a never-reviewed one is a failure: record a verdict either way.
if #buckets.unreviewed > 0 then
io.stderr:write(('\n%d silent joker(s) have never been reviewed. Each scores ~0\n'
.. 'against all %d jokers. Decide whether that is right and add a verdict to\n'
.. 'VERDICTS in audit.lua:\n %s\n')
:format(#buckets.unreviewed, n_jokers, table.concat(buckets.unreviewed, '\n ')))
os.exit(1)
end
-- A theme hanging on two jokers is a discovery achievement one rebalance away
-- from impossible; that has to be a decision too.
if #fragile_unreviewed > 0 then
io.stderr:write(('\n%d fragile theme(s) (two members) have no FRAGILE_OK verdict:\n %s\n')
:format(#fragile_unreviewed, table.concat(fragile_unreviewed, '\n ')))
os.exit(1)
end
if #fragile_stale > 0 then
io.stderr:write(('\nFRAGILE_OK verdict(s) for themes that are no longer fragile -- '
.. 'delete them:\n %s\n'):format(table.concat(fragile_stale, '\n ')))
os.exit(1)
end