fertig-classic-games/tools/verifyBalatro.js

531 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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); }