319f79bc5d
Hovering a joker that is a fusion component now names the fusion it feeds,
its cost with discounts applied, and what is still missing. In the shop the
card is counted as if already bought, so it answers the buy-side question
("Buy to fuse into Diamond Bard ($12 to fuse)") instead of restating what
you already own.
The line worth having is the last one. Fusing CONSUMES its components --
fuse_card calls ingredient:remove() -- so every combo those jokers held with
the rest of the board dies with them, and the FUSE button cannot tell you
that. "Fusing drops combos with: Smeared" is computed from the surviving
board, and a component being spent is not counted as a loss.
Three things about their code shaped this:
* Card:get_card_fusion() would have answered most of it, but it drives its
price flicker with math.randomseed(love.timer.getTime() * 8). An advisor
reseeding Lua's RNG on every hover has no business doing that, so this
reads FusionJokers.fusions directly and mirrors their discount arithmetic
(flat then percentage, per-result and universal, floored, min $1).
* Recipes take repeated components -- their own debug fusion needs 3x Joker
-- so components are counted by quantity, not presence.
* Affordability is judged only on plain numbers: Fusion Jokers uses to_big,
and Talisman turns G.GAME.dollars into an object. Guessing wrong about
someone's money is worse than staying quiet, so the money line just
does not appear.
Ownership is board membership, not card.area, matching copy_source and
dagger_victim -- the area pointer answers differently for the same card
depending on who built it.
Verified against the shipped recipe table: all 15 resolve at the right cost,
and all 30 components are jokers the database already knows. Not installed
means silence, like every other integration.
918 tests pass on 5.4 and LuaJIT (31 new), and 18/18 in the real game.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1046 lines
47 KiB
Lua
1046 lines
47 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
|
|
|
|
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: 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')
|
|
|
|
--------------------------------------------------------------------------------
|
|
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)
|