// verifyBalatro.js — headless engine verification for Balatro. // Content sanity, evaluator table tests, scoring goldens, greedy-bot full // runs across many seeds with state invariants, determinism (incl. mid-run // serialize round trip) and an endless smoke test. Pure-logic (no Phaser). // // node verifyBalatro.js (from the tools directory) import { HAND_TYPES, HAND_BY_ID, handBase, anteBase, DECKS, DECK_BY_ID, TAROTS, PLANETS, SPECTRALS, VOUCHERS, VOUCHER_BY_ID, BOOSTERS, BOOSTER_WEIGHTS, BOOSTER_BY_ID, BOSSES, FINISHERS, BOSS_BY_ID, PLANET_BY_HAND, fmtChips, BLINDS, } from '../src/games/balatro/BalatroData.js'; import { JOKERS, JOKER_BY_ID, makeJokerInstance, resolveEffectiveJokers, jokerPool } from '../src/games/balatro/BalatroJokers.js'; import { evaluate, handContains, scorePlayedHand, cardHasSuit } from '../src/games/balatro/BalatroScoring.js'; import { makeRng, newRun, serializeRun, deserializeRun, passives, cardsOf, handCards, beginAnte, selectBlind, skipBlind, validatePlay, playHand, discard, computeCashOut, collectCashOut, continueEndless, leaveShop, rerollShop, forfeitRound, anyLegalPlay, buyShopCard, buyVoucher, sellJoker, sellConsumable, buyPack, pickFromPack, skipPack, useConsumable, sortHand, blindChipsFor, isDebuffed, consumableDef, } from '../src/games/balatro/BalatroLogic.js'; let pass = 0, fail = 0; const fails = []; function ok(cond, msg) { if (cond) pass++; else { fail++; fails.push(msg); if (fails.length < 40) console.error('FAIL:', msg); } } // ── 1. Content sanity ──────────────────────────────────────────────────────── { const JOKER_KEYS = new Set(['id', 'name', 'rarity', 'cost', 'desc', 'copyable', 'initState', 'onScored', 'onHeld', 'independent', 'cardRetriggers', 'heldRetriggers', 'onHandPlayed', 'afterHand', 'onDiscard', 'onBlindStart', 'onBlindSelected', 'onCashOut', 'onRoundEnd', 'onPackSkipped', 'onBossDefeated', 'onCardAdded', 'onConsumableUsed', 'passive', 'isDead']); const ids = new Set(); for (const j of JOKERS) { ok(!ids.has(j.id), `joker ${j.id} unique`); ids.add(j.id); ok(['common', 'uncommon', 'rare'].includes(j.rarity), `joker ${j.id} rarity`); ok(typeof j.cost === 'number' && j.cost >= 1, `joker ${j.id} cost`); ok(typeof j.desc === 'string' || typeof j.desc === 'function', `joker ${j.id} desc`); for (const k of Object.keys(j)) ok(JOKER_KEYS.has(k), `joker ${j.id} unknown key '${k}'`); if (typeof j.desc === 'function') { const inst = makeJokerInstance(j.id, 1, makeRng(1)); ok(typeof j.desc(inst) === 'string', `joker ${j.id} desc(inst) returns string`); } } console.log(`Jokers: ${JOKERS.length} (${jokerPool('common').length}C/${jokerPool('uncommon').length}U/${jokerPool('rare').length}R)`); ok(JOKERS.length >= 100, 'roster size >= 100'); const BOSS_KEYS = new Set(['id', 'name', 'desc', 'minAnte', 'reqMult', 'onBlindStart', 'validPlay', 'cardDebuffed', 'drawTransform', 'afterHandPlayed', 'preScore', 'handSizeDelta']); for (const b of [...BOSSES, ...FINISHERS]) { for (const k of Object.keys(b)) ok(BOSS_KEYS.has(k), `boss ${b.id} unknown key '${k}'`); ok(typeof b.desc === 'string', `boss ${b.id} desc`); } ok(BOSSES.length >= 18 && FINISHERS.length === 3, 'boss pool sizes'); for (const h of HAND_TYPES) ok(!!PLANET_BY_HAND[h.id], `hand ${h.id} has a planet`); ok(PLANETS.length === HAND_TYPES.length, 'planet/hand bijection'); for (const v of VOUCHERS) { if (v.requires) ok(!!VOUCHER_BY_ID[v.requires], `voucher ${v.id} requires exists`); } for (const [bid] of BOOSTER_WEIGHTS) ok(!!BOOSTER_BY_ID[bid], `booster weight ${bid} exists`); for (const d of DECKS) { for (const vid of (d.mods.startVouchers || [])) ok(!!VOUCHER_BY_ID[vid], `deck ${d.id} voucher ${vid}`); for (const c of (d.mods.startConsumables || [])) { const pool = c.kind === 'tarot' ? TAROTS : c.kind === 'planet' ? PLANETS : SPECTRALS; ok(pool.some((x) => x.id === c.id), `deck ${d.id} consumable ${c.id}`); } } const cids = new Set(); for (const c of [...TAROTS, ...PLANETS, ...SPECTRALS]) { ok(!cids.has(c.id), `consumable ${c.id} unique`); cids.add(c.id); } } // ── 2. Evaluator table ─────────────────────────────────────────────────────── { let uid = 1; const K = (rank, suit, extra = {}) => ({ uid: uid++, rank, suit, enhancement: null, edition: null, seal: null, faceDown: false, debuffed: false, ...extra }); const t = (cards, want, opts = {}, label = '') => { const r = evaluate(cards, opts); ok(r.handType === want, `evaluate ${label || want}: got ${r.handType}`); return r; }; t([K(14, 'S'), K(13, 'S'), K(12, 'S'), K(11, 'S'), K(10, 'S')], 'straightflush', {}, 'royal flush'); t([K(14, 'S'), K(2, 'H'), K(3, 'D'), K(4, 'C'), K(5, 'S')], 'straight', {}, 'ace-low straight'); t([K(9, 'S'), K(10, 'H'), K(11, 'D'), K(12, 'C'), K(13, 'S')], 'straight', {}, 'K-high straight'); t([K(2, 'S'), K(4, 'H'), K(6, 'D'), K(8, 'C'), K(10, 'S')], 'highcard', {}, 'gaps no shortcut'); t([K(2, 'S'), K(4, 'H'), K(6, 'D'), K(8, 'C'), K(10, 'S')], 'straight', { shortcut: true }, 'shortcut straight'); t([K(2, 'S'), K(3, 'H'), K(4, 'D'), K(5, 'C')], 'straight', { fourFingers: true }, '4-card straight'); t([K(2, 'S'), K(3, 'H'), K(4, 'D'), K(5, 'C')], 'highcard', {}, '4 cards no straight'); t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H'), K(13, 'H')], 'flush'); t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H')], 'flush', { fourFingers: true }, '4-card flush'); t([K(2, 'H'), K(7, 'D'), K(9, 'H'), K(11, 'D'), K(13, 'H')], 'flush', { smeared: true }, 'smeared flush'); t([K(2, 'H'), K(7, 'D'), K(9, 'H'), K(11, 'D'), K(13, 'H')], 'highcard', {}, 'mixed no smeared'); t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H'), K(13, 'S', { enhancement: 'wild' })], 'flush', {}, 'wild completes flush'); t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(5, 'C'), K(2, 'S')], 'fourofakind'); t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'fullhouse'); t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C')], 'threeofakind'); t([K(5, 'S'), K(5, 'H'), K(2, 'D'), K(2, 'C'), K(9, 'S')], 'twopair'); t([K(5, 'S'), K(5, 'H'), K(2, 'D')], 'pair'); t([K(5, 'S'), K(9, 'H'), K(2, 'D')], 'highcard'); t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(5, 'C'), K(5, 'S')], 'fiveofakind', {}, '5oak off-suit'); t([K(5, 'H'), K(5, 'H'), K(5, 'H'), K(5, 'H'), K(5, 'H')], 'flushfive'); t([K(5, 'H'), K(5, 'H'), K(5, 'H'), K(2, 'H'), K(2, 'H')], 'flushhouse'); // Stones: excluded from detection, always score. { const stone = K(9, 'S', { enhancement: 'stone' }); const r = t([K(5, 'S'), K(5, 'H'), stone], 'pair', {}, 'pair + stone'); ok(r.scoringUids.includes(stone.uid), 'stone always scores'); const r2 = t([stone], 'highcard', {}, 'stone-only hand'); ok(r2.scoringUids.includes(stone.uid), 'lone stone scores'); } // Scoring set shapes. { const cards = [K(5, 'S'), K(5, 'H'), K(9, 'D'), K(11, 'C'), K(13, 'S')]; const r = evaluate(cards, {}); ok(r.handType === 'pair' && r.scoringUids.length === 2, 'pair scores 2 cards'); const hi = evaluate([K(5, 'S'), K(9, 'H'), K(13, 'D')], {}); ok(hi.scoringUids.length === 1, 'high card scores 1'); } // handContains ok(handContains([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'pair'), 'full house contains pair'); ok(handContains([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'threeofakind'), 'full house contains trips'); ok(!handContains([K(5, 'S'), K(5, 'H'), K(9, 'D')], 'threeofakind'), 'pair lacks trips'); ok(cardHasSuit(K(5, 'H'), 'D', true) && !cardHasSuit(K(5, 'H'), 'D', false), 'smeared suit merge'); ok(cardHasSuit(K(5, 'S', { enhancement: 'wild' }), 'D', false), 'wild any suit'); ok(!cardHasSuit(K(5, 'D', { enhancement: 'stone' }), 'D', false), 'stone no suit'); } // ── 3. Scoring goldens ─────────────────────────────────────────────────────── { let uid = 100; const K = (rank, suit, extra = {}) => ({ uid: uid++, rank, suit, enhancement: null, edition: null, seal: null, faceDown: false, debuffed: false, ...extra }); function fixture(jokerIds = [], deckId = 'red') { const run = newRun(deckId, 42); run.jokers = jokerIds.map((id, i) => makeJokerInstance(id, 9000 + i, makeRng(7))); return run; } function score(run, played, held = [], boss = null, seed = 1) { const p = passives(run); return scorePlayedHand(run, played, held, { effJokers: resolveEffectiveJokers(run), passives: p, rng: makeRng(seed), isDebuffed: () => false, boss, }); } // Pair of Kings + Joker = (10+10+10) × (2+4) = 180 { const run = fixture(['joker']); const r = score(run, [K(13, 'S'), K(13, 'H')]); ok(r.score === 180, `pair of kings + Joker = 180 (got ${r.score})`); ok(r.events[0].t === 'base' && r.events[r.events.length - 1].t === 'total', 'trace bookends'); ok(r.events.every((e) => typeof e.chipsAfter === 'number' && typeof e.multAfter === 'number'), 'events carry counters'); } // Red seal + Hack on a played 4 → 3 triggers of its 4 chips: highcard 5 + 4*3 = 17 { const run = fixture(['hack']); const r = score(run, [K(4, 'D', { seal: 'red' })]); ok(r.score === 17 * 1, `red seal + Hack triggers x3 (got ${r.score})`); ok(r.events.filter((e) => e.t === 'retrigger').length === 2, 'two retrigger markers'); } // Blueprint copies The Duo: pair of 2s → mult 2 × 2 × 2 = 8, chips 10+2+2=14 → 112 { const run = fixture(['blueprint', 'duo']); const r = score(run, [K(2, 'S'), K(2, 'H')]); ok(r.score === 112, `Blueprint copies Duo = 112 (got ${r.score})`); } // Brainstorm copies leftmost: [duo, brainstorm] → same 112 { const run = fixture(['duo', 'brainstorm']); const r = score(run, [K(2, 'S'), K(2, 'H')]); ok(r.score === 112, `Brainstorm copies leftmost Duo = 112 (got ${r.score})`); } // Blueprint pointing at Blueprint→Duo chain resolves through { const run = fixture(['blueprint', 'blueprint', 'duo']); const r = score(run, [K(2, 'S'), K(2, 'H')]); ok(r.score === 14 * 16, `Blueprint chain = ${14 * 16} (got ${r.score})`); } // The Flint halves base: pair of kings → ceil(10/2)=5 +10+10 chips, mult ceil(2/2)=1 → 25 { const run = fixture([]); const r = score(run, [K(13, 'S'), K(13, 'H')], [], BOSS_BY_ID.the_flint); ok(r.score === 25, `The Flint halving = 25 (got ${r.score})`); } // Debuffed card counts for hand type but contributes 0 { const run = fixture([]); const cards = [K(13, 'S'), K(13, 'H', { debuffed: true })]; const r = score(run, cards); ok(r.handType === 'pair' && r.score === (10 + 10) * 2, `debuffed contributes 0 (got ${r.score})`); } // Steel held ×1.5: high card 5+11=16 chips... A♠ played (11), steel held: mult 1×1.5=1.5 → floor(16)*1.5=24 { const run = fixture([]); const r = score(run, [K(14, 'S')], [K(9, 'H', { enhancement: 'steel' })]); ok(r.score === Math.floor(16) * 1.5, `steel held x1.5 (got ${r.score})`); } // Glass ×2 then Polychrome ×1.5 on the same card: high card A: chips 5+11+? poly? glass: { const run = fixture([]); const r = score(run, [K(14, 'S', { enhancement: 'glass', edition: 'poly' })], [], null, 3); ok(r.score === 16 * 3, `glass x2 then poly x1.5 = mult 3 (got ${r.score})`); } // Foil +50 chips on scored card { const run = fixture([]); const r = score(run, [K(14, 'S', { edition: 'foil' })]); ok(r.score === (5 + 11 + 50) * 1, `foil +50 (got ${r.score})`); } // The Arm: hand level drops before scoring (level 2 pair → level 1) { const run = fixture([]); run.handLevels.pair = 2; const r = score(run, [K(13, 'S'), K(13, 'H')], [], BOSS_BY_ID.the_arm); ok(run.handLevels.pair === 1, 'The Arm decrements level'); ok(r.score === 180 / 6 * 2, `The Arm scores at reduced level (got ${r.score})`); } // Lucky determinism: same seed → same score twice { const mk = () => { const run = fixture([]); return score(run, [K(7, 'S', { enhancement: 'lucky' }), K(7, 'H', { enhancement: 'lucky' })], [], null, 12345); }; ok(mk().score === mk().score, 'lucky cards deterministic under seed'); } // Gold seal pays $3 per trigger { const run = fixture([]); const before = run.gold; score(run, [K(9, 'S', { seal: 'gold' })]); ok(run.gold === before + 3, 'gold seal +$3'); } // Ride the Bus grows before contributing; resets on face { const run = fixture(['ridethebus']); const r1 = score(run, [K(9, 'S'), K(9, 'H')]); ok(run.jokers[0].state.n === 1 && r1.score === (10 + 9 + 9) * 3, `RTB first hand +1 (got ${r1.score})`); score(run, [K(13, 'S')]); ok(run.jokers[0].state.n === 0, 'RTB resets on face card'); } // Ice Cream decays after the hand { const run = fixture(['icecream']); const r = score(run, [K(9, 'S')]); ok(r.score === (5 + 9 + 100) * 1, `Ice Cream +100 first hand (got ${r.score})`); ok(run.jokers[0].state.n === undefined && run.jokers[0].state.chips === 95, 'Ice Cream decayed to 95'); } // Splash: every played card scores { const run = fixture(['splash']); const r = score(run, [K(13, 'S'), K(13, 'H'), K(2, 'D'), K(3, 'C'), K(4, 'S')]); ok(r.score === (10 + 10 + 10 + 2 + 3 + 4) * 2, `Splash scores all (got ${r.score})`); } // Stencil: empty slots + itself { const run = fixture(['stencil']); // 5 slots, 1 used → 4 empty + itself = x5 const r = score(run, [K(9, 'S')]); ok(r.score === 14 * 5, `Stencil x5 (got ${r.score})`); } // Baseball Card: uncommon jokers give x1.5 { const run = fixture(['baseball', 'hack', 'mime']); const r = score(run, [K(9, 'S')]); ok(r.score === 14 * 1 * 1.5 * 1.5, `Baseball x1.5 per uncommon (got ${r.score})`); } // Hiker: permanent +5 chips accumulates { const run = fixture(['hiker']); const card = K(9, 'S'); score(run, [card]); ok(card.permaChips === 5, 'Hiker adds permaChips'); const r2 = score(run, [card]); ok(r2.score === (5 + 9 + 5) * 1, `Hiker perma chips apply next play (got ${r2.score})`); } } // ── 4. Greedy-bot full runs ────────────────────────────────────────────────── function combos(arr, k) { const out = []; const rec = (start, cur) => { if (cur.length === k) { out.push(cur.slice()); return; } for (let i = start; i < arr.length; i++) { cur.push(arr[i]); rec(i + 1, cur); cur.pop(); } }; rec(0, []); return out; } function bestPlay(run) { const hand = run.hand.slice(); const p = passives(run); let best = null; for (const k of [5, 4, 3, 2, 1]) { if (hand.length < k) continue; for (const uids of combos(hand, Math.min(k, hand.length))) { if (validatePlay(run, uids)) continue; const { handType } = evaluate(cardsOf(run, uids), p); const base = handBase(handType, run.handLevels[handType] || 1); const est = base.chips * base.mult; if (!best || est > best.est) best = { uids, est, handType }; } if (best && k <= 3) break; } return best; } function invariants(run, tag) { const uids = run.deck.map((c) => c.uid); ok(new Set(uids).size === uids.length, `${tag}: deck uids unique`); const inDeck = new Set(uids); ok(run.hand.every((u) => inDeck.has(u)), `${tag}: hand ⊆ deck`); ok(run.drawPile.every((u) => inDeck.has(u)), `${tag}: drawPile ⊆ deck`); ok(run.hand.every((u) => !run.drawPile.includes(u)), `${tag}: hand ∩ drawPile empty`); const p = passives(run); ok(run.gold >= p.creditFloor - 0.001, `${tag}: gold ${run.gold} >= floor ${p.creditFloor}`); ok(run.handsLeft >= 0 && run.discardsLeft >= 0, `${tag}: counters >= 0`); ok(run.consumables.length <= p.consumableSlots, `${tag}: consumables within slots`); // Slots can shrink after acquisition (a Negative joker destroyed by Madness / // Hex, or sold) — over-cap is legal then; only gross blowout is a bug. ok(run.jokers.filter((j) => j.edition !== 'negative').length <= p.jokerSlots + 2, `${tag}: jokers within slots (+slack)`); } function botRun(seed, deckId) { const rng = makeRng(seed ^ 0x9e3779b9); const run = newRun(deckId, seed); let steps = 0; const MAX = 4000; while (run.phase !== 'over' && steps++ < MAX) { if (steps % 25 === 0) invariants(run, `seed ${seed} step ${steps}`); if (run.phase === 'blindselect') { if (BLINDS[run.blind].skippable && rng() < 0.08) { skipBlind(run); continue; } const r = selectBlind(run); ok(r.ok, `seed ${seed}: selectBlind ok`); } else if (run.phase === 'playing') { // Occasionally use a consumable to exercise paths. if (run.consumables.length && rng() < 0.35) { const inst = rng.pick(run.consumables); const def = consumableDef(inst); const n = def.targets || 0; const targets = n ? run.hand.slice(0, def.effect && def.effect.kind === 'death' ? 2 : n) : []; useConsumable(run, inst.uid, targets); } const p = passives(run); const best = bestPlay(run); if (!best) { // No legal play (boss constraints); discard toward one, else forfeit. if (run.discardsLeft > 0 && run.hand.length) { const r = discard(run, run.hand.slice(-Math.min(3, run.hand.length))); ok(r.ok, `seed ${seed}: fallback discard ok`); continue; } ok(!anyLegalPlay(run), `seed ${seed}: forfeit only when truly stuck`); const r = forfeitRound(run); ok(r.ok && run.phase === 'over', `seed ${seed}: forfeit ends run`); continue; } if (best.est < 40 && run.discardsLeft > 0 && rng() < 0.6 && run.hand.length > 3) { const worst = run.hand.slice().sort((a, b) => { const ca = cardsOf(run, [a])[0], cb = cardsOf(run, [b])[0]; return ca.rank - cb.rank; }).slice(0, 3); const r = discard(run, worst); ok(r.ok, `seed ${seed}: discard ok`); continue; } const r = playHand(run, best.uids); ok(r.ok, `seed ${seed}: playHand ok (${r.error || ''})`); if (r.ok) { ok(r.trace.events.length >= 2, `seed ${seed}: trace has events`); ok(Number.isFinite(r.trace.score) || run.endless, `seed ${seed}: finite score`); } } else if (run.phase === 'cashout') { const r = collectCashOut(run); ok(r.ok, `seed ${seed}: collectCashOut ok`); } else if (run.phase === 'shop') { if (run.pack) { const r = pickFromPack(run, 0); if (!r.ok) skipPack(run); continue; } const p = passives(run); let acted = false; // Buy first affordable shop card. for (let i = 0; i < run.shop.cards.length; i++) { const item = run.shop.cards[i]; if (run.gold - item.cost >= 0) { const r = buyShopCard(run, i); if (r.ok) { acted = true; break; } } } if (!acted && run.shop.voucher && !run.shop.voucher.sold && run.gold >= run.shop.voucher.cost + 4) { if (buyVoucher(run).ok) acted = true; } if (!acted && run.shop.packs.length && run.gold >= run.shop.packs[0].cost + 2 && rng() < 0.7) { if (buyPack(run, 0).ok) continue; } if (!acted && rng() < 0.2 && run.gold > (run.shop.rerollCost + 6)) { rerollShop(run); continue; } if (!acted) { // Sometimes sell a joker to exercise selling. if (run.jokers.length > 4 && rng() < 0.1) sellJoker(run, rng.pick(run.jokers).uid); const r = leaveShop(run); ok(r.ok, `seed ${seed}: leaveShop ok`); } } else { ok(false, `seed ${seed}: unknown phase ${run.phase}`); break; } } ok(steps < MAX, `seed ${seed}: run terminated (${steps} steps)`); invariants(run, `seed ${seed} final`); return run; } { const SEEDS = 200; const anteReached = []; let wins = 0; for (let s = 1; s <= SEEDS; s++) { const deckId = DECKS[s % DECKS.length].id; let run; try { run = botRun(s * 7919, deckId); } catch (e) { ok(false, `seed ${s * 7919} (${deckId}) threw: ${e.stack.split('\n').slice(0, 3).join(' | ')}`); continue; } anteReached.push(run.stats.bestAnte); if (run.won) wins++; } const dist = {}; for (const a of anteReached) dist[a] = (dist[a] || 0) + 1; console.log('Bot ante distribution:', JSON.stringify(dist), `wins: ${wins}/${SEEDS}`); const reach4 = anteReached.filter((a) => a >= 4).length; ok(reach4 >= SEEDS * 0.05, `>=5% of seeds reach ante 4 (got ${reach4}/${SEEDS})`); ok(anteReached.length && Math.max(...anteReached) >= 5, 'some seed reaches ante 5+'); } // ── 5. Determinism ─────────────────────────────────────────────────────────── { const play = (seed) => { const run = newRun('blue', seed); const traces = []; let guard = 0; while (run.phase !== 'over' && guard++ < 400) { if (run.phase === 'blindselect') selectBlind(run); else if (run.phase === 'playing') { const best = bestPlay(run) || { uids: run.hand.slice(0, Math.min(5, run.hand.length)) }; const r = playHand(run, best.uids); if (r.ok) traces.push(r.trace.score); else break; } else if (run.phase === 'cashout') collectCashOut(run); else if (run.phase === 'shop') { if (run.pack) { if (!pickFromPack(run, 0).ok) skipPack(run); continue; } if (run.shop.cards.length && run.gold >= run.shop.cards[0].cost) buyShopCard(run, 0); leaveShop(run); } } return { snap: serializeRun(run), traces: traces.join(',') }; }; const a = play(777), b = play(777); ok(a.snap === b.snap, 'same seed → identical final run'); ok(a.traces === b.traces, 'same seed → identical score traces'); // Mid-run serialize round trip continues identically. const run1 = newRun('red', 4242); selectBlind(run1); const best1 = bestPlay(run1); playHand(run1, best1.uids); const run2 = deserializeRun(serializeRun(run1)); ok(run2 !== null, 'deserialize succeeds'); const cont = (run) => { let guard = 0; const log = []; while (run.phase !== 'over' && guard++ < 200) { if (run.phase === 'blindselect') selectBlind(run); else if (run.phase === 'playing') { const b = bestPlay(run) || { uids: run.hand.slice(0, 1) }; const r = playHand(run, b.uids); if (!r.ok) break; log.push(r.trace.score); } else if (run.phase === 'cashout') collectCashOut(run); else if (run.phase === 'shop') { if (run.pack) { if (!pickFromPack(run, 0).ok) skipPack(run); continue; } leaveShop(run); } } return log.join(','); }; ok(cont(run1) === cont(run2), 'serialize round trip continues identically'); } // ── 6. Endless smoke ───────────────────────────────────────────────────────── { const run = newRun('red', 31337); let lastReq = 0; let guard = 0; let overflow = false; while (run.phase !== 'over' && run.ante <= 20 && guard++ < 3000) { if (run.phase === 'blindselect') { selectBlind(run); ok(run.blindChips > 0 && Number.isFinite(run.blindChips), `ante ${run.ante} requirement finite`); if (run.blind === 'small') { ok(run.blindChips >= lastReq, `ante ${run.ante} small >= previous small (${run.blindChips} vs ${lastReq})`); lastReq = run.blindChips; } ok(typeof fmtChips(run.blindChips) === 'string' && !fmtChips(run.blindChips).includes('NaN'), 'fmtChips clean'); } else if (run.phase === 'playing') { run.roundScore = run.blindChips - 1; // god hand: any valid play wins // Respect boss constraints: include the forced card, satisfy The Psychic. let sel = run.hand.slice(0, Math.min(5, run.hand.length)); const forced = run.bossState.forcedUid; if (forced && !sel.includes(forced)) sel = [forced, ...sel.slice(0, 4)]; let r = playHand(run, sel); if (!r.ok) r = playHand(run, forced ? [forced] : [run.hand[0]]); if (!r.ok) { if (run.discardsLeft > 0) { discard(run, [run.hand[run.hand.length - 1]]); continue; } forfeitRound(run); } } else if (run.phase === 'cashout') { collectCashOut(run); if (run.phase === 'over' && run.won && !run.endless) { continueEndless(run); } } else if (run.phase === 'shop') { if (run.pack) { skipPack(run); continue; } leaveShop(run); } } ok(run.ante >= 20 || run.phase === 'over', `endless reached ante ${run.ante}`); ok(run.endless && run.won, 'endless flag + win recorded'); ok(anteBase(30) > anteBase(20) && anteBase(20) > anteBase(9), 'endless scaling increases'); ok(Number.isFinite(anteBase(60)), 'anteBase clamps before Infinity'); ok(fmtChips(anteBase(25)).length > 0 && fmtChips(1e15).includes('e'), 'e-notation past 1e11'); } console.log(`\nverifyBalatro: ${pass} passed, ${fail} failed`); if (fail) { console.error(fails.slice(0, 60).join('\n')); process.exit(1); }