From 07cada9e49a756bc86aa74b0ff7da26fcff67660 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 14 Jul 2026 11:15:10 -0700 Subject: [PATCH] Add a test suite, and fix the three dead-data bugs it found There was no test file -- CLAUDE.md described the harness in prose and referred to an "orphan-tag check" that did not exist. test.lua now stubs the game globals and exercises the real engine, database and catalog tab builders. It found three bugs on its first run: * four_kind was the only theme in the catalog that could never be discovered. Theme discovery needs a giver and a wanter on two DIFFERENT fielded jokers, and The Family was its sole member -- so the Hands bar and the overall Progress bar could never reach 100%. DNA is the real enabler (copying a card is how you stack duplicate ranks, the only vanilla route to a reliable Four of a Kind), so it now gives the matching-hand tags as a pure enabler. * hand_level was given by Space Joker and Burnt Joker and wanted by nobody, so it could never contribute to a score and its "want" tooltip text was unreachable. Nothing in vanilla rewards hand levels; the tag is dropped rather than faked. * Invisible Joker had no tags at all, so it scored 0 against the entire game. Selling it duplicates one of your Jokers -- the same "stronger target, better payoff" logic Blueprint and Brainstorm already model -- so it now wants copy_target. The suite guards both directions of the tag contract (no orphan wants, no dead gives), theme reachability, blurb widths, the inert-joker list, and the card materialize fix from the previous commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 70 +++++---- main.lua | 2 +- synergies.lua | 16 +- test.lua | 395 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 447 insertions(+), 36 deletions(-) create mode 100644 test.lua diff --git a/CLAUDE.md b/CLAUDE.md index bd8df3f..66f2653 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,38 +10,42 @@ and pulses strongly recommended cards. Pure Lua, no build step. ## Commands -Syntax check everything: +Syntax check, then run the test suite — **always both, after every edit**: ```sh -luac -p main.lua synergies.lua config.lua +luac -p main.lua synergies.lua config.lua test.lua +lua test.lua ``` -There is no test framework. Test the engine standalone by stubbing the game globals -before loading `main.lua` (the game itself cannot be launched headlessly here): +`test.lua` stubs the handful of game globals `main.lua` touches (the game cannot be +launched headlessly here) and then exercises the real engine, the real database and +the real catalog tab builders. It is the only safety net; there is no other harness. +It enforces, among other things: -```sh -lua - <<'EOF' -SMODS = { load_file = function(f) return loadfile(f) end, - current_mod = { config = dofile('config.lua') } } -Card = { generate_UIBox_ability_table = function() end, update = function() end } -G = { UIT = {ROOT='ROOT', R='R', T='T'}, - C = { UI = {TEXT_DARK='dark'}, GREEN='green', CLEAR='clear' }, - STAGES = {RUN=1}, FUNCS = {} } -dofile('main.lua') -print(JCA.score('j_baron', 'j_mime')) -- pairwise score --- simulate owned jokers via G.jokers.cards; each card needs --- { config = { center = { key=..., name=..., set='Joker' } } } -EOF -``` +- Score tiers: famous explicit pair + tags = 6, hand-type/suit cluster = 4, single + tag match = 2, generic mult+xmult complement = +1 on top, unrelated = 0. +- **No orphan wants** — a `wants` tag nothing gives is a payoff with no enabler. +- **No dead gives** — a non-engine tag given but never wanted can never change a + score, so it is invisible dead weight. (This caught `hand_level`, given by Space + and Burnt Joker and wanted by nobody.) +- **Every theme is discoverable** — theme discovery needs a giver and a wanter on two + *different* fielded jokers, so a theme with fewer than two members is an + achievement no player can ever earn and pins the Progress tab under 100% forever. + (This caught `four_kind`, whose only member was The Family.) +- `theme_info` and `THEME_TABS` cover exactly the same tag set. +- Pair/clash/caution keys all exist; every blurb fits its ~34-char row. +- Every card the catalog pages emplace is materialized (see the UI notes below). +- Jokers with no tags at all are listed in `EXPECTED_INERT` on purpose — a joker + landing there by accident scores 0 against the entire game and says nothing. -Expected score tiers: famous explicit pair + tags ≈ 6, hand-type/suit cluster = 4, -single tag match = 2, generic mult+xmult complement = 1, unrelated = 0. +To poke at the engine by hand, copy the stub block from the top of `test.lua`. ## In-game verification (this machine) - The mod is **symlinked** into the live Mods folder: - `~/.local/share/Balatro/Mods/JokerComboAdvisor -> ~/Documents/Balatro Mod`. - Edits here go live on next game launch — do not copy files anywhere. + `~/.local/share/Balatro/Mods/JokerComboAdvisor -> ~/Documents/JokerComboAdvisor` + (this repo). Edits here go live on next game launch — do not copy files anywhere. + Lovely reads that folder as `C:\users\steamuser\AppData\Roaming\Balatro\Mods`. - Game: Steam/Proton at `/mnt/games/Steam/steamapps/common/Balatro` (note: second Steam library, not the default paths). Lovely injector + Steamodded 1.0.0-beta are already installed; mods are managed via Balatro Mod Manager. @@ -96,10 +100,16 @@ Three-layer split; `JCA` is the single global namespace: per page; pagers swap the `tab_contents` UIBox from an option-cycle callback — the same pattern as SMODS's achievements tab. Because pages contain live Card/CardArea objects, tab definitions must be rebuilt on - every call — never cache the node trees. The same tabs open in-run via - a "Combos" HUD button (wrapped `create_UIBox_HUD`, injected into the - `button_area` node under Run Info/Options); the pagers work there - because vanilla `create_tabs` also names its body `tab_contents`. + every call — never cache the node trees. Cards must be `start_materialize`d + as they are emplaced (`card_row_node`), exactly as every vanilla collection + page does — that call *is* the appear animation, and without it cards pop + in with no animation on tab open and page switch. Only the first card of a + page build plays the sound (vanilla's `i>1 or j>1` silence flag); tab defs + call `begin_page()` so the counter spans the page, not one row. The same + tabs open in-run via a "Combos" HUD button (wrapped `create_UIBox_HUD`, + injected into the `button_area` node under Run Info/Options); the pagers + work there because vanilla `create_tabs` also names its body + `tab_contents`. - Discovery: `CardArea:emplace` hook calls `JCA.check_discoveries` when a joker lands in `G.jokers`; famous pairs fielded together persist in `config.discovered` (saved immediately via `SMODS.save_mod_config`) and @@ -132,9 +142,11 @@ Three-layer split; `JCA` is the single global namespace: - **Cluster tags** (hand types `pair`/`straight`/`flush`/…, suits, `planet`, `spectral_gen`): put the same tag in *both* `gives` and `wants` of every member so members mutually reinforce (2+2 = 4). Pure enablers (e.g. Four Fingers, - Smeared, Pareidolia) only `give`; pure payoffs only `want`. -- Every `wants` tag must be given by at least one joker — the standalone test's - orphan-tag check enforces this idea; re-run it after database edits. + Smeared, Pareidolia, DNA) only `give`; pure payoffs only `want`. +- A tag needs **both sides to exist, on at least two different jokers**: something + must give it, something must want it, or it is dead data that can never change a + score — and if it is also a theme, an achievement nobody can earn. `lua test.lua` + enforces both directions; run it after every `synergies.lua` edit. - Tooltips teach: `JCA.explain(hovered, partner)` picks one reason per partner — famous-pair blurb (third element of each `PAIRS` entry), else the first `TAG_ORDER` match phrased from `TAG_TEXT` (give/want/both variants), else diff --git a/main.lua b/main.lua index 2da6f39..8a62e30 100644 --- a/main.lua +++ b/main.lua @@ -267,7 +267,7 @@ local TAG_LABEL = { faces = 'face cards', retrig_scoring = 'played-card retriggers', retrig_held = 'held-card retriggers', hand_size = 'hand size', hands_up = 'extra hands', discards_up = 'discards', - probability = 'listed odds', money = 'money', hand_level = 'hand levels', + probability = 'listed odds', money = 'money', tarot_gen = 'Tarot cards', planet = 'Planet cards', spectral_gen = 'Spectral cards', gold_gen = 'Gold cards', stone_gen = 'Stone cards', enhance_gen = 'card enhancing', diff --git a/synergies.lua b/synergies.lua index 4301c24..385c2f5 100644 --- a/synergies.lua +++ b/synergies.lua @@ -114,7 +114,7 @@ j('j_fibonacci', {'mult'}, {'retrig_scoring'}) j('j_steel_joker', {'xmult'}) j('j_hack', {'retrig_scoring'}) j('j_pareidolia', {'faces'}) -j('j_space', {'hand_level'}, {'probability'}) +j('j_space', {}, {'probability'}) j('j_burglar', {'hands_up'}) j('j_blackboard', {'xmult'}, {'spades','clubs'}) j('j_sixth_sense', {'spectral_gen'}, {'spectral_gen'}) @@ -127,9 +127,12 @@ j('j_vampire', {'xmult'}, {'enhance_gen'}) j('j_hologram', {'xmult','copy_target'}, {'card_gen'}) j('j_cartomancer', {'tarot_gen'}) j('j_astronomer', {'planet'}, {'planet'}) -j('j_burnt', {'hand_level'}, {'discards_up'}) +j('j_burnt', {}, {'discards_up'}) j('j_bootstraps', {'mult'}, {'money'}) -j('j_dna', {'card_gen'}) +-- DNA copies a played card into the deck, which is how you stack duplicate +-- ranks -- the only vanilla route to a reliable Four of a Kind. Pure enabler: +-- it gives the matching-hand tags without wanting them. +j('j_dna', {'card_gen','pair','three_kind','four_kind'}) j('j_midas_mask', {'gold_gen','enhance_gen'}, {'faces'}) j('j_photograph', {'xmult','copy_target'}, {'faces','retrig_scoring'}) j('j_stone', {'chips'}, {'stone_gen'}) @@ -176,7 +179,9 @@ j('j_family', {'xmult','four_kind'}, {'four_kind'}) j('j_order', {'xmult','straight'}, {'straight'}) j('j_tribe', {'xmult','flush'}, {'flush'}) j('j_stuntman', {'chips'}) -j('j_invisible', {}) +-- Sell Invisible Joker to duplicate one of your Jokers: the same "the stronger +-- the target, the better" logic as Blueprint and Brainstorm. +j('j_invisible', {}, {'copy_target'}) j('j_blueprint', {}, {'copy_target'}) j('j_brainstorm', {}, {'copy_target'}) j('j_drivers_license', {'xmult'}, {'enhance_gen'}) @@ -258,7 +263,6 @@ local TAG_TEXT = { discards_up = {give = 'grants extra discards', want = 'feeds on discards'}, probability = {give = 'improves listed odds', want = 'relies on listed odds'}, money = {give = 'earns money', want = 'scales with your money'}, - hand_level = {give = 'levels up poker hands', want = 'rewards hand levels'}, tarot_gen = {give = 'creates Tarot cards', want = 'rewards Tarot use'}, planet = {both = 'shares the Planet economy'}, spectral_gen = {both = 'shares the Spectral theme'}, @@ -288,7 +292,7 @@ local TAG_ORDER = { 'retrig_held', 'retrig_scoring', 'faces', 'gold_gen', 'stone_gen', 'enhance_gen', 'card_gen', 'tarot_gen', 'spectral_gen', 'planet', - 'probability', 'discards_up', 'hands_up', 'hand_size', 'hand_level', + 'probability', 'discards_up', 'hands_up', 'hand_size', 'money', 'sell_value', 'joker_gen', 'hearts', 'diamonds', 'spades', 'clubs', 'pair', 'two_pair', 'three_kind', 'four_kind', 'straight', 'flush', diff --git a/test.lua b/test.lua new file mode 100644 index 0000000..727d5f4 --- /dev/null +++ b/test.lua @@ -0,0 +1,395 @@ +-- Joker Combo Advisor - standalone test suite. +-- +-- lua test.lua +-- +-- The game cannot be launched headlessly, so this stubs the handful of Balatro +-- globals main.lua touches and then exercises the real engine, the real +-- database, and the real catalog tab builders. It covers three things: +-- +-- * ENGINE - scoring tiers, partner aggregation, the sell advisor. +-- * DATA - the invariants that keep synergies.lua honest. A tag that +-- nothing wants, or a theme too small to ever be discovered, is +-- silent dead weight in the game; only a check catches it. +-- * UI - the catalog pages build, and every card they place animates. +-- +-- Run it after every synergies.lua edit. + +-------------------------------------------------------------------------------- +-- Test harness +-------------------------------------------------------------------------------- + +local pass, fail = 0, 0 +local function ok(cond, what, detail) + if cond then + pass = pass + 1 + else + fail = fail + 1 + print((' FAIL %s%s'):format(what, detail and ('\n ' .. detail) or '')) + end + return cond +end +local function eq(got, want, what) + return ok(got == want, what, ('expected %s, got %s'):format(tostring(want), tostring(got))) +end +local function section(name) print('\n== ' .. name .. ' ==') end + +-- Sorted key list, so failure messages are stable and diffable. +local function sorted_keys(t) + local keys = {} + for k in pairs(t) do keys[#keys + 1] = k end + table.sort(keys) + return keys +end + +-------------------------------------------------------------------------------- +-- Game global stubs (only what main.lua actually reaches for) +-------------------------------------------------------------------------------- + +-- G.C is full of colour tables; auto-vivify so any colour lookup works. +local function auto() + return setmetatable({}, {__index = function(t, k) + local v = setmetatable({}, getmetatable(t)) + rawset(t, k, v) + return v + end}) +end + +SMODS = { + load_file = function(f) return loadfile(f) end, + current_mod = {config = dofile('config.lua')}, + save_mod_config = function() end, +} + +G = { + UIT = setmetatable({}, {__index = function(_, k) return k end}), + C = auto(), + STAGES = {RUN = 1}, + STAGE = 1, + FUNCS = {}, + ROOM = {T = {x = 0, y = 0, w = 20, h = 12}}, + CARD_W = 1, CARD_H = 1.4, + P_CARDS = {empty = {}}, + P_CENTERS = {}, + SETTINGS = {profile = 1}, + PROFILES = {{all_unlocked = false}}, + GAME = {}, +} + +-- Cards the catalog pages build, so the UI section can inspect them. +local emplaced, materialized, sounds = {}, {}, 0 + +CardArea = function(x, y, w, h, cfg) + return { + T = {x = x, y = y, w = w, h = h}, config = cfg, cards = {}, + emplace = function(self, c) + self.cards[#self.cards + 1] = c + emplaced[#emplaced + 1] = c + end, + } +end + +Card = setmetatable({ + generate_UIBox_ability_table = function() end, + update = function() end, +}, {__call = function(_, x, y, w, h, front, center) + local card + card = { + T = {x = x, y = y, w = w, h = h}, + config = {center = center}, + ability = {set = 'Joker'}, + children = {}, states = {hover = {}}, + juice_up = function() end, + start_materialize = function(_, _, silent) + materialized[card] = true + if not silent then sounds = sounds + 1 end + end, + } + return card +end}) + +UIBox = function(args) return {def = args and args.definition, remove = function() end} end +create_option_cycle = function() return {n = 'R', config = {}, nodes = {}} end +localize = function() error('no localization outside the game') end + +local data = dofile('synergies.lua') +dofile('main.lua') + +-- Every joker the database knows needs a center for the UI to render it. +for key in pairs(JCA.db) do + G.P_CENTERS[key] = {key = key, name = key, set = 'Joker', rarity = 1} +end + +-- Engine tags carry no teaching text and are deliberately never "wanted" -- +-- the generic mult/xmult complement handles them instead. +local ENGINE_TAGS = {mult = true, chips = true, xmult = true} + +-- Put jokers on the board, as G.jokers.cards holding real center references. +local function field(...) + local cards = {} + for _, key in ipairs({...}) do + cards[#cards + 1] = {config = {center = {key = key, name = key, set = 'Joker'}}} + end + G.jokers = {cards = cards, config = {card_limit = #cards}} + return cards +end + +-------------------------------------------------------------------------------- +section('Engine: scoring tiers') +-------------------------------------------------------------------------------- + +eq(JCA.score('j_baron', 'j_mime'), 6, 'famous pair + shared tags = 6') +eq(JCA.score('j_blueprint', 'j_brainstorm'), 4, 'famous pair alone = 4') +eq(JCA.score('j_jolly', 'j_sly'), 4, 'hand-type cluster (2 + 2) = 4') +-- Hack gives retrig_scoring, which The Idol wants; 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') +-- 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: 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_hack') >= 2, 'a registered joker scores against vanilla') +local before = JCA.score('j_mod_test', 'j_baron') +JCA.register_pair('j_mod_test', 'j_baron', 'test blurb') +eq(JCA.score('j_mod_test', 'j_baron') - before, 4, 'JCA.register_pair adds the +4 pair bonus') +JCA.db.j_mod_test = nil -- keep the UI section on vanilla data only + +-------------------------------------------------------------------------------- +section('UI: catalog pages build and animate') +-------------------------------------------------------------------------------- + +-- Cards must materialize as they are emplaced, or a page switch pops them in +-- with no animation. Only the first card of a page may play the sound. +local function build(label, fn) + emplaced, materialized, sounds = {}, {}, 0 + local built, err = pcall(fn) + if not ok(built, ('page %s builds'):format(label), tostring(err)) then return end + local missing = 0 + for _, c in ipairs(emplaced) do + if not materialized[c] then missing = missing + 1 end + end + eq(missing, 0, ('page %s materializes every one of its %d cards'):format(label, #emplaced)) + ok(sounds <= 1, ('page %s plays at most one materialize sound'):format(label), + ('%d cards played it'):format(sounds)) +end + +G.jokers = nil -- the catalog is reachable from the main menu, outside a run +for _, tab in ipairs(SMODS.current_mod.extra_tabs()) do + build(tab.label, tab.tab_definition_function) +end + +-- And the same on a page switch, which is what the pager callbacks do. +G.OVERLAY_MENU = {get_UIE_by_ID = function() + return {config = {object = {remove = function() end}}, + UIBox = {recalculate = function() end}} +end} +for _, name in ipairs({'Combos', 'Engine', 'Economy', 'Hands'}) do + build(name .. ' page 2', function() + G.FUNCS['jca_page_' .. name]{cycle_config = {current_option = 2}} + end) +end + +-------------------------------------------------------------------------------- +print(('\n%d passed, %d failed'):format(pass, fail)) +os.exit(fail == 0 and 0 or 1)