07cada9e49
There was no test file -- CLAUDE.md described the harness in prose and referred to an "orphan-tag check" that did not exist. test.lua now stubs the game globals and exercises the real engine, database and catalog tab builders. It found three bugs on its first run: * four_kind was the only theme in the catalog that could never be discovered. Theme discovery needs a giver and a wanter on two DIFFERENT fielded jokers, and The Family was its sole member -- so the Hands bar and the overall Progress bar could never reach 100%. DNA is the real enabler (copying a card is how you stack duplicate ranks, the only vanilla route to a reliable Four of a Kind), so it now gives the matching-hand tags as a pure enabler. * hand_level was given by Space Joker and Burnt Joker and wanted by nobody, so it could never contribute to a score and its "want" tooltip text was unreachable. Nothing in vanilla rewards hand levels; the tag is dropped rather than faked. * Invisible Joker had no tags at all, so it scored 0 against the entire game. Selling it duplicates one of your Jokers -- the same "stronger target, better payoff" logic Blueprint and Brainstorm already model -- so it now wants copy_target. The suite guards both directions of the tag contract (no orphan wants, no dead gives), theme reachability, blurb widths, the inert-joker list, and the card materialize fix from the previous commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
396 lines
16 KiB
Lua
396 lines
16 KiB
Lua
-- Joker Combo Advisor - standalone test suite.
|
|
--
|
|
-- lua test.lua
|
|
--
|
|
-- The game cannot be launched headlessly, so this stubs the handful of Balatro
|
|
-- globals main.lua touches and then exercises the real engine, the real
|
|
-- database, and the real catalog tab builders. It covers three things:
|
|
--
|
|
-- * ENGINE - scoring tiers, partner aggregation, the sell advisor.
|
|
-- * DATA - the invariants that keep synergies.lua honest. A tag that
|
|
-- nothing wants, or a theme too small to ever be discovered, is
|
|
-- silent dead weight in the game; only a check catches it.
|
|
-- * UI - the catalog pages build, and every card they place animates.
|
|
--
|
|
-- Run it after every synergies.lua edit.
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Test harness
|
|
--------------------------------------------------------------------------------
|
|
|
|
local pass, fail = 0, 0
|
|
local function ok(cond, what, detail)
|
|
if cond then
|
|
pass = pass + 1
|
|
else
|
|
fail = fail + 1
|
|
print((' FAIL %s%s'):format(what, detail and ('\n ' .. detail) or ''))
|
|
end
|
|
return cond
|
|
end
|
|
local function eq(got, want, what)
|
|
return ok(got == want, what, ('expected %s, got %s'):format(tostring(want), tostring(got)))
|
|
end
|
|
local function section(name) print('\n== ' .. name .. ' ==') end
|
|
|
|
-- Sorted key list, so failure messages are stable and diffable.
|
|
local function sorted_keys(t)
|
|
local keys = {}
|
|
for k in pairs(t) do keys[#keys + 1] = k end
|
|
table.sort(keys)
|
|
return keys
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Game global stubs (only what main.lua actually reaches for)
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- G.C is full of colour tables; auto-vivify so any colour lookup works.
|
|
local function auto()
|
|
return setmetatable({}, {__index = function(t, k)
|
|
local v = setmetatable({}, getmetatable(t))
|
|
rawset(t, k, v)
|
|
return v
|
|
end})
|
|
end
|
|
|
|
SMODS = {
|
|
load_file = function(f) return loadfile(f) end,
|
|
current_mod = {config = dofile('config.lua')},
|
|
save_mod_config = function() end,
|
|
}
|
|
|
|
G = {
|
|
UIT = setmetatable({}, {__index = function(_, k) return k end}),
|
|
C = auto(),
|
|
STAGES = {RUN = 1},
|
|
STAGE = 1,
|
|
FUNCS = {},
|
|
ROOM = {T = {x = 0, y = 0, w = 20, h = 12}},
|
|
CARD_W = 1, CARD_H = 1.4,
|
|
P_CARDS = {empty = {}},
|
|
P_CENTERS = {},
|
|
SETTINGS = {profile = 1},
|
|
PROFILES = {{all_unlocked = false}},
|
|
GAME = {},
|
|
}
|
|
|
|
-- Cards the catalog pages build, so the UI section can inspect them.
|
|
local emplaced, materialized, sounds = {}, {}, 0
|
|
|
|
CardArea = function(x, y, w, h, cfg)
|
|
return {
|
|
T = {x = x, y = y, w = w, h = h}, config = cfg, cards = {},
|
|
emplace = function(self, c)
|
|
self.cards[#self.cards + 1] = c
|
|
emplaced[#emplaced + 1] = c
|
|
end,
|
|
}
|
|
end
|
|
|
|
Card = setmetatable({
|
|
generate_UIBox_ability_table = function() end,
|
|
update = function() end,
|
|
}, {__call = function(_, x, y, w, h, front, center)
|
|
local card
|
|
card = {
|
|
T = {x = x, y = y, w = w, h = h},
|
|
config = {center = center},
|
|
ability = {set = 'Joker'},
|
|
children = {}, states = {hover = {}},
|
|
juice_up = function() end,
|
|
start_materialize = function(_, _, silent)
|
|
materialized[card] = true
|
|
if not silent then sounds = sounds + 1 end
|
|
end,
|
|
}
|
|
return card
|
|
end})
|
|
|
|
UIBox = function(args) return {def = args and args.definition, remove = function() end} end
|
|
create_option_cycle = function() return {n = 'R', config = {}, nodes = {}} end
|
|
localize = function() error('no localization outside the game') end
|
|
|
|
local data = dofile('synergies.lua')
|
|
dofile('main.lua')
|
|
|
|
-- Every joker the database knows needs a center for the UI to render it.
|
|
for key in pairs(JCA.db) do
|
|
G.P_CENTERS[key] = {key = key, name = key, set = 'Joker', rarity = 1}
|
|
end
|
|
|
|
-- Engine tags carry no teaching text and are deliberately never "wanted" --
|
|
-- the generic mult/xmult complement handles them instead.
|
|
local ENGINE_TAGS = {mult = true, chips = true, xmult = true}
|
|
|
|
-- Put jokers on the board, as G.jokers.cards holding real center references.
|
|
local function field(...)
|
|
local cards = {}
|
|
for _, key in ipairs({...}) do
|
|
cards[#cards + 1] = {config = {center = {key = key, name = key, set = 'Joker'}}}
|
|
end
|
|
G.jokers = {cards = cards, config = {card_limit = #cards}}
|
|
return cards
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('Engine: scoring tiers')
|
|
--------------------------------------------------------------------------------
|
|
|
|
eq(JCA.score('j_baron', 'j_mime'), 6, 'famous pair + shared tags = 6')
|
|
eq(JCA.score('j_blueprint', 'j_brainstorm'), 4, 'famous pair alone = 4')
|
|
eq(JCA.score('j_jolly', 'j_sly'), 4, 'hand-type cluster (2 + 2) = 4')
|
|
-- Hack gives retrig_scoring, which The Idol wants; neither side triggers the
|
|
-- generic complement (Hack adds no Mult or Chips), so this is a clean 2.
|
|
eq(JCA.score('j_hack', 'j_idol'), 2, 'single tag match = 2')
|
|
eq(JCA.score('j_8_ball', 'j_scary_face'), 0, 'unrelated jokers = 0')
|
|
-- The generic complement stacks on top of a tag match: Gluttonous gives clubs
|
|
-- (Blackboard wants them) AND feeds Blackboard's xMult, so 2 + 1.
|
|
eq(JCA.score('j_gluttenous_joker', 'j_blackboard'), 3, 'tag match + generic complement = 3')
|
|
|
|
eq(JCA.score('j_not_a_real_joker', 'j_baron'), 0, 'unknown key (other mods) scores 0')
|
|
eq(JCA.score('j_not_a_real_joker', 'j_also_fake'), 0, 'two unknown keys score 0')
|
|
|
|
ok(select(2, JCA.score('j_gluttenous_joker', 'j_blackboard')),
|
|
'generic +Mult x xMult complement flags itself')
|
|
ok(not select(2, JCA.score('j_joker', 'j_stencil')),
|
|
'no_generic opts Joker Stencil out of the generic complement')
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('Engine: partners, recommendation, sell advisor')
|
|
--------------------------------------------------------------------------------
|
|
|
|
field('j_mime', 'j_hack')
|
|
local partners, total = JCA.partners_for({config = {center = {key = 'j_baron', set = 'Joker'}}})
|
|
ok(#partners >= 1, 'Baron finds a partner on a Mime board')
|
|
eq(partners[1].key, 'j_mime', 'strongest partner sorts first')
|
|
ok(total >= JCA.config.threshold, 'Baron clears the threshold next to Mime')
|
|
|
|
-- An unrelated board names no partners. The total can still be 1 (Baron's xMult
|
|
-- against Scary Face's chips is the generic complement), which is exactly why
|
|
-- the threshold is even -- see below.
|
|
field('j_8_ball', 'j_scary_face')
|
|
local nobody, low = JCA.partners_for({config = {center = {key = 'j_baron', set = 'Joker'}}})
|
|
eq(#nobody, 0, 'no partners named on an unrelated board')
|
|
ok(low < JCA.config.threshold, 'an unrelated board stays under the threshold')
|
|
|
|
-- The generic +1 must never be able to trigger a recommendation on its own:
|
|
-- tag and pair scores are even and the threshold is even, so a pulsing card
|
|
-- always has a nameable partner.
|
|
ok(JCA.config.threshold % 2 == 0, 'threshold is even, so the generic +1 alone cannot recommend')
|
|
|
|
field('j_baron', 'j_mime') -- 2 jokers, board not full
|
|
eq(JCA.weakest_link(), nil, 'no sell advice below 3 jokers')
|
|
local board = field('j_baron', 'j_mime', 'j_8_ball')
|
|
eq(JCA.weakest_link(), board[3], 'flags the joker with the least synergy')
|
|
G.jokers.config.card_limit = 5
|
|
eq(JCA.weakest_link(), nil, 'no sell advice while slots are free')
|
|
field('j_8_ball', 'j_8_ball', 'j_8_ball')
|
|
eq(JCA.weakest_link(), nil, 'no sell advice when every joker scores the same')
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('Data: tag integrity')
|
|
--------------------------------------------------------------------------------
|
|
|
|
local givers, wanters, members = {}, {}, {}
|
|
for key, e in pairs(JCA.db) do
|
|
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
|
|
|
|
-- A wanted tag nobody gives is a payoff with no enabler.
|
|
for _, t in ipairs(sorted_keys(wanters)) do
|
|
ok(givers[t], ('tag %q is wanted (%dx) and given by someone'):format(t, wanters[t]))
|
|
end
|
|
|
|
-- The mirror image, and the one that actually bit us: a tag given but never
|
|
-- wanted can never add to a score, so it is invisible dead weight.
|
|
for _, t in ipairs(sorted_keys(givers)) do
|
|
if not ENGINE_TAGS[t] then
|
|
ok(wanters[t], ('tag %q is given (%dx) and wanted by someone'):format(t, givers[t]),
|
|
'nothing wants it, so it can never affect a score')
|
|
end
|
|
end
|
|
|
|
for _, t in ipairs(sorted_keys(givers)) do
|
|
if not ENGINE_TAGS[t] then
|
|
ok(data.tag_text[t], ('tag %q has TAG_TEXT for the tooltip'):format(t))
|
|
end
|
|
end
|
|
|
|
local in_order = {}
|
|
for _, t in ipairs(data.tag_order) do in_order[t] = true end
|
|
for _, t in ipairs(sorted_keys(members)) do
|
|
if not ENGINE_TAGS[t] then
|
|
ok(in_order[t], ('tag %q appears in TAG_ORDER'):format(t))
|
|
end
|
|
end
|
|
for _, t in ipairs(data.tag_order) do
|
|
ok(members[t] or ENGINE_TAGS[t], ('TAG_ORDER tag %q is used by at least one joker'):format(t))
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('Data: themes are reachable')
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- The catalog tabs and the discovery loop must agree on the theme set, or a
|
|
-- theme is either untrackable or unshowable.
|
|
for _, t in ipairs(sorted_keys(data.theme_info)) do
|
|
ok(JCA.theme_names[t], ('theme %q has a tab entry in THEME_TABS'):format(t))
|
|
end
|
|
for _, t in ipairs(sorted_keys(JCA.theme_names)) do
|
|
ok(data.theme_info[t], ('THEME_TABS tag %q has a theme_info sentence'):format(t))
|
|
end
|
|
|
|
-- Discovery needs a giver and a wanter on two DIFFERENT jokers (main.lua:480).
|
|
-- A theme that cannot satisfy that is an achievement no player can ever earn,
|
|
-- and it holds the Progress tab below 100% forever.
|
|
for _, t in ipairs(sorted_keys(data.theme_info)) do
|
|
local n = 0
|
|
for _ in pairs(members[t] or {}) do n = n + 1 end
|
|
ok(givers[t] and wanters[t] and n >= 2,
|
|
('theme %q is discoverable'):format(t),
|
|
('givers=%d wanters=%d distinct jokers=%d - needs >=1 giver, >=1 wanter, >=2 jokers')
|
|
:format(givers[t] or 0, wanters[t] or 0, n))
|
|
end
|
|
|
|
for _, t in ipairs(sorted_keys(data.theme_info)) do
|
|
ok(#data.theme_info[t] <= 90, ('theme %q blurb fits (<=90 chars)'):format(t),
|
|
('%d chars'):format(#data.theme_info[t]))
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('Data: pairs, clashes and cautions')
|
|
--------------------------------------------------------------------------------
|
|
|
|
local BLURB_MAX = 34 -- name + blurb must fit one tooltip row
|
|
|
|
for _, p in ipairs(data.pairs) do
|
|
local label = ('pair %s|%s'):format(p[1], p[2])
|
|
ok(JCA.db[p[1]], label .. ': first key is a known joker')
|
|
ok(JCA.db[p[2]], label .. ': second key is a known joker')
|
|
ok(p[1] ~= p[2], label .. ': pairs two different jokers')
|
|
ok(p[3] and #p[3] > 0, label .. ': has a blurb')
|
|
ok(not p[3] or #p[3] <= BLURB_MAX, label .. ': blurb fits one row',
|
|
p[3] and ('%d chars: %q'):format(#p[3], p[3]))
|
|
end
|
|
|
|
local seen = {}
|
|
for _, p in ipairs(data.pairs) do
|
|
local a, b = p[1], p[2]
|
|
local k = a < b and (a .. '|' .. b) or (b .. '|' .. a)
|
|
ok(not seen[k], ('pair %s is listed only once'):format(k))
|
|
seen[k] = true
|
|
end
|
|
|
|
for _, c in ipairs(data.clashes or {}) do
|
|
local label = ('clash %s|%s'):format(c[1], c[2])
|
|
ok(JCA.db[c[1]] and JCA.db[c[2]], label .. ': both keys are known jokers')
|
|
ok(#c[3] <= BLURB_MAX, label .. ': warning fits one row',
|
|
('%d chars: %q'):format(#c[3], c[3]))
|
|
-- A clash is a trap, not a combo. Listing a pair as both would be incoherent.
|
|
local k = c[1] < c[2] and (c[1] .. '|' .. c[2]) or (c[2] .. '|' .. c[1])
|
|
ok(not seen[k], label .. ': is not also listed as a famous pair')
|
|
end
|
|
|
|
for _, key in ipairs(sorted_keys(data.cautions or {})) do
|
|
ok(JCA.db[key], ('caution %s: is a known joker'):format(key))
|
|
ok(#data.cautions[key] <= BLURB_MAX, ('caution %s: fits one row'):format(key),
|
|
('%d chars: %q'):format(#data.cautions[key], data.cautions[key]))
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('Data: joker coverage')
|
|
--------------------------------------------------------------------------------
|
|
|
|
local n_jokers, inert = 0, {}
|
|
for _, key in ipairs(sorted_keys(JCA.db)) do
|
|
n_jokers = n_jokers + 1
|
|
local e = JCA.db[key]
|
|
if not next(e.gives) and not next(e.wants) then inert[#inert + 1] = key end
|
|
end
|
|
eq(n_jokers, 150, 'all 150 vanilla jokers are in the database')
|
|
|
|
-- Some jokers really have no combo (Mr. Bones, Credit Card...). That is a
|
|
-- legitimate answer, but it should be a deliberate list, not an accident:
|
|
-- anything new showing up here scores 0 against the entire game.
|
|
local EXPECTED_INERT = {
|
|
j_chaos = true, -- free reroll each shop; helps no specific build
|
|
j_chicot = true, -- disables the boss blind
|
|
j_credit_card = true, -- go negative on money
|
|
j_diet_cola = true, -- free Double Tag
|
|
j_luchador = true, -- sell to disable the boss blind
|
|
j_mr_bones = true, -- prevents death
|
|
j_ring_master = true, -- duplicates in the shop pool
|
|
}
|
|
for _, key in ipairs(inert) do
|
|
ok(EXPECTED_INERT[key], ('joker %s is inert on purpose'):format(key),
|
|
'it has no gives and no wants, so it scores 0 against every joker')
|
|
end
|
|
for _, key in ipairs(sorted_keys(EXPECTED_INERT)) do
|
|
ok(JCA.db[key] and not next(JCA.db[key].gives) and not next(JCA.db[key].wants),
|
|
('%s is still listed as intentionally inert'):format(key),
|
|
'it now has tags - drop it from EXPECTED_INERT')
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('Data: public API')
|
|
--------------------------------------------------------------------------------
|
|
|
|
JCA.register('j_mod_test', {'mult', 'bogus_tag'}, {'retrig_scoring'})
|
|
ok(JCA.db.j_mod_test, 'JCA.register adds a joker')
|
|
ok(JCA.db.j_mod_test.gives.mult, 'known tags survive registration')
|
|
ok(not JCA.db.j_mod_test.gives.bogus_tag, 'unknown tags are dropped, not fatal')
|
|
ok(JCA.score('j_mod_test', 'j_hack') >= 2, 'a registered joker scores against vanilla')
|
|
local before = JCA.score('j_mod_test', 'j_baron')
|
|
JCA.register_pair('j_mod_test', 'j_baron', 'test blurb')
|
|
eq(JCA.score('j_mod_test', 'j_baron') - before, 4, 'JCA.register_pair adds the +4 pair bonus')
|
|
JCA.db.j_mod_test = nil -- keep the UI section on vanilla data only
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('UI: catalog pages build and animate')
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Cards must materialize as they are emplaced, or a page switch pops them in
|
|
-- with no animation. Only the first card of a page may play the sound.
|
|
local function build(label, fn)
|
|
emplaced, materialized, sounds = {}, {}, 0
|
|
local built, err = pcall(fn)
|
|
if not ok(built, ('page %s builds'):format(label), tostring(err)) then return end
|
|
local missing = 0
|
|
for _, c in ipairs(emplaced) do
|
|
if not materialized[c] then missing = missing + 1 end
|
|
end
|
|
eq(missing, 0, ('page %s materializes every one of its %d cards'):format(label, #emplaced))
|
|
ok(sounds <= 1, ('page %s plays at most one materialize sound'):format(label),
|
|
('%d cards played it'):format(sounds))
|
|
end
|
|
|
|
G.jokers = nil -- the catalog is reachable from the main menu, outside a run
|
|
for _, tab in ipairs(SMODS.current_mod.extra_tabs()) do
|
|
build(tab.label, tab.tab_definition_function)
|
|
end
|
|
|
|
-- And the same on a page switch, which is what the pager callbacks do.
|
|
G.OVERLAY_MENU = {get_UIE_by_ID = function()
|
|
return {config = {object = {remove = function() end}},
|
|
UIBox = {recalculate = function() end}}
|
|
end}
|
|
for _, name in ipairs({'Combos', 'Engine', 'Economy', 'Hands'}) do
|
|
build(name .. ' page 2', function()
|
|
G.FUNCS['jca_page_' .. name]{cycle_config = {current_option = 2}}
|
|
end)
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
print(('\n%d passed, %d failed'):format(pass, fail))
|
|
os.exit(fail == 0 and 0 or 1)
|