531 lines
25 KiB
JavaScript
531 lines
25 KiB
JavaScript
// 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); }
|