Files
JokerComboAdvisor/test.lua
T
funman300 445171dc0b Warn when the upcoming boss will silence the joker on sale
Five bosses debuff a whole card class for the fight (game.lua P_BLINDS,
applied in Blind:debuff_card blind.lua:708): The Club/Goad/Head/Window
kill a suit, The Plant kills faces. A debuffed card scores nothing and
triggers nothing, so a shop joker wanting that class is a dead ante --
and one GIVING it is worse: Pareidolia under The Plant converts the
whole deck into debuffed faces, so givers warn too. JCA.upcoming_boss
reads G.GAME.round_resets and goes quiet once the boss is defeated.
Warning-only, never a score change, kept in learning mode.

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

674 lines
30 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')
-- A dead copier's fix is a reorder, so the advisor names the best target on
-- the board: copyable (blueprint_compat for TRUTH, not debuffed, not another
-- copier), preferring jokers the db marks as prime copy targets.
field('j_hiker', 'j_baron', 'j_blueprint')
for _, c in ipairs(G.jokers.cards) do c.config.center.blueprint_compat = true end
local best, prime = JCA.best_copy_target(G.jokers.cards[3])
eq(best, G.jokers.cards[2], 'the prime copy target (Baron) beats a plain joker')
eq(prime, true, 'and is reported as prime')
-- With no prime target, the leftmost copyable joker wins; copiers and
-- uncopyable jokers never get suggested.
field('j_blueprint', 'j_hiker', 'j_splash')
G.jokers.cards[2].config.center.blueprint_compat = true
best, prime = JCA.best_copy_target(G.jokers.cards[1])
eq(best, G.jokers.cards[2], 'falls back to the leftmost copyable joker')
eq(prime, false, 'which is not prime')
-- A debuffed joker is never the suggestion: it copies as nothing.
field('j_blueprint', 'j_baron')
G.jokers.cards[2].config.center.blueprint_compat = true
G.jokers.cards[2].debuff = true
eq(JCA.best_copy_target(G.jokers.cards[1]), nil,
'a debuffed joker is not suggested as a copy target')
--------------------------------------------------------------------------------
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: the sell advisor weighs rentals and perished jokers')
--------------------------------------------------------------------------------
-- ability.rental drains $3 a round (card.lua:2646, game.lua:1957); a perishable
-- joker whose tally ran out is debuffed FOREVER (card.lua:716). Both are flags
-- on card.ability, read the same way as eternal.
local plain = field('j_baron', 'j_joker')
eq(JCA.is_rental(plain[1]), false, 'no sticker reads as no rental')
plain[1].ability = {rental = true}
eq(JCA.is_rental(plain[1]), true, 'ability.rental reads as a rental')
plain[2].ability = {perishable = true, perish_tally = 3}
eq(JCA.is_perished(plain[2]), false, 'a ticking perishable has not perished yet')
plain[2].ability.perish_tally = 0
eq(JCA.is_perished(plain[2]), true, 'tally 0 means debuffed for good')
-- Three mutually inert jokers tie at 0, so nothing stands out to cut --
-- until one of them is a rental, which bleeds money every round.
local rent = field('j_joker', 'j_half', 'j_misprint')
G.jokers.config.card_limit = 3
eq(JCA.weakest_link(), nil, 'an all-tied board flags nobody')
rent[2].ability = {rental = true}
eq(JCA.weakest_link(), rent[2], 'the rental loses the tie: it drains $3 a round')
-- A perished joker is dead weight at ANY synergy score: it outweighs even a
-- famous-pair member against the board's zero-scorer.
local per = field('j_baron', 'j_mime', 'j_joker')
G.jokers.config.card_limit = 3
eq(JCA.weakest_link(), per[3], 'normally the zero-scorer is the cut')
per[2].ability = {perishable = true, perish_tally = 0}
eq(JCA.weakest_link(), per[2],
'but a perished joker is the cut at any score - it will never work again')
--------------------------------------------------------------------------------
section('Engine: boss-blind cautions know who the boss silences')
--------------------------------------------------------------------------------
-- The five class-killer bosses, verbatim from game.lua's P_BLINDS debuff
-- configs. Every tag they reference must exist in the database, or the
-- caution can never fire.
for boss, kill in pairs(JCA.boss_kills) do
ok(JCA.tag_label[kill.tag], ('boss %s kills a real catalog tag (%s)'):format(boss, kill.tag))
end
-- The Plant debuffs faces: it must catch both the payoff (Business Card
-- wants faces) and the enabler (Pareidolia turns every card into one).
local plant = JCA.boss_kills.bl_plant
ok(JCA.db.j_business.wants[plant.tag], 'The Plant caution catches face payoffs')
ok(JCA.db.j_pareidolia.gives[plant.tag], 'and face enablers, whose conversion kills the deck')
-- Upcoming-boss reading: warn while the boss is ahead, stay quiet once it
-- is dealt with.
G.GAME.round_resets = {
blind_choices = {Small = 'bl_small', Big = 'bl_big', Boss = 'bl_plant'},
blind_states = {Small = 'Defeated', Big = 'Current', Boss = 'Upcoming'},
}
eq(JCA.upcoming_boss(), 'bl_plant', 'the upcoming boss is read from round_resets')
G.GAME.round_resets.blind_states.Boss = 'Defeated'
eq(JCA.upcoming_boss(), nil, 'a defeated boss warns nobody')
G.GAME.round_resets = nil
eq(JCA.upcoming_boss(), nil, 'no round state (menus, tests) reads as no boss')
--------------------------------------------------------------------------------
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)