-- Joker Combo Advisor - standalone test suite. -- -- lua test.lua -- -- The game cannot be launched headlessly, so this stubs the handful of Balatro -- globals main.lua touches and then exercises the real engine, the real -- database, and the real catalog tab builders. It covers three things: -- -- * ENGINE - scoring tiers, partner aggregation, the sell advisor. -- * DATA - the invariants that keep synergies.lua honest. A tag that -- nothing wants, or a theme too small to ever be discovered, is -- silent dead weight in the game; only a check catches it. -- * UI - the catalog pages build, and every card they place animates. -- -- Run it after every synergies.lua edit. -------------------------------------------------------------------------------- -- Test harness -------------------------------------------------------------------------------- local pass, fail = 0, 0 local function ok(cond, what, detail) if cond then pass = pass + 1 else fail = fail + 1 print((' FAIL %s%s'):format(what, detail and ('\n ' .. detail) or '')) end return cond end local function eq(got, want, what) return ok(got == want, what, ('expected %s, got %s'):format(tostring(want), tostring(got))) end local function section(name) print('\n== ' .. name .. ' ==') end -- Sorted key list, so failure messages are stable and diffable. local function sorted_keys(t) local keys = {} for k in pairs(t) do keys[#keys + 1] = k end table.sort(keys) return keys end -------------------------------------------------------------------------------- -- Game global stubs (only what main.lua actually reaches for) -------------------------------------------------------------------------------- -- G.C is full of colour tables; auto-vivify so any colour lookup works. local function auto() return setmetatable({}, {__index = function(t, k) local v = setmetatable({}, getmetatable(t)) rawset(t, k, v) return v end}) end SMODS = { load_file = function(f) return loadfile(f) end, current_mod = {config = dofile('config.lua')}, save_mod_config = function() end, } G = { UIT = setmetatable({}, {__index = function(_, k) return k end}), C = auto(), STAGES = {RUN = 1}, STAGE = 1, FUNCS = {}, ROOM = {T = {x = 0, y = 0, w = 20, h = 12}}, CARD_W = 1, CARD_H = 1.4, P_CARDS = {empty = {}}, P_CENTERS = {}, SETTINGS = {profile = 1}, PROFILES = {{all_unlocked = false}}, GAME = {}, } -- Cards the catalog pages build, so the UI section can inspect them. local emplaced, materialized, sounds = {}, {}, 0 CardArea = function(x, y, w, h, cfg) return { T = {x = x, y = y, w = w, h = h}, config = cfg, cards = {}, emplace = function(self, c) self.cards[#self.cards + 1] = c emplaced[#emplaced + 1] = c end, } end Card = setmetatable({ generate_UIBox_ability_table = function() end, update = function() end, }, {__call = function(_, x, y, w, h, front, center) local card card = { T = {x = x, y = y, w = w, h = h}, config = {center = center}, ability = {set = 'Joker'}, children = {}, states = {hover = {}}, juice_up = function() end, start_materialize = function(_, _, silent) materialized[card] = true if not silent then sounds = sounds + 1 end end, } return card end}) UIBox = function(args) return {def = args and args.definition, remove = function() end} end create_option_cycle = function() return {n = 'R', config = {}, nodes = {}} end localize = function() error('no localization outside the game') end local data = dofile('synergies.lua') dofile('main.lua') -- Every joker the database knows needs a center for the UI to render it. for key in pairs(JCA.db) do G.P_CENTERS[key] = {key = key, name = key, set = 'Joker', rarity = 1} end -- Engine tags carry no teaching text and are deliberately never "wanted" -- -- the generic mult/xmult complement handles them instead. local ENGINE_TAGS = {mult = true, chips = true, xmult = true} -- Put jokers on the board, as G.jokers.cards holding real center references. local function field(...) local cards = {} for _, key in ipairs({...}) do cards[#cards + 1] = {config = {center = {key = key, name = key, set = 'Joker'}}} end G.jokers = {cards = cards, config = {card_limit = #cards}} return cards end -------------------------------------------------------------------------------- section('Engine: scoring tiers') -------------------------------------------------------------------------------- eq(JCA.score('j_baron', 'j_mime'), 6, 'famous pair + shared tags = 6') eq(JCA.score('j_blueprint', 'j_brainstorm'), 4, 'famous pair alone = 4') eq(JCA.score('j_jolly', 'j_sly'), 4, 'hand-type cluster (2 + 2) = 4') -- Hack gives retrig_low, which The Idol wants (the Idol card can roll any -- rank); neither side triggers the generic complement (Hack adds no Mult or -- Chips), so this is a clean 2. eq(JCA.score('j_hack', 'j_idol'), 2, 'single tag match = 2') eq(JCA.score('j_8_ball', 'j_scary_face'), 0, 'unrelated jokers = 0') -- Rank-limited retriggers must not claim payoffs their ranks cannot reach: -- Hack replays only 2s-5s, so it never feeds a face or Ace payoff; Sock and -- Buskin replays only faces, so Wee's 2s mean nothing to it. eq(JCA.score('j_hack', 'j_business'), 0, 'Hack cannot retrigger Business Card faces') eq(JCA.score('j_hack', 'j_scholar'), 0, 'Hack cannot retrigger Scholar Aces') eq(JCA.score('j_sock_and_buskin', 'j_wee'), 0, 'Sock and Buskin cannot retrigger 2s') -- The generic complement stacks on top of a tag match: Gluttonous gives clubs -- (Blackboard wants them) AND feeds Blackboard's xMult, so 2 + 1. eq(JCA.score('j_gluttenous_joker', 'j_blackboard'), 3, 'tag match + generic complement = 3') eq(JCA.score('j_not_a_real_joker', 'j_baron'), 0, 'unknown key (other mods) scores 0') eq(JCA.score('j_not_a_real_joker', 'j_also_fake'), 0, 'two unknown keys score 0') ok(select(2, JCA.score('j_gluttenous_joker', 'j_blackboard')), 'generic +Mult x xMult complement flags itself') ok(not select(2, JCA.score('j_joker', 'j_stencil')), 'no_generic opts Joker Stencil out of the generic complement') -------------------------------------------------------------------------------- section('Engine: partners, recommendation, sell advisor') -------------------------------------------------------------------------------- field('j_mime', 'j_hack') local partners, total = JCA.partners_for({config = {center = {key = 'j_baron', set = 'Joker'}}}) ok(#partners >= 1, 'Baron finds a partner on a Mime board') eq(partners[1].key, 'j_mime', 'strongest partner sorts first') ok(total >= JCA.config.threshold, 'Baron clears the threshold next to Mime') -- An unrelated board names no partners. The total can still be 1 (Baron's xMult -- against Scary Face's chips is the generic complement), which is exactly why -- the threshold is even -- see below. field('j_8_ball', 'j_scary_face') local nobody, low = JCA.partners_for({config = {center = {key = 'j_baron', set = 'Joker'}}}) eq(#nobody, 0, 'no partners named on an unrelated board') ok(low < JCA.config.threshold, 'an unrelated board stays under the threshold') -- The generic +1 must never be able to trigger a recommendation on its own: -- tag and pair scores are even and the threshold is even, so a pulsing card -- always has a nameable partner. ok(JCA.config.threshold % 2 == 0, 'threshold is even, so the generic +1 alone cannot recommend') field('j_baron', 'j_mime') -- 2 jokers, board not full eq(JCA.weakest_link(), nil, 'no sell advice below 3 jokers') local board = field('j_baron', 'j_mime', 'j_8_ball') eq(JCA.weakest_link(), board[3], 'flags the joker with the least synergy') G.jokers.config.card_limit = 5 eq(JCA.weakest_link(), nil, 'no sell advice while slots are free') field('j_8_ball', 'j_8_ball', 'j_8_ball') eq(JCA.weakest_link(), nil, 'no sell advice when every joker scores the same') -------------------------------------------------------------------------------- section('Data: tag integrity') -------------------------------------------------------------------------------- local givers, wanters, members = {}, {}, {} for key, e in pairs(JCA.db) do for t in pairs(e.gives) do givers[t] = (givers[t] or 0) + 1 members[t] = members[t] or {} members[t][key] = true end for t in pairs(e.wants) do wanters[t] = (wanters[t] or 0) + 1 members[t] = members[t] or {} members[t][key] = true end end -- A wanted tag nobody gives is a payoff with no enabler. for _, t in ipairs(sorted_keys(wanters)) do ok(givers[t], ('tag %q is wanted (%dx) and given by someone'):format(t, wanters[t])) end -- The mirror image, and the one that actually bit us: a tag given but never -- wanted can never add to a score, so it is invisible dead weight. for _, t in ipairs(sorted_keys(givers)) do if not ENGINE_TAGS[t] then ok(wanters[t], ('tag %q is given (%dx) and wanted by someone'):format(t, givers[t]), 'nothing wants it, so it can never affect a score') end end for _, t in ipairs(sorted_keys(givers)) do if not ENGINE_TAGS[t] then ok(data.tag_text[t], ('tag %q has TAG_TEXT for the tooltip'):format(t)) end end local in_order = {} for _, t in ipairs(data.tag_order) do in_order[t] = true end for _, t in ipairs(sorted_keys(members)) do if not ENGINE_TAGS[t] then ok(in_order[t], ('tag %q appears in TAG_ORDER'):format(t)) end end for _, t in ipairs(data.tag_order) do ok(members[t] or ENGINE_TAGS[t], ('TAG_ORDER tag %q is used by at least one joker'):format(t)) end -------------------------------------------------------------------------------- section('Data: tooltip phrasing') -------------------------------------------------------------------------------- -- A cluster tag needs all three phrasings. "both reward Straights" is only true -- when BOTH jokers reward them; a pure enabler (Four Fingers, DNA) gives the tag -- without wanting it and must not be described as rewarding the hand. for _, t in ipairs(sorted_keys(data.tag_text)) do local text = data.tag_text[t] if text.both then ok(text.give and text.want, ('cluster tag %q has give/want phrasings, not just "both"'):format(t), 'an enabler paired with a payoff would otherwise be told it "rewards" the hand') end end -- Four Fingers ENABLES straights (it does not reward them); Runner rewards them. eq(JCA.explain('j_four_fingers', 'j_runner'), 'rewards Straights', 'a payoff partner is described as rewarding the hand') eq(JCA.explain('j_runner', 'j_four_fingers'), 'makes Straights easier', 'an enabler partner is described as enabling, not rewarding') eq(JCA.explain('j_dna', 'j_family'), 'rewards Four of a Kind', 'The Family rewards Four of a Kind') eq(JCA.explain('j_family', 'j_dna'), 'makes Four of a Kind easier', 'DNA enables Four of a Kind') -- Two full cluster members still get the "both" phrasing. eq(JCA.explain('j_jolly', 'j_sly'), 'both reward Pairs', 'two payoffs still read as "both"') for _, t in ipairs(sorted_keys(data.theme_info)) do ok(JCA.tag_label[t], ('theme %q has a TAG_LABEL for learning-mode hints'):format(t)) end -------------------------------------------------------------------------------- section('Engine: copy jokers are positional') -------------------------------------------------------------------------------- -- Blueprint copies the joker to its RIGHT, Brainstorm the LEFTMOST. The board -- order decides what they actually copy, and a copier in the wrong slot is dead. local bp_board = field('j_baron', 'j_blueprint', 'j_mime') eq(JCA.copy_source(bp_board[2]), bp_board[3], 'Blueprint copies the joker to its right') field('j_baron', 'j_mime', 'j_blueprint') eq(JCA.copy_source(G.jokers.cards[3]), nil, 'a rightmost Blueprint copies nothing') local bs_board = field('j_baron', 'j_brainstorm', 'j_mime') eq(JCA.copy_source(bs_board[2]), bs_board[1], 'Brainstorm copies the leftmost joker') field('j_brainstorm', 'j_baron') eq(JCA.copy_source(G.jokers.cards[1]), nil, 'a leftmost Brainstorm copies nothing') field('j_baron', 'j_mime') eq(JCA.copy_source(G.jokers.cards[1]), nil, 'a non-copier has no copy source') -- vanilla's own "cannot be copied" flag: 29 jokers carry blueprint_compat = false -- and yield nothing at all when copied. local uncopyable = field('j_blueprint', 'j_splash') uncopyable[2].config.center.blueprint_compat = false local target, status = JCA.copy_source(uncopyable[1]) eq(target, uncopyable[2], 'it still reports what sits in the copied slot') eq(status, 'incompatible', 'and flags that vanilla will not copy it (blueprint_compat)') -- Vanilla tests the flag for TRUTH, so a modded joker that omits it is NOT -- copyable. `~= false` would wrongly call this one fine. local modded = field('j_blueprint', 'j_baron') modded[2].config.center.blueprint_compat = nil eq(select(2, JCA.copy_source(modded[1])), 'incompatible', 'a joker with no blueprint_compat flag at all is treated as uncopyable') local fine = field('j_blueprint', 'j_baron') fine[2].config.center.blueprint_compat = true eq(select(2, JCA.copy_source(fine[1])), 'ok', 'a copyable target reports as ok') -- A debuffed joker is copied as nothing (SMODS.blueprint_effect bails on it). local debuffed = field('j_blueprint', 'j_baron') debuffed[2].config.center.blueprint_compat = true debuffed[2].debuff = true eq(select(2, JCA.copy_source(debuffed[1])), 'debuffed', 'copying a debuffed joker yields nothing') -- Copiers CHAIN: Blueprint -> Blueprint -> Baron really does give two Barons, so -- the answer must be Baron, not "a Blueprint". local chained = field('j_blueprint', 'j_blueprint', 'j_baron') for _, c in ipairs(chained) do c.config.center.blueprint_compat = true end local final, st, via = JCA.copy_source(chained[1]) eq(final, chained[3], 'the chain resolves to the joker actually copied') eq(st, 'ok', 'and reports it as live') eq(#via, 1, 'and records the copier walked through on the way') eq(via[1], chained[2], 'which is the middle Blueprint') -- A copier ring copies nothing, and must not hang. local ring = field('j_brainstorm', 'j_blueprint') for _, c in ipairs(ring) do c.config.center.blueprint_compat = true end eq(JCA.copy_source(ring[2]), nil, 'a Blueprint/Brainstorm ring resolves to nothing') -- A dead copier's fix is a reorder, so the advisor names the best target on -- the board: copyable (blueprint_compat for TRUTH, not debuffed, not another -- copier), preferring jokers the db marks as prime copy targets. field('j_hiker', 'j_baron', 'j_blueprint') for _, c in ipairs(G.jokers.cards) do c.config.center.blueprint_compat = true end local best, prime = JCA.best_copy_target(G.jokers.cards[3]) eq(best, G.jokers.cards[2], 'the prime copy target (Baron) beats a plain joker') eq(prime, true, 'and is reported as prime') -- With no prime target, the leftmost copyable joker wins; copiers and -- uncopyable jokers never get suggested. field('j_blueprint', 'j_hiker', 'j_splash') G.jokers.cards[2].config.center.blueprint_compat = true best, prime = JCA.best_copy_target(G.jokers.cards[1]) eq(best, G.jokers.cards[2], 'falls back to the leftmost copyable joker') eq(prime, false, 'which is not prime') -- A debuffed joker is never the suggestion: it copies as nothing. field('j_blueprint', 'j_baron') G.jokers.cards[2].config.center.blueprint_compat = true G.jokers.cards[2].debuff = true eq(JCA.best_copy_target(G.jokers.cards[1]), nil, 'a debuffed joker is not suggested as a copy target') -------------------------------------------------------------------------------- section('Engine: the sell advisor respects what you can actually sell') -------------------------------------------------------------------------------- -- Eternal jokers CANNOT be sold (Card:can_sell_card, card.lua:1993). Telling the -- player to sell one is advice they are not allowed to take. -- -- 8 Ball scores 0 here and Scary Face 1, so 8 Ball is the natural cut -- until it -- turns out to be Eternal, and the advice has to move to the next one down. local et = field('j_8_ball', 'j_scary_face', 'j_baron', 'j_mime') G.jokers.config.card_limit = 4 eq(JCA.weakest_link(), et[1], 'normally it flags the lowest-scoring joker') et[1].ability = {eternal = true} local pick = JCA.weakest_link() ok(pick ~= et[1], 'but never an Eternal one, because it cannot be sold', pick and pick.config.center.key or 'nil') eq(pick, et[2], 'it falls through to the next sellable candidate instead') -------------------------------------------------------------------------------- section('Engine: the sell advisor weighs rentals and perished jokers') -------------------------------------------------------------------------------- -- ability.rental drains $3 a round (card.lua:2646, game.lua:1957); a perishable -- joker whose tally ran out is debuffed FOREVER (card.lua:716). Both are flags -- on card.ability, read the same way as eternal. local plain = field('j_baron', 'j_joker') eq(JCA.is_rental(plain[1]), false, 'no sticker reads as no rental') plain[1].ability = {rental = true} eq(JCA.is_rental(plain[1]), true, 'ability.rental reads as a rental') plain[2].ability = {perishable = true, perish_tally = 3} eq(JCA.is_perished(plain[2]), false, 'a ticking perishable has not perished yet') plain[2].ability.perish_tally = 0 eq(JCA.is_perished(plain[2]), true, 'tally 0 means debuffed for good') -- Three mutually inert jokers tie at 0, so nothing stands out to cut -- -- until one of them is a rental, which bleeds money every round. local rent = field('j_joker', 'j_half', 'j_misprint') G.jokers.config.card_limit = 3 eq(JCA.weakest_link(), nil, 'an all-tied board flags nobody') rent[2].ability = {rental = true} eq(JCA.weakest_link(), rent[2], 'the rental loses the tie: it drains $3 a round') -- A perished joker is dead weight at ANY synergy score: it outweighs even a -- famous-pair member against the board's zero-scorer. local per = field('j_baron', 'j_mime', 'j_joker') G.jokers.config.card_limit = 3 eq(JCA.weakest_link(), per[3], 'normally the zero-scorer is the cut') per[2].ability = {perishable = true, perish_tally = 0} eq(JCA.weakest_link(), per[2], 'but a perished joker is the cut at any score - it will never work again') -------------------------------------------------------------------------------- section('Engine: 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 five class-killer bosses, verbatim from game.lua's P_BLINDS debuff -- configs. Every tag they reference must exist in the database, or the -- caution can never fire. for boss, kill in pairs(JCA.boss_kills) do ok(JCA.tag_label[kill.tag], ('boss %s kills a real catalog tag (%s)'):format(boss, kill.tag)) end -- The Plant debuffs faces: it must catch both the payoff (Business Card -- wants faces) and the enabler (Pareidolia turns every card into one). local plant = JCA.boss_kills.bl_plant ok(JCA.db.j_business.wants[plant.tag], 'The Plant caution catches face payoffs') ok(JCA.db.j_pareidolia.gives[plant.tag], 'and face enablers, whose conversion kills the deck') -- Upcoming-boss reading: warn while the boss is ahead, stay quiet once it -- is dealt with. G.GAME.round_resets = { blind_choices = {Small = 'bl_small', Big = 'bl_big', Boss = 'bl_plant'}, blind_states = {Small = 'Defeated', Big = 'Current', Boss = 'Upcoming'}, } eq(JCA.upcoming_boss(), 'bl_plant', 'the upcoming boss is read from round_resets') G.GAME.round_resets.blind_states.Boss = 'Defeated' eq(JCA.upcoming_boss(), nil, 'a defeated boss warns nobody') G.GAME.round_resets = nil eq(JCA.upcoming_boss(), nil, 'no round state (menus, tests) reads as no boss') -------------------------------------------------------------------------------- section('Engine: Ceremonial Dagger names its victim') -------------------------------------------------------------------------------- -- It destroys the joker to its RIGHT at the next blind, permanently. local dagger = field('j_baron', 'j_ceremonial', 'j_mime') local victim, safe = JCA.dagger_victim(dagger[2]) eq(victim, dagger[3], 'it names the joker on its right as the victim') eq(safe, false, 'and an ordinary joker is not safe from it') field('j_baron', 'j_mime', 'j_ceremonial') eq(JCA.dagger_victim(G.jokers.cards[3]), nil, 'a rightmost Dagger eats nothing') field('j_ceremonial', 'j_baron') eq(JCA.dagger_victim(G.jokers.cards[2]), nil, 'a non-Dagger has no victim') -- Vanilla refuses to slice Eternal jokers, so the advisor must not cry wolf. local eternal = field('j_ceremonial', 'j_baron') eternal[2].ability = {eternal = true} local _, is_safe = JCA.dagger_victim(eternal[1]) eq(is_safe, true, 'an Eternal neighbour is reported as safe') -------------------------------------------------------------------------------- section('Engine: the sell advisor weighs clashes') -------------------------------------------------------------------------------- -- Clashes must never touch a synergy score... local pre = JCA.score('j_vampire', 'j_ticket') ok(pre >= 0, 'a clashing pair still scores by its tags alone') ok(JCA.clash_blurb[ ('j_ticket' < 'j_vampire') and 'j_ticket|j_vampire' or 'j_vampire|j_ticket'], 'Vampire + Golden Ticket is a known clash') -- ...but on a full board, the joker stuck in a clash is the one to cut, even -- when a joker with no synergy at all is sitting next to it. local board = field('j_vampire', 'j_ticket', 'j_baron', 'j_mime') G.jokers.config.card_limit = 4 local cut = JCA.weakest_link() ok(cut == board[1] or cut == board[2], 'the sell advisor flags one of the two clashing jokers', cut and cut.config.center.key or 'nil') -------------------------------------------------------------------------------- section('Engine: build gaps name what the board is shopping for') -------------------------------------------------------------------------------- -- Baron alone: wants held-card retriggers and hand size, and nothing on the -- board provides either; its copy_target give has no copier to pay it off. field('j_baron') local looking, untapped = JCA.build_gaps() eq(table.concat(looking, ','), 'retrig_held,hand_size', 'a lone Baron is shopping for its enablers, in TAG_ORDER') eq(table.concat(untapped, ','), 'copy_target', 'its copy_target hook is untapped until a copier arrives') -- Mime fills the retrig_held gap (and only that gap). field('j_baron', 'j_mime') looking = JCA.build_gaps() eq(table.concat(looking, ','), 'hand_size', 'Mime closes the retrig_held gap') -- Two copies of the same cluster joker feed each other: the entries share one -- db table, so the check must compare board slots, not table identity. field('j_jolly', 'j_jolly') looking = JCA.build_gaps() eq(table.concat(looking, ','), '', 'two Jolly Jokers are not shopping for Pairs') -- One alone is: a cluster member cannot feed itself. field('j_jolly') looking = JCA.build_gaps() eq(table.concat(looking, ','), 'pair', 'a lone cluster member still wants its cluster') -------------------------------------------------------------------------------- section('Data: themes are reachable') -------------------------------------------------------------------------------- -- The catalog tabs and the discovery loop must agree on the theme set, or a -- theme is either untrackable or unshowable. for _, t in ipairs(sorted_keys(data.theme_info)) do ok(JCA.theme_names[t], ('theme %q has a tab entry in THEME_TABS'):format(t)) end for _, t in ipairs(sorted_keys(JCA.theme_names)) do ok(data.theme_info[t], ('THEME_TABS tag %q has a theme_info sentence'):format(t)) end -- Discovery needs a giver and a wanter on two DIFFERENT jokers (main.lua:480). -- A theme that cannot satisfy that is an achievement no player can ever earn, -- and it holds the Progress tab below 100% forever. for _, t in ipairs(sorted_keys(data.theme_info)) do local n = 0 for _ in pairs(members[t] or {}) do n = n + 1 end ok(givers[t] and wanters[t] and n >= 2, ('theme %q is discoverable'):format(t), ('givers=%d wanters=%d distinct jokers=%d - needs >=1 giver, >=1 wanter, >=2 jokers') :format(givers[t] or 0, wanters[t] or 0, n)) end for _, t in ipairs(sorted_keys(data.theme_info)) do ok(#data.theme_info[t] <= 90, ('theme %q blurb fits (<=90 chars)'):format(t), ('%d chars'):format(#data.theme_info[t])) end -------------------------------------------------------------------------------- section('Data: pairs, clashes and cautions') -------------------------------------------------------------------------------- local BLURB_MAX = 34 -- name + blurb must fit one tooltip row for _, p in ipairs(data.pairs) do local label = ('pair %s|%s'):format(p[1], p[2]) ok(JCA.db[p[1]], label .. ': first key is a known joker') ok(JCA.db[p[2]], label .. ': second key is a known joker') ok(p[1] ~= p[2], label .. ': pairs two different jokers') ok(p[3] and #p[3] > 0, label .. ': has a blurb') ok(not p[3] or #p[3] <= BLURB_MAX, label .. ': blurb fits one row', p[3] and ('%d chars: %q'):format(#p[3], p[3])) end local seen = {} for _, p in ipairs(data.pairs) do local a, b = p[1], p[2] local k = a < b and (a .. '|' .. b) or (b .. '|' .. a) ok(not seen[k], ('pair %s is listed only once'):format(k)) seen[k] = true end for _, c in ipairs(data.clashes or {}) do local label = ('clash %s|%s'):format(c[1], c[2]) ok(JCA.db[c[1]] and JCA.db[c[2]], label .. ': both keys are known jokers') ok(#c[3] <= BLURB_MAX, label .. ': warning fits one row', ('%d chars: %q'):format(#c[3], c[3])) -- A clash is a trap, not a combo. Listing a pair as both would be incoherent. local k = c[1] < c[2] and (c[1] .. '|' .. c[2]) or (c[2] .. '|' .. c[1]) ok(not seen[k], label .. ': is not also listed as a famous pair') end for _, key in ipairs(sorted_keys(data.cautions or {})) do ok(JCA.db[key], ('caution %s: is a known joker'):format(key)) ok(#data.cautions[key] <= BLURB_MAX, ('caution %s: fits one row'):format(key), ('%d chars: %q'):format(#data.cautions[key], data.cautions[key])) end -------------------------------------------------------------------------------- section('Data: joker coverage') -------------------------------------------------------------------------------- local n_jokers, inert = 0, {} for _, key in ipairs(sorted_keys(JCA.db)) do n_jokers = n_jokers + 1 local e = JCA.db[key] if not next(e.gives) and not next(e.wants) then inert[#inert + 1] = key end end eq(n_jokers, 150, 'all 150 vanilla jokers are in the database') -- Some jokers really have no combo (Mr. Bones, Credit Card...). That is a -- legitimate answer, but it should be a deliberate list, not an accident: -- anything new showing up here scores 0 against the entire game. local EXPECTED_INERT = { j_chaos = true, -- free reroll each shop; helps no specific build j_chicot = true, -- disables the boss blind j_credit_card = true, -- go negative on money j_diet_cola = true, -- free Double Tag j_luchador = true, -- sell to disable the boss blind j_mr_bones = true, -- prevents death j_ring_master = true, -- duplicates in the shop pool } for _, key in ipairs(inert) do ok(EXPECTED_INERT[key], ('joker %s is inert on purpose'):format(key), 'it has no gives and no wants, so it scores 0 against every joker') end for _, key in ipairs(sorted_keys(EXPECTED_INERT)) do ok(JCA.db[key] and not next(JCA.db[key].gives) and not next(JCA.db[key].wants), ('%s is still listed as intentionally inert'):format(key), 'it now has tags - drop it from EXPECTED_INERT') end -------------------------------------------------------------------------------- section('Data: public API') -------------------------------------------------------------------------------- JCA.register('j_mod_test', {'mult', 'bogus_tag'}, {'retrig_scoring'}) ok(JCA.db.j_mod_test, 'JCA.register adds a joker') ok(JCA.db.j_mod_test.gives.mult, 'known tags survive registration') ok(not JCA.db.j_mod_test.gives.bogus_tag, 'unknown tags are dropped, not fatal') ok(JCA.score('j_mod_test', 'j_dusk') >= 2, 'a registered joker scores against vanilla') local before = JCA.score('j_mod_test', 'j_baron') JCA.register_pair('j_mod_test', 'j_baron', 'test blurb') eq(JCA.score('j_mod_test', 'j_baron') - before, 4, 'JCA.register_pair adds the +4 pair bonus') JCA.db.j_mod_test = nil -- keep the UI section on vanilla data only -------------------------------------------------------------------------------- section('UI: catalog pages build and animate') -------------------------------------------------------------------------------- -- Cards must materialize as they are emplaced, or a page switch pops them in -- with no animation. Only the first card of a page may play the sound. local function build(label, fn) emplaced, materialized, sounds = {}, {}, 0 local built, err = pcall(fn) if not ok(built, ('page %s builds'):format(label), tostring(err)) then return end local missing = 0 for _, c in ipairs(emplaced) do if not materialized[c] then missing = missing + 1 end end eq(missing, 0, ('page %s materializes every one of its %d cards'):format(label, #emplaced)) ok(sounds <= 1, ('page %s plays at most one materialize sound'):format(label), ('%d cards played it'):format(sounds)) end G.jokers = nil -- the catalog is reachable from the main menu, outside a run for _, tab in ipairs(SMODS.current_mod.extra_tabs()) do build(tab.label, tab.tab_definition_function) end -- And the same on a page switch, which is what the pager callbacks do. G.OVERLAY_MENU = {get_UIE_by_ID = function() return {config = {object = {remove = function() end}}, UIBox = {recalculate = function() end}} end} for _, name in ipairs({'Combos', 'Engine', 'Economy', 'Hands'}) do build(name .. ' page 2', function() G.FUNCS['jca_page_' .. name]{cycle_config = {current_option = 2}} end) end -------------------------------------------------------------------------------- print(('\n%d passed, %d failed'):format(pass, fail)) os.exit(fail == 0 and 0 or 1)