Files
funman300 b4e4f8e23e
test / test (push) Successful in 14s
Read the perishable timer instead of assuming it, and fix a pcall idiom
Two fixes from a review pass, plus the first tests to reach the post-run
recap at all.

The perishable caution said "gains die in 5 rounds" as a literal, but the
count is a game variable (G.GAME.perishable_rounds, game.lua:1961) and
vanilla's own sticker tooltip reads it (common_events.lua:3339). A challenge
or another mod moving it made the advisor state a wrong number, in the one
place this mod promises mechanical facts. It now reads perish_tally when the
sticker is already ticking, falls back to the game value, then to 5, and
says "1 round" rather than "1 rounds". The new tests fail against the old
literal, so this one was real.

The win screen's recap fallback tested only the second pcall return:

    local _, inserted = pcall(insert_recap, ui, 'from_game_won', 2, 1)
    if not inserted then ... win_cta ... end

The first return is the ok flag, so on a throw `inserted` holds the error
MESSAGE -- a truthy string -- and a failure reads as a success. That is a
trap worth removing, but being honest about its blast radius: it is NOT
currently reachable. Every way the first call can throw (recap_row, or
find_trail on a malformed tree) throws identically for win_cta, and the
table.insert cannot go out of bounds because spot.index indexes the very
list being inserted into. The whole suite passes with the old idiom. This
is a latent correctness fix, not a live bug -- it stops the trap biting if
insert_recap ever grows an anchor-specific failure.

The recap machinery (recap_row, find_trail, insert_recap) had no coverage.
It does now, including the win_cta fallback and the empty-run case.

925 tests pass on 5.4 and LuaJIT, 18/18 in the real game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:22:47 -07:00

1114 lines
50 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
-- Cross-mod integration stubs, present BEFORE main.lua loads (both real mods
-- load before this one: JokerDisplay at priority -1e9, TMJ at -1000). One
-- JokerDisplay definition of each shape: with a reminder_text row and without.
JokerDisplay = {Definitions = {
j_baron = {text = {{text = 'x'}},
calc_function = function(card)
card.joker_display_values = card.joker_display_values or {}
card.joker_display_values.orig_ran = true
end},
j_mime = {text = {{text = 'x'}}, reminder_text = {{text = '(hand)'}}},
}}
TMJ = {SEARCH_FIELD_FUNCS = {}}
-- Fusion Jokers (priority -10000, so it too is loaded before us). Its recipe
-- table is an array with the reverse index hung off the same table. Three
-- recipes, shaped like the real ones: a plain two-component fusion, a second
-- recipe sharing a component (so one joker can feed two fusions), and a
-- repeated-component recipe -- their own debug fusion needs 3x Joker, so
-- counting by quantity rather than presence is a real requirement, not a
-- hypothetical.
FusionJokers = {fusions = {
{jokers = {{name = 'j_greedy_joker'}, {name = 'j_rough_gem'}},
result_joker = 'j_fuse_diamond_bard', cost = 12},
{jokers = {{name = 'j_greedy_joker'}, {name = 'j_blackboard'}},
result_joker = 'j_fuse_test_two', cost = 4},
{jokers = {{name = 'j_joker'}, {name = 'j_joker'}, {name = 'j_joker'}},
result_joker = 'j_fuse_three_jimbos', cost = 2},
}}
FusionJokers.fusions.ingredience = {}
for _, f in ipairs(FusionJokers.fusions) do
for _, c in ipairs(f.jokers) do
FusionJokers.fusions.ingredience[c.name] =
FusionJokers.fusions.ingredience[c.name] or {}
FusionJokers.fusions.ingredience[c.name][f.result_joker] = true
end
end
-- Post-run recap screens. main.lua wraps these at load, so they have to exist
-- before it is required. Each returns a FRESH tree shaped like vanilla's
-- (root > column > row > button), because the recap is inserted by walking up
-- from the button id. RECAP_ANCHOR picks which id the win screen exposes, so
-- the win_cta fallback can be exercised.
RECAP_ANCHOR = 'from_game_won'
local function button_tree(id)
return {n = 'ROOT', nodes = {
{n = 'C', nodes = {
{n = 'R', nodes = {
{n = 'C', config = {id = id}},
}},
}},
}}
end
create_UIBox_game_over = function() return button_tree('from_game_over') end
create_UIBox_win = function() return button_tree(RECAP_ANCHOR) 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 copier still in the SHOP is positional before it is even bought: a purchase
-- always lands in the rightmost slot (buy_from_shop emplaces at the end,
-- button_callbacks.lua:2279), so a bought Blueprint starts dead while a bought
-- Brainstorm reads the leftmost joker immediately.
field('j_baron', 'j_mime')
eq(JCA.copy_source_if_bought('j_blueprint'), nil,
'a bought Blueprint arrives rightmost, copying nothing')
eq(select(2, JCA.copy_source_if_bought('j_blueprint')), 'none',
'and reports the empty slot, not an error')
local arrival = field('j_baron', 'j_mime')
arrival[1].config.center.blueprint_compat = true
local bought, bought_status = JCA.copy_source_if_bought('j_brainstorm')
eq(bought, arrival[1], 'a bought Brainstorm copies the leftmost joker on arrival')
eq(bought_status, 'ok', 'and reports it as live')
-- The leftmost joker being a copier chains exactly like an owned Brainstorm:
-- the answer is the joker at the end, and the walked copier is recorded.
local shop_chain = field('j_blueprint', 'j_baron')
for _, c in ipairs(shop_chain) do c.config.center.blueprint_compat = true end
local final_b, st_b, via_b = JCA.copy_source_if_bought('j_brainstorm')
eq(final_b, shop_chain[2], 'a leftmost Blueprint chains to the joker it copies')
eq(st_b, 'ok', 'and reports it as live')
eq(#via_b, 1, 'and records the copier walked through')
eq(via_b[1], shop_chain[1], 'which is the leftmost Blueprint')
field('j_baron', 'j_mime')
G.jokers.cards = {}
eq(JCA.copy_source_if_bought('j_brainstorm'), nil,
'an empty board gives a bought Brainstorm nothing to copy')
eq(JCA.copy_source_if_bought('j_baron'), nil, 'a non-copier has no arrival answer')
-- 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: board synergy total and its run peak')
--------------------------------------------------------------------------------
-- Every owned pair scored once: Baron+Mime is the famous 6, the plain Joker
-- adds only its generic +1 against Baron's xMult.
field('j_baron', 'j_mime', 'j_joker')
eq(JCA.board_synergy(), 7, 'board synergy sums every owned pair once')
-- The peak rides the same emplace hook as discovery, and never goes down.
-- Discovery state is snapshotted so this cannot leak into the UI tests.
local saved_disc, saved_themes = JCA.config.discovered, JCA.config.themes_found
JCA.config.discovered, JCA.config.themes_found = {}, {}
G.GAME = {}
JCA.check_discoveries()
eq(G.GAME.jca_peak_synergy, 7, 'the emplace hook records the high-water mark')
field('j_joker')
JCA.check_discoveries()
eq(G.GAME.jca_peak_synergy, 7, 'selling down does not lower the recorded peak')
G.GAME = {}
JCA.config.discovered, JCA.config.themes_found = saved_disc, saved_themes
--------------------------------------------------------------------------------
section('Engine: the post-run recap finds its anchor on both screens')
--------------------------------------------------------------------------------
-- The recap is inserted relative to a vanilla button id by walking UP the
-- parent trail, and the two win screens anchor at different ids -- so the
-- fallback is the whole point of the hook, not decoration.
local function tree_texts(node, out)
out = out or {}
if type(node) ~= 'table' then return out end
if node.config and node.config.text then out[#out + 1] = node.config.text end
for _, child in ipairs(node.nodes or {}) do tree_texts(child, out) end
return out
end
local function has_recap(ui)
for _, t in ipairs(tree_texts(ui)) do
if t == 'Combos fielded this run' then return true end
end
return false
end
G.GAME = {jca_run_combos = {['j_baron|j_mime'] = true}}
ok(has_recap(create_UIBox_game_over()), 'the game over screen gets the recap')
ok(has_recap(create_UIBox_win()), 'so does the win screen, at its own anchor')
-- The CTA variant of the win screen replaces 'from_game_won' with 'win_cta';
-- the first insert finds nothing and the fallback has to carry it.
RECAP_ANCHOR = 'win_cta'
ok(has_recap(create_UIBox_win()), 'and the win_cta variant falls back correctly')
RECAP_ANCHOR = 'from_game_won'
-- Nothing fielded and no peak: no row at all, rather than an empty heading.
G.GAME = {}
ok(not has_recap(create_UIBox_win()), 'a run with no combos gets no recap row')
G.GAME = {}
--------------------------------------------------------------------------------
section('Engine: deck context counts base suits')
--------------------------------------------------------------------------------
-- Counts card.base.suit, the field vanilla scores by (card.lua:145). The
-- score never reads these numbers; they only frame the tooltip.
G.playing_cards = {
{base = {suit = 'Hearts'}}, {base = {suit = 'Hearts'}},
{base = {suit = 'Spades'}}, {base = {suit = 'Clubs'}},
}
local n, deck_total = JCA.deck_suit_count('Hearts')
eq(n, 2, 'counts the cards of the asked suit')
eq(deck_total, 4, 'and the whole deck for the ratio')
eq(JCA.deck_suit_count('Diamonds'), 0, 'a missing suit counts as zero')
G.playing_cards = nil
n, deck_total = JCA.deck_suit_count('Hearts')
eq(deck_total, 0, 'no deck (menus, tests) reads as an empty deck')
--------------------------------------------------------------------------------
section('Engine: boss-blind cautions know who the boss silences')
--------------------------------------------------------------------------------
-- The class-killer bosses, verbatim from game.lua's P_BLINDS configs and the
-- blind.lua effects. Every tag they reference must exist in the database,
-- or the caution can never fire.
for boss, kill in pairs(JCA.boss_kills) do
local n = 0
for tag in pairs(kill.tags) do
n = n + 1
ok(JCA.tag_label[tag], ('boss %s kills a real catalog tag (%s)'):format(boss, tag))
end
ok(n > 0, ('boss %s kills at least one tag'):format(boss))
ok(type(kill.what) == 'string' and #kill.what > 0,
('boss %s carries its tooltip phrase'):format(boss))
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.faces and plant.tags.faces,
'The Plant caution catches face payoffs')
ok(JCA.db.j_pareidolia.gives.faces, 'and face enablers, whose conversion kills the deck')
-- The Water removes every discard for the fight (blind.lua:208): both the
-- granters (Drunkard) and the payoffs (Hit the Road) must be caught.
local water = JCA.boss_kills.bl_water
ok(water.tags.discards_up and JCA.db.j_drunkard.gives.discards_up,
'The Water caution catches discard granters')
ok(JCA.db.j_hit_the_road.wants.discards_up, 'and discard payoffs')
-- The Eye blocks repeat hand types (blind.lua:577): every hand-type cluster
-- tag must be covered, so a one-hand build always hears about it.
local eye = JCA.boss_kills.bl_eye
for _, t in ipairs({'pair', 'two_pair', 'three_kind', 'four_kind', 'straight', 'flush'}) do
ok(eye.tags[t], ('The Eye covers the %s cluster'):format(t))
end
ok(not eye.tags.hearts, 'but The Eye does not touch suit tags (suits repeat fine)')
-- 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('Engine: consumable advice names the fed jokers')
--------------------------------------------------------------------------------
local function consumable(set, kind)
return {config = {center = {set = set, kind = kind, key = 'c_test'}}}
end
field('j_constellation', 'j_fortune_teller', 'j_mod_unknown')
local fed, tag = JCA.consumable_partners(consumable('Planet'))
eq(tag, 'planet', 'a Planet card maps to the planet tag')
eq(#fed, 1, 'exactly one owned joker is fed by a Planet card')
eq(fed[1].key, 'j_constellation', 'and it is Constellation')
fed, tag = JCA.consumable_partners(consumable('Booster', 'Celestial'))
eq(tag, 'planet', 'a Celestial Pack counts as Planet supply')
eq(fed[1].key, 'j_constellation', 'the pack feeds Constellation too')
fed = JCA.consumable_partners(consumable('Tarot'))
eq(fed[1].key, 'j_fortune_teller', 'a Tarot card feeds Fortune Teller')
-- Advice material with nobody home: tag resolves, list stays empty.
fed, tag = JCA.consumable_partners(consumable('Spectral'))
eq(tag, 'spectral_gen', 'a Spectral card still resolves its tag')
eq(#fed, 0, 'but feeds nobody on this board')
-- A Standard Pack is card supply; a Buffoon Pack is not advice material.
fed, tag = JCA.consumable_partners(consumable('Booster', 'Standard'))
eq(tag, 'card_gen', 'a Standard Pack maps to card_gen')
eq(JCA.consumable_partners(consumable('Booster', 'Buffoon')), nil,
'a Buffoon Pack yields no consumable advice')
eq(JCA.consumable_partners({config = {center = {set = 'Voucher'}}}), nil,
'a Voucher yields no consumable advice')
--------------------------------------------------------------------------------
section('Engine: sticker cautions are per-instance, not per-joker')
--------------------------------------------------------------------------------
-- Every scaler is a real joker; a typo here would silently disarm the caution.
for k in pairs(JCA.scalers) do
ok(JCA.db[k] ~= nil, 'scaler ' .. k .. ' exists in the database')
end
local function stickered(key, ability)
return {config = {center = {key = key}}, ability = ability}
end
eq(#JCA.sticker_cautions(stickered('j_ride_the_bus', {perishable = true})), 1,
'a perishable scaler earns a caution')
eq(#JCA.sticker_cautions(stickered('j_golden', {perishable = true})), 0,
'a perishable static joker loses nothing stored - no caution')
eq(#JCA.sticker_cautions(stickered('j_golden', {rental = true})), 1,
'a rental money joker earns a caution (rent can eat the payout)')
eq(#JCA.sticker_cautions(stickered('j_baron', {rental = true})), 0,
'a rental non-money joker is just a cheap joker - no caution')
eq(#JCA.sticker_cautions(stickered('j_ride_the_bus', {})), 0,
'no sticker, no caution')
eq(#JCA.sticker_cautions(stickered('j_mod_unknown', {perishable = true, rental = true})), 0,
'unknown keys never warn and never error')
-- The round count is a GAME VARIABLE (G.GAME.perishable_rounds, game.lua:1961),
-- not the constant 5 -- a challenge or another mod can move it, and vanilla's
-- own sticker tooltip reads it. Hardcoding it would state a wrong number.
G.GAME = {perishable_rounds = 3}
eq(JCA.sticker_cautions(stickered('j_ride_the_bus', {perishable = true}))[1],
'perishable: gains die in 3 rounds', 'the caution reads the game round count')
eq(JCA.sticker_cautions(stickered('j_ride_the_bus',
{perishable = true, perish_tally = 1}))[1],
'perishable: gains die in 1 round',
'a ticking sticker reports what is LEFT, and says "round" once')
G.GAME = {}
eq(JCA.sticker_cautions(stickered('j_ride_the_bus', {perishable = true}))[1],
'perishable: gains die in 5 rounds', 'falling back to vanilla 5 outside a run')
--------------------------------------------------------------------------------
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')
-- register_clash must feed the Traps tab too: the list gains the entry once,
-- and a re-registration updates the blurb without duplicating it.
local clashes_before = #JCA.clash_list
JCA.register_clash('j_mod_test', 'j_baron', 'test warning')
eq(#JCA.clash_list, clashes_before + 1, 'JCA.register_clash lands in the traps list')
JCA.register_clash('j_mod_test', 'j_baron', 'updated warning')
eq(#JCA.clash_list, clashes_before + 1, 're-registering a clash does not duplicate it')
ok(JCA._traps_dirty, 'and marks the traps tab for a re-sort')
JCA.db.j_mod_test = nil -- keep the UI section on vanilla data only
--------------------------------------------------------------------------------
section('Integrations: JokerDisplay counter and TMJ search')
--------------------------------------------------------------------------------
-- JokerDisplay: every known joker with a definition gains the counter, in
-- both definition shapes.
eq(JCA.integrations.jokerdisplay, 2, 'both stub JokerDisplay definitions were patched')
ok(JokerDisplay.Definitions.j_baron.reminder_text,
'a definition without reminder_text gains one for the counter')
eq(#JokerDisplay.Definitions.j_mime.reminder_text, 3,
'an existing reminder_text grows by separator + counter')
-- The wrapped calc_function fills the live count and keeps the original.
field('j_baron', 'j_mime')
local jd_card = G.jokers.cards[1]
jd_card.joker_display_values = {}
JokerDisplay.Definitions.j_baron.calc_function(jd_card)
eq(jd_card.joker_display_values.jca_combos, '(1 combo)',
'the counter shows the live board partner count')
ok(jd_card.joker_display_values.orig_ran,
'and the original calc_function still runs first')
JCA.config.jd_combos = false
JokerDisplay.Definitions.j_baron.calc_function(jd_card)
eq(jd_card.joker_display_values.jca_combos, '',
'the config toggle blanks the counter mid-run')
JCA.config.jd_combos = true
-- Too Many Jokers: the search hook indexes jokers by tag labels and stays
-- silent on centers the database does not know.
eq(JCA.integrations.toomanyjokers, 1, 'the TMJ search hook registered')
local tmj_fields = TMJ.SEARCH_FIELD_FUNCS[1]({key = 'j_baron'})
local tmj_hit = false
for _, f in ipairs(tmj_fields) do
if f == 'held-card retriggers' then tmj_hit = true end
end
ok(tmj_hit, 'TMJ search finds Baron under its tag labels')
eq(TMJ.SEARCH_FIELD_FUNCS[1]({key = 'j_totally_modded'}), nil,
'an unknown center returns nil, not an error')
--------------------------------------------------------------------------------
section('Integrations: Fusion Jokers recipes, cost and what fusing costs you')
--------------------------------------------------------------------------------
eq(JCA.integrations.fusionjokers, 3, 'every stub recipe was indexed')
-- A shop card is priced as if already bought: the question in the shop is
-- "what does buying this unlock", not "what do I have".
local function shop_card(key)
return {config = {center = {key = key, set = 'Joker'}}}
end
local function plan_for(card, result)
for _, p in ipairs(JCA.fusion_plans(card)) do
if p.result == result then return p end
end
end
G.GAME.dollars = 20
-- A joker in no recipe stays silent, and so does an unknown modded one.
field('j_baron', 'j_mime')
eq(#JCA.fusion_plans(G.jokers.cards[1]), 0, 'a joker in no recipe has no plans')
eq(#JCA.fusion_plans(shop_card('j_totally_modded')), 0,
'an unknown modded joker has no plans, not an error')
-- Owned, with the partner component missing.
field('j_greedy_joker', 'j_baron')
local p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard')
ok(p and not p.ready, 'a half-assembled fusion is not ready')
eq(p and #p.missing, 1, 'and names exactly the component still missing')
eq(p and p.missing[1].key, 'j_rough_gem', 'which is Rough Gem')
eq(p and p.missing[1].n, 1, 'one copy short')
eq(p and #p.loses, 0, 'an unready fusion claims no losses')
-- Both components owned: ready, priced, affordable at $20.
field('j_greedy_joker', 'j_rough_gem')
p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard')
ok(p and p.ready, 'both components fielded makes the fusion ready')
eq(p and p.cost, 12, 'at the recipe cost')
eq(p and p.affordable, true, '$20 covers a $12 fusion')
G.GAME.dollars = 5
p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard')
eq(p and p.affordable, false, 'but $5 does not')
G.GAME.dollars = 20
-- The whole point of the integration: fusing EATS the components, so the
-- combos they hold with the rest of the board die with them. Smeared feeds
-- both Diamond jokers their suit_flex, and survives the fusion.
field('j_greedy_joker', 'j_rough_gem', 'j_smeared')
p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard')
eq(p and #p.loses, 1, 'fusing reports the combo it would cost you')
eq(p and p.loses[1], 'j_smeared', 'naming the surviving partner left stranded')
-- A component of the fusion is not "lost" -- it is being spent, not stranded.
field('j_greedy_joker', 'j_rough_gem')
p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard')
eq(p and #p.loses, 0, 'the other component is spent, not counted as a loss')
-- In the shop, the card counts as bought.
field('j_rough_gem')
p = plan_for(shop_card('j_greedy_joker'), 'j_fuse_diamond_bard')
ok(p and p.ready, 'a shop card completes a recipe as if already bought')
eq(#JCA.fusion_plans(G.jokers.cards[1]), 1,
'while the owned half alone still reads as unready')
ok(not JCA.fusion_plans(G.jokers.cards[1])[1].ready, '(and says so)')
-- Repeated components are counted by QUANTITY, not presence: their own debug
-- recipe needs three Jokers, and owning one is not owning three.
field('j_joker')
p = plan_for(G.jokers.cards[1], 'j_fuse_three_jimbos')
ok(p and not p.ready, 'one Joker does not satisfy a 3x Joker recipe')
eq(p and p.missing[1].n, 2, 'and the shortfall is the missing two')
field('j_joker', 'j_joker', 'j_joker')
p = plan_for(G.jokers.cards[1], 'j_fuse_three_jimbos')
ok(p and p.ready, 'three Jokers do satisfy it')
-- Discounts, mirroring get_card_fusion: flat then percentage, floored, min $1.
field('j_greedy_joker', 'j_rough_gem')
G.GAME.fujo_fusion_discount = {j_fuse_diamond_bard = 5}
eq(plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard').cost, 7,
'a per-fusion flat discount comes off the price')
G.GAME.fujo_fusion_discount.universal = 2
eq(plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard').cost, 5,
'and stacks with the universal one')
G.GAME.fujo_fusion_discountpercent = {universal = 0.5}
eq(plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard').cost, 2,
'percentages apply after, and round down')
G.GAME.fujo_fusion_discount.universal = 100
eq(plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard').cost, 1,
'a fusion never costs less than $1')
G.GAME.fujo_fusion_discount, G.GAME.fujo_fusion_discountpercent = nil, nil
-- Ready fusions sort ahead of unready ones, so the two lines the tooltip has
-- room for are the two the player can act on.
field('j_greedy_joker', 'j_blackboard')
local plans = JCA.fusion_plans(G.jokers.cards[1])
eq(#plans, 2, 'a joker feeding two recipes gets a plan for each')
eq(plans[1].result, 'j_fuse_test_two', 'the ready fusion sorts first')
ok(not plans[2].ready, 'the unready one follows')
-- A third-party recipe can gate itself; a predicate that says no, or throws,
-- must never produce a false "ready".
FusionJokers.fusions[2].requirement = function() return false end
ok(not plan_for(G.jokers.cards[1], 'j_fuse_test_two').ready,
'a requirement returning false blocks readiness')
FusionJokers.fusions[2].requirement = function() error('someone elses bug') end
ok(not plan_for(G.jokers.cards[1], 'j_fuse_test_two').ready,
'and a requirement that throws is treated as unmet, not a crash')
FusionJokers.fusions[2].requirement = nil
-- No Fusion Jokers installed: silence, exactly like an unknown joker.
do
local real = FusionJokers
FusionJokers = nil
eq(dofile('integrations.lua').fusionjokers(), 0,
'a missing Fusion Jokers install patches nothing and does not error')
FusionJokers = real
end
G.GAME.dollars = nil
--------------------------------------------------------------------------------
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', 'Traps'}) do
build(name .. ' page 2', function()
G.FUNCS['jca_page_' .. name]{cycle_config = {current_option = 2}}
end)
end
-- The Traps pager spans two page kinds; also build the first CAUTION page
-- (the option right after the last clash page) and the very last page.
local first_caution = math.ceil(#JCA.clash_list / 6) + 1
build('Traps first caution page', function()
G.FUNCS.jca_page_Traps{cycle_config = {current_option = first_caution}}
end)
build('Traps last page', function()
G.FUNCS.jca_page_Traps{cycle_config = {current_option = 999}}
end)
--------------------------------------------------------------------------------
print(('\n%d passed, %d failed'):format(pass, fail))
os.exit(fail == 0 and 0 or 1)