4a86a3e1ff
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>
359 lines
16 KiB
Lua
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
|