diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..5028f57 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,131 @@ +# Synergy database audit + +Generated by `lua audit.lua --write`. Do not edit by hand. + +`test.lua` is the gate (it fails on what is broken). This is the map: it reports +what is merely *suspicious*, and records what we already decided so the analysis +does not get redone from scratch every session. + +## Totals + +| | | +|---|---| +| Jokers in the database | 150 | +| Famous pairs | 32 | +| Clashes | 22 | +| Cautions | 15 | +| Themes | 29 | +| Jokers in no famous pair | 108 | + +## Jokers the advisor is silent about + +These give nothing but engine roles (Mult/Chips/xMult) and want nothing, so they +score at most the generic +1 against any joker in the game. That is a legitimate +answer for some of them -- but it should be a decision, not an accident. + +| | count | +|---|---| +| Silent, reviewed, staying that way | 20 | +| Silent, but a lead worth chasing | 12 | +| **Silent and never looked at** | **0** | + +### Leads worth chasing + +Unverified. Check each against the Lovely dump before acting on it -- a wrong +synergy is worse than a missing one. + +- **`j_baseball`** — xMult per Uncommon joker owned. We have no rarity tag, and adding one for a single payoff may not earn its keep. +- **`j_card_sharp`** — xMult when the hand type was already played this round -- it rewards repeating one hand, which is exactly what Obelisk punishes. Clash? +- **`j_ceremonial`** — 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_chaos`** — free reroll each shop; see j_flash +- **`j_erosion`** — +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_flash`** — +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_green_joker`** — +Mult per hand, -Mult per discard: Burglar removes discards entirely. Same shape as the existing Burglar + Mystic Summit pair. +- **`j_obelisk`** — 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_ramen`** — xMult decays per card discarded, so Burglar (zero discards all run) stops the decay dead. Likely a famous pair. +- **`j_ring_master`** — 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. +- **`j_square`** — +Chips on exactly-4-card hands; Four Fingers makes 4-card flushes and straights playable. Needs a tag that does not exist yet. +- **`j_supernova`** — Mult scales with times the hand was played; see j_card_sharp + +### Reviewed, and deliberately silent + +- **`j_cavendish`** — the reward for Gros Michel dying, not a partner +- **`j_chicot`** — disables the boss blind; no joker interaction +- **`j_credit_card`** — lets you go negative; a money buffer, not a combo +- **`j_diet_cola`** — sell for a Double Tag; tags are outside our model +- **`j_glass`** — wants GLASS cards, and no vanilla joker creates them. Same trap as Steel Joker: enhance_gen is too coarse a tag to use here. +- **`j_gros_michel`** — self-destructs; the trap is a CAUTION +- **`j_half`** — +Mult on small hands; no joker cares +- **`j_ice_cream`** — decays; the trap is a CAUTION +- **`j_joker`** — plain +Mult; interacts with nothing +- **`j_loyalty_card`** — xMult every 6 hands; no joker interaction +- **`j_luchador`** — sell to disable a boss; no joker interaction +- **`j_misprint`** — random +Mult; interacts with nothing +- **`j_mr_bones`** — death insurance; no joker interaction +- **`j_mystic_summit`** — already paired with Burglar (zero discards, max Mult) +- **`j_popcorn`** — decays; the trap is a CAUTION +- **`j_red_card`** — +Mult per skipped booster; no joker interaction +- **`j_ride_the_bus`** — scales on face-free hands; the traps are CLASHES +- **`j_steel_joker`** — 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_stuntman`** — big chips, -2 hand size; the trap is a CAUTION +- **`j_throwback`** — xMult per skipped blind; no joker interaction + +## Tag health + +A tag with one member can never fire (it takes two jokers to make a synergy). +A tag with two is fragile: drop either joker and the theme dies. + +| tag | givers | wanters | jokers | theme? | +|---|---|---|---|---| +| `card_gen` | 3 | 2 | 5 | yes | +| `clubs` | 3 | 4 | 4 | yes | +| `copy_target` | 10 | 3 | 13 | yes | +| `diamonds` | 2 | 2 | **2** | yes | +| `discards_up` | 2 | 8 | 10 | yes | +| `enhance_gen` | 2 | 2 | 4 | yes | +| `faces` | 1 | 9 | 10 | yes | +| `flush` | 5 | 4 | 6 | yes | +| `four_kind` | 2 | 1 | **2** | yes | +| `gold_gen` | 1 | 1 | **2** | yes | +| `hand_size` | 3 | 4 | 7 | yes | +| `hands_up` | 1 | 1 | **2** | yes | +| `hearts` | 2 | 2 | **2** | yes | +| `joker_gen` | 1 | 3 | 4 | yes | +| `money` | 15 | 3 | 17 | yes | +| `pair` | 4 | 3 | 4 | yes | +| `planet` | 3 | 3 | 3 | yes | +| `probability` | 1 | 7 | 8 | yes | +| `retrig_held` | 1 | 4 | 5 | yes | +| `retrig_scoring` | 6 | 16 | 22 | yes | +| `sell_value` | 2 | 2 | 4 | yes | +| `spades` | 2 | 3 | 3 | yes | +| `spectral_gen` | 2 | 2 | **2** | yes | +| `stone_gen` | 1 | 1 | **2** | yes | +| `straight` | 7 | 6 | 8 | yes | +| `suit_flex` | 1 | 11 | 12 | yes | +| `tarot_gen` | 6 | 2 | 7 | yes | +| `three_kind` | 4 | 3 | 4 | yes | +| `two_pair` | 3 | 3 | 3 | yes | + +## Load-bearing jokers + +The jokers that appear in the most scoring relationships. If one of these is +mis-tagged, the error is spread across the whole database. + +| joker | partners scoring >= 2 | +|---|---| +| `j_sock_and_buskin` | 18 | +| `j_dusk` | 16 | +| `j_hack` | 16 | +| `j_hanging_chad` | 16 | +| `j_selzer` | 16 | +| `j_smeared` | 16 | +| `j_splash` | 16 | +| `j_to_the_moon` | 16 | +| `j_bootstraps` | 15 | +| `j_bull` | 15 | +| `j_four_fingers` | 12 | +| `j_seance` | 12 | + +Jokers with **no** partner scoring >= 2 anywhere in the game: 30 + diff --git a/CLAUDE.md b/CLAUDE.md index c140af1..4e9c59f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,26 @@ It enforces, among other things: To poke at the engine by hand, copy the stub block from the top of `test.lua`. +Then the audit, after any `synergies.lua` change: + +```sh +lua audit.lua --write # refreshes AUDIT.md; exits 1 on an unreviewed joker +``` + +`test.lua` is the **gate** — it fails on what is broken. `audit.lua` is the **map** — +it reports what is merely suspicious, chiefly the ~32 jokers the advisor is silent +about (they give nothing but Mult/Chips/xMult and want nothing, so they can only ever +earn the generic +1). Silence is the *right* answer for many of them, but it must be a +decision rather than an accident, so `audit.lua` carries a `VERDICTS` table recording +what we concluded about each one and why — including the traps. **Read `AUDIT.md` +before re-deriving any of this.** A newly-silent joker lands in "never looked at" and +fails the audit until it gets a verdict. + +The verdicts also hold the leads not yet chased (Ceremonial Dagger's positional +destroy, Erosion vs the card-adders, Burglar + Ramen/Green Joker, Chaos + Flash Card). +They are marked `candidate` because they are **unverified** — check the dump before +acting on one. + ## In-game verification (this machine) - The mod is **symlinked** into the live Mods folder: diff --git a/audit.lua b/audit.lua new file mode 100644 index 0000000..44d2543 --- /dev/null +++ b/audit.lua @@ -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