Add audit.lua: the map that test.lua is not

test.lua is the gate -- it fails on what is broken. This is the map: it
reports what is merely suspicious, and (the actual point) it REMEMBERS.

32 jokers score ~0 against all 150: they give nothing but engine roles
and want nothing, so the advisor has nothing to say about them. For most
that is the correct answer, but I have now worked that question out from
scratch twice, and the reasoning evaporated both times. VERDICTS records
the conclusion for each one, so an audit only surfaces what is new.

It preserves the traps: Steel Joker and Glass Joker look exactly like
Blue Joker and Abstract Joker did, but they want Steel/Glass cards
specifically, and the only enhance_gen givers make Gold and Stone -- so
"fixing" them would invent a synergy that does not exist. That is written
down now instead of being rediscovered.

It also preserves the leads not yet chased (Ceremonial Dagger's
positional destroy, Erosion vs the card-adders, Burglar + Ramen and Green
Joker, Chaos + Flash Card), marked candidate because they are unverified.

A newly-silent joker lands in "never looked at" and fails the audit until
someone decides. AUDIT.md is committed so the map is readable without
running anything.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-14 12:28:46 -07:00
parent e688de0efb
commit 96a00d0d0c
3 changed files with 450 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
-- 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.'},
-- Leads worth chasing. NOT verified against the game source yet.
j_ceremonial = {'candidate',
'positional like Blueprint: it DESTROYS the joker to its right. ' ..
'JCA.copy_source already reads board order -- the same machinery could ' ..
'warn "will destroy your Baron" before it eats something irreplaceable.'},
j_erosion = {'candidate',
'+Mult per card BELOW 52 in the deck, so it is the inverse of card_gen: ' ..
'DNA/Marble/Certificate permanently shrink it. Likely CLASHES, and ' ..
'possibly a synergy with anything that destroys cards (Trading Card).'},
j_ramen = {'candidate',
'xMult decays per card discarded, so Burglar (zero discards all run) ' ..
'stops the decay dead. Likely a famous pair.'},
j_green_joker = {'candidate',
'+Mult per hand, -Mult per discard: Burglar removes discards entirely. ' ..
'Same shape as the existing Burglar + Mystic Summit pair.'},
j_flash = {'candidate',
'+Mult per shop reroll. Chaos the Clown grants a free reroll each shop, ' ..
'which would give both of them a reason to exist (Chaos is inert today).'},
j_chaos = {'candidate', 'free reroll each shop; see j_flash'},
j_square = {'candidate',
'+Chips on exactly-4-card hands; Four Fingers makes 4-card flushes and ' ..
'straights playable. Needs a tag that does not exist yet.'},
j_card_sharp = {'candidate',
'xMult when the hand type was already played this round -- it rewards ' ..
'repeating one hand, which is exactly what Obelisk punishes. Clash?'},
j_supernova = {'candidate', 'Mult scales with times the hand was played; see j_card_sharp'},
j_obelisk = {'candidate',
'resets on your most-played hand, so it fights every hand-type build. ' ..
'Has a CAUTION already; the pairwise clashes may be worth adding.'},
j_baseball = {'candidate',
'xMult per Uncommon joker owned. We have no rarity tag, and adding one ' ..
'for a single payoff may not earn its keep.'},
j_mystic_summit = {'settled', 'already paired with Burglar (zero discards, max Mult)'},
j_ring_master = {'candidate',
'Showman allows duplicates in the pool. Enables copy/duplicate strategies, ' ..
'but we have no tag for "shop pool" effects and inventing one for a single ' ..
'joker is probably not worth it.'},
}
--------------------------------------------------------------------------------
-- 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
-- "Silent": nothing but engine roles to give, and nothing wanted. Such a joker
-- can only ever earn the generic +1, so the advisor has nothing to say about it.
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 then silent[#silent + 1] = key end
end
table.sort(silent)
local in_pair = {}
for _, p in ipairs(data.pairs) do in_pair[p[1]] = true; in_pair[p[2]] = true end
--------------------------------------------------------------------------------
-- 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('')
--------------------------------------------------------------------------------
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('|---|---|---|---|---|')
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
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
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