-- 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('') -------------------------------------------------------------------------------- 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