Files
JokerComboAdvisor/test.lua
T
funman300 b81a6c6dcd Add the shopping list: name the gaps the board wants filled
JCA.build_gaps() splits the board's tags into unmet wants (some joker
wants it, no OTHER joker gives it) and untapped hooks (given, nothing
pays it off). Rendered at the top of the in-run Combos overlay via
TAG_LABEL; engine roles stay excluded because they hint nothing.
Board slots are compared by index, not db-entry identity, so two
copies of one cluster joker correctly feed each other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:41:32 -07:00

588 lines
26 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_low, which The Idol wants (the Idol card can roll any
-- rank); 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')
-- Rank-limited retriggers must not claim payoffs their ranks cannot reach:
-- Hack replays only 2s-5s, so it never feeds a face or Ace payoff; Sock and
-- Buskin replays only faces, so Wee's 2s mean nothing to it.
eq(JCA.score('j_hack', 'j_business'), 0, 'Hack cannot retrigger Business Card faces')
eq(JCA.score('j_hack', 'j_scholar'), 0, 'Hack cannot retrigger Scholar Aces')
eq(JCA.score('j_sock_and_buskin', 'j_wee'), 0, 'Sock and Buskin cannot retrigger 2s')
-- 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: tooltip phrasing')
--------------------------------------------------------------------------------
-- A cluster tag needs all three phrasings. "both reward Straights" is only true
-- when BOTH jokers reward them; a pure enabler (Four Fingers, DNA) gives the tag
-- without wanting it and must not be described as rewarding the hand.
for _, t in ipairs(sorted_keys(data.tag_text)) do
local text = data.tag_text[t]
if text.both then
ok(text.give and text.want,
('cluster tag %q has give/want phrasings, not just "both"'):format(t),
'an enabler paired with a payoff would otherwise be told it "rewards" the hand')
end
end
-- Four Fingers ENABLES straights (it does not reward them); Runner rewards them.
eq(JCA.explain('j_four_fingers', 'j_runner'), 'rewards Straights',
'a payoff partner is described as rewarding the hand')
eq(JCA.explain('j_runner', 'j_four_fingers'), 'makes Straights easier',
'an enabler partner is described as enabling, not rewarding')
eq(JCA.explain('j_dna', 'j_family'), 'rewards Four of a Kind',
'The Family rewards Four of a Kind')
eq(JCA.explain('j_family', 'j_dna'), 'makes Four of a Kind easier',
'DNA enables Four of a Kind')
-- Two full cluster members still get the "both" phrasing.
eq(JCA.explain('j_jolly', 'j_sly'), 'both reward Pairs',
'two payoffs still read as "both"')
for _, t in ipairs(sorted_keys(data.theme_info)) do
ok(JCA.tag_label[t], ('theme %q has a TAG_LABEL for learning-mode hints'):format(t))
end
--------------------------------------------------------------------------------
section('Engine: copy jokers are positional')
--------------------------------------------------------------------------------
-- Blueprint copies the joker to its RIGHT, Brainstorm the LEFTMOST. The board
-- order decides what they actually copy, and a copier in the wrong slot is dead.
local bp_board = field('j_baron', 'j_blueprint', 'j_mime')
eq(JCA.copy_source(bp_board[2]), bp_board[3], 'Blueprint copies the joker to its right')
field('j_baron', 'j_mime', 'j_blueprint')
eq(JCA.copy_source(G.jokers.cards[3]), nil, 'a rightmost Blueprint copies nothing')
local bs_board = field('j_baron', 'j_brainstorm', 'j_mime')
eq(JCA.copy_source(bs_board[2]), bs_board[1], 'Brainstorm copies the leftmost joker')
field('j_brainstorm', 'j_baron')
eq(JCA.copy_source(G.jokers.cards[1]), nil, 'a leftmost Brainstorm copies nothing')
field('j_baron', 'j_mime')
eq(JCA.copy_source(G.jokers.cards[1]), nil, 'a non-copier has no copy source')
-- vanilla's own "cannot be copied" flag: 29 jokers carry blueprint_compat = false
-- and yield nothing at all when copied.
local uncopyable = field('j_blueprint', 'j_splash')
uncopyable[2].config.center.blueprint_compat = false
local target, status = JCA.copy_source(uncopyable[1])
eq(target, uncopyable[2], 'it still reports what sits in the copied slot')
eq(status, 'incompatible', 'and flags that vanilla will not copy it (blueprint_compat)')
-- Vanilla tests the flag for TRUTH, so a modded joker that omits it is NOT
-- copyable. `~= false` would wrongly call this one fine.
local modded = field('j_blueprint', 'j_baron')
modded[2].config.center.blueprint_compat = nil
eq(select(2, JCA.copy_source(modded[1])), 'incompatible',
'a joker with no blueprint_compat flag at all is treated as uncopyable')
local fine = field('j_blueprint', 'j_baron')
fine[2].config.center.blueprint_compat = true
eq(select(2, JCA.copy_source(fine[1])), 'ok', 'a copyable target reports as ok')
-- A debuffed joker is copied as nothing (SMODS.blueprint_effect bails on it).
local debuffed = field('j_blueprint', 'j_baron')
debuffed[2].config.center.blueprint_compat = true
debuffed[2].debuff = true
eq(select(2, JCA.copy_source(debuffed[1])), 'debuffed',
'copying a debuffed joker yields nothing')
-- Copiers CHAIN: Blueprint -> Blueprint -> Baron really does give two Barons, so
-- the answer must be Baron, not "a Blueprint".
local chained = field('j_blueprint', 'j_blueprint', 'j_baron')
for _, c in ipairs(chained) do c.config.center.blueprint_compat = true end
local final, st, via = JCA.copy_source(chained[1])
eq(final, chained[3], 'the chain resolves to the joker actually copied')
eq(st, 'ok', 'and reports it as live')
eq(#via, 1, 'and records the copier walked through on the way')
eq(via[1], chained[2], 'which is the middle Blueprint')
-- A copier ring copies nothing, and must not hang.
local ring = field('j_brainstorm', 'j_blueprint')
for _, c in ipairs(ring) do c.config.center.blueprint_compat = true end
eq(JCA.copy_source(ring[2]), nil, 'a Blueprint/Brainstorm ring resolves to nothing')
--------------------------------------------------------------------------------
section('Engine: the sell advisor respects what you can actually sell')
--------------------------------------------------------------------------------
-- Eternal jokers CANNOT be sold (Card:can_sell_card, card.lua:1993). Telling the
-- player to sell one is advice they are not allowed to take.
--
-- 8 Ball scores 0 here and Scary Face 1, so 8 Ball is the natural cut -- until it
-- turns out to be Eternal, and the advice has to move to the next one down.
local et = field('j_8_ball', 'j_scary_face', 'j_baron', 'j_mime')
G.jokers.config.card_limit = 4
eq(JCA.weakest_link(), et[1], 'normally it flags the lowest-scoring joker')
et[1].ability = {eternal = true}
local pick = JCA.weakest_link()
ok(pick ~= et[1], 'but never an Eternal one, because it cannot be sold',
pick and pick.config.center.key or 'nil')
eq(pick, et[2], 'it falls through to the next sellable candidate instead')
--------------------------------------------------------------------------------
section('Engine: Ceremonial Dagger names its victim')
--------------------------------------------------------------------------------
-- It destroys the joker to its RIGHT at the next blind, permanently.
local dagger = field('j_baron', 'j_ceremonial', 'j_mime')
local victim, safe = JCA.dagger_victim(dagger[2])
eq(victim, dagger[3], 'it names the joker on its right as the victim')
eq(safe, false, 'and an ordinary joker is not safe from it')
field('j_baron', 'j_mime', 'j_ceremonial')
eq(JCA.dagger_victim(G.jokers.cards[3]), nil, 'a rightmost Dagger eats nothing')
field('j_ceremonial', 'j_baron')
eq(JCA.dagger_victim(G.jokers.cards[2]), nil, 'a non-Dagger has no victim')
-- Vanilla refuses to slice Eternal jokers, so the advisor must not cry wolf.
local eternal = field('j_ceremonial', 'j_baron')
eternal[2].ability = {eternal = true}
local _, is_safe = JCA.dagger_victim(eternal[1])
eq(is_safe, true, 'an Eternal neighbour is reported as safe')
--------------------------------------------------------------------------------
section('Engine: the sell advisor weighs clashes')
--------------------------------------------------------------------------------
-- Clashes must never touch a synergy score...
local pre = JCA.score('j_vampire', 'j_ticket')
ok(pre >= 0, 'a clashing pair still scores by its tags alone')
ok(JCA.clash_blurb[
('j_ticket' < 'j_vampire') and 'j_ticket|j_vampire' or 'j_vampire|j_ticket'],
'Vampire + Golden Ticket is a known clash')
-- ...but on a full board, the joker stuck in a clash is the one to cut, even
-- when a joker with no synergy at all is sitting next to it.
local board = field('j_vampire', 'j_ticket', 'j_baron', 'j_mime')
G.jokers.config.card_limit = 4
local cut = JCA.weakest_link()
ok(cut == board[1] or cut == board[2],
'the sell advisor flags one of the two clashing jokers',
cut and cut.config.center.key or 'nil')
--------------------------------------------------------------------------------
section('Engine: build gaps name what the board is shopping for')
--------------------------------------------------------------------------------
-- Baron alone: wants held-card retriggers and hand size, and nothing on the
-- board provides either; its copy_target give has no copier to pay it off.
field('j_baron')
local looking, untapped = JCA.build_gaps()
eq(table.concat(looking, ','), 'retrig_held,hand_size',
'a lone Baron is shopping for its enablers, in TAG_ORDER')
eq(table.concat(untapped, ','), 'copy_target',
'its copy_target hook is untapped until a copier arrives')
-- Mime fills the retrig_held gap (and only that gap).
field('j_baron', 'j_mime')
looking = JCA.build_gaps()
eq(table.concat(looking, ','), 'hand_size', 'Mime closes the retrig_held gap')
-- Two copies of the same cluster joker feed each other: the entries share one
-- db table, so the check must compare board slots, not table identity.
field('j_jolly', 'j_jolly')
looking = JCA.build_gaps()
eq(table.concat(looking, ','), '', 'two Jolly Jokers are not shopping for Pairs')
-- One alone is: a cluster member cannot feed itself.
field('j_jolly')
looking = JCA.build_gaps()
eq(table.concat(looking, ','), 'pair', 'a lone cluster member still wants its cluster')
--------------------------------------------------------------------------------
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_dusk') >= 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)