075d5da4d1
The Combo Advisor info popup is right-anchored to the hovered card and grows leftward with no screen clamp (vanilla align_h_popup leaves lr_clamp off), so its width is set by its single widest row -- and several rows had no cap: the fusion "Fuses into X - still needs Y" line, chained copy lines, and the "name: reason" combo / "Clashes - name: blurb" rows, which run well past a single row once a long joker name meets a full 34-char blurb. On a left-of-centre shop card the box ran off the screen and clipped. Enforce one width invariant on every rendered line via a wrap_text helper (word-wrap, never splits a name, keeps continuation indents) and a seg_row for the two-segment lines. The prefixed wrap_names blocks (Looking for:, Offers:, also:, Feeds:, Fusing drops) now budget for their prefix instead of adding it on top. Size the budget to the card's actual gutter rather than a fixed number: the room width is a constant in game units whatever the window size, so the real variable is where the card sits and which side vanilla opens the popup on. row_budget measures the gutter with the game's own text metric (exact, resolution-independent) -- roomy for a far-side card, tight near the 40% flip point so it never clips, and it tracks an ultrawide/room-widening mod for free. Missing geometry falls back to the fixed budget. The one estimate, MAIN_BOX_UNITS (the card's own desc box between us and the card), is sized ~1 unit generous to bias toward wrapping early over clipping; dbg() logs the inputs for later calibration. Tests: real row-builder driven to its widest row and asserted within budget; wrap_text budget/indent/word-integrity; row_budget fallback, monotonicity and clamp. 936 pass on 5.4 and LuaJIT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1186 lines
54 KiB
Lua
1186 lines
54 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)
|
|
|
|
--------------------------------------------------------------------------------
|
|
section('UI: advice rows fit the tooltip gutter')
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- The hover info popup is right-anchored to the card and grows leftward with no
|
|
-- screen clamp, so a row wider than the shop's left gutter runs off-screen and
|
|
-- clips (the reported bug). Every rendered line -- summed across its coloured
|
|
-- segments -- must stay within the budget, however long the name or blurb.
|
|
local BUDGET = JCA._row_budget
|
|
|
|
-- wrap_text: never exceeds the budget, never splits a word, loses no content.
|
|
do
|
|
local long = 'Fuses into Obelisk ($12) - still needs Wrathful Joker and Blueprint'
|
|
local lines = JCA._wrap_text(long, BUDGET)
|
|
local wide = false
|
|
for _, l in ipairs(lines) do if #l > BUDGET then wide = true end end
|
|
ok(not wide, 'wrap_text keeps every line within budget')
|
|
eq(table.concat(lines, ' '), long, 'wrap_text loses no words')
|
|
eq(#JCA._wrap_text(string.rep('x', BUDGET + 10), BUDGET), 1,
|
|
'wrap_text leaves an unbreakable long word on its own line')
|
|
-- Continuation lines arrive pre-indented (" Baron, Mime"); the indent must
|
|
-- survive and still count against the budget.
|
|
local indented = JCA._wrap_text(' ' .. string.rep('a', BUDGET - 2), BUDGET)
|
|
eq(#indented, 1, 'an indented line at budget does not wrap')
|
|
ok(indented[1]:sub(1, 2) == ' ', 'wrap_text keeps the leading indent')
|
|
ok(#indented[1] <= BUDGET, 'indent counts against the budget')
|
|
end
|
|
|
|
-- row_budget sizes each row to the card's actual gutter, using the game's own
|
|
-- text metric. Window size never enters it (the room is a constant in units);
|
|
-- only the card's position does. With no geometry it must fall back to the fixed
|
|
-- budget so headless callers -- and this suite -- keep a stable width.
|
|
do
|
|
eq(JCA._row_budget_for({}), BUDGET,
|
|
'row_budget falls back to the fixed budget with no card geometry')
|
|
-- Stub the font metric: getWidth in "pixels", scaled to ~0.15 units/char.
|
|
G.TILESIZE = 0.32 / 0.15
|
|
G.LANG = {font = {squish = 1, FONTSCALE = 1,
|
|
FONT = {getWidth = function(_, s) return #s end}}}
|
|
local roomy = JCA._row_budget_for({T = {x = 17, w = 1}}) -- far right, wide gutter
|
|
local tight = JCA._row_budget_for({T = {x = 8.1, w = 1}}) -- just past the flip
|
|
ok(roomy > tight, 'a card with more room gets a wider budget',
|
|
('roomy=%d tight=%d'):format(roomy, tight))
|
|
ok(tight >= 20 and roomy <= 64, 'the budget stays within a sane clamp',
|
|
('roomy=%d tight=%d'):format(roomy, tight))
|
|
G.TILESIZE, G.LANG = nil, nil
|
|
end
|
|
|
|
-- The widest real row is a clash against a long-named joker carrying a full
|
|
-- 34-char blurb: "Clashes - <name>: <blurb>" is ~64 chars and must wrap.
|
|
G.shop_jokers = {}
|
|
G.jokers = {cards = {{config = {center = {key = 'j_gluttenous_joker', set = 'Joker'}}}}}
|
|
JCA.register_clash('j_widetest', 'j_gluttenous_joker', string.rep('x', BLURB_MAX))
|
|
local hovered = {
|
|
config = {center = {key = 'j_widetest', set = 'Joker'}},
|
|
ability = {}, area = G.shop_jokers,
|
|
}
|
|
local rows = JCA._tooltip_rows(hovered)
|
|
local widest, saw_clash = 0, false
|
|
for _, row in ipairs(rows) do
|
|
local w = 0
|
|
for _, seg in ipairs(row) do w = w + #((seg.config or {}).text or '') end
|
|
if w > widest then widest = w end
|
|
for _, seg in ipairs(row) do
|
|
if ((seg.config or {}).text or ''):find('Clashes') then saw_clash = true end
|
|
end
|
|
end
|
|
ok(saw_clash, 'the clash line is present to be measured')
|
|
ok(widest <= BUDGET, 'every advice row fits the budget',
|
|
('widest row is %d chars (budget %d)'):format(widest, BUDGET))
|
|
G.jokers, G.shop_jokers = nil, nil
|
|
|
|
--------------------------------------------------------------------------------
|
|
print(('\n%d passed, %d failed'):format(pass, fail))
|
|
os.exit(fail == 0 and 0 or 1)
|