fertig-classic-games/server/scripts/verifyJewelQuest.js

604 lines
25 KiB
JavaScript

// Headless verification for Jewel Quest.
// node server/scripts/verifyJewelQuest.js [--quick]
// Exits non-zero on any failure.
//
// 1. Fixture tests: exact engine behavior on hand-built boards.
// 2. AI-vs-AI self-play with invariant checks after every action.
// 3. Skill differentiation matrix (higher skill should win more).
// 4. Class balance matrix at skill 5 (info + loose sanity band).
// 5. Ladder lint (public/data/jewelquest.json vs opponents.json).
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
SIZE, MANA_COLORS, MANA_CAP,
createMatch, applySwap, castSpell, findRuns, legalSwaps,
boardFromStrings, playerLoadout,
} from '../../public/src/games/jewelquest/JewelQuestLogic.js';
import { CLASSES, SPELLS } from '../../public/src/games/jewelquest/JewelQuestData.js';
import { createAI, chooseAction } from '../../public/src/games/jewelquest/JewelQuestAI.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const QUICK = process.argv.includes('--quick');
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); }
else { failures += 1; console.error(`FAIL ${name}${detail ? `${detail}` : ''}`); }
}
// ── Fixture helpers ──────────────────────────────────────────────────────────
// Diagonal 4-color tiling: no resting runs and (except for the skull motif in
// the top-right corner) no legal swaps — a controlled canvas for edits. The
// S/S motif guarantees one legal swap so post-action move guards never
// reshuffle a fixture board.
const BASE = [
'RGBYRGSS',
'GBYRGSYR',
'BYRGBYRG',
'YRGBYRGB',
'RGBYRGBY',
'GBYRGBYR',
'BYRGBYRG',
'YRGBYRGB',
];
// The same tiling without the motif: zero legal swaps anywhere.
const BASE_DEAD = [
'RGBYRGBY',
'GBYRGBYR',
'BYRGBYRG',
'YRGBYRGB',
'RGBYRGBY',
'GBYRGBYR',
'BYRGBYRG',
'YRGBYRGB',
];
const cell = (type) => ({ type });
const wild = (mult = 2) => ({ type: 'wild', mult });
function fixtureMatch(rows = BASE, edits = [], opts = {}) {
const m = createMatch({ seed: 5, ...opts });
m.board = boardFromStrings(rows);
for (const [r, c, x] of edits) m.board[r][c] = typeof x === 'string' ? cell(x) : x;
return m;
}
function assertClean(name, m) {
check(`${name}: fixture board has no resting runs`, findRuns(m.board).length === 0);
}
// Values that land inside each type's DEFAULT_WEIGHTS band.
const RNG_VAL = { red: 0.10, green: 0.30, blue: 0.50, yellow: 0.70, skull: 0.90, skull5: 0.965, wild: 0.985 };
function stubRng(m, seq) {
let i = 0;
m.rng = () => {
const v = seq[i++ % seq.length];
return typeof v === 'number' ? v : RNG_VAL[v];
};
}
function countCells(board, pred) {
let n = 0;
for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) {
if (board[r][c] && pred(board[r][c])) n++;
}
return n;
}
function fullBoard(board) {
return countCells(board, () => true) === SIZE * SIZE;
}
// ── 1. Fixtures ──────────────────────────────────────────────────────────────
console.log('Fixtures:');
{
const m = fixtureMatch();
assertClean('base', m);
check('base motif provides a legal swap', legalSwaps(m.board).length >= 1);
const dead = boardFromStrings(BASE_DEAD);
check('dead tiling has zero legal swaps', legalSwaps(dead).length === 0);
}
{
// Swap legality.
const m = fixtureMatch();
const before = JSON.stringify(m.board);
let res = applySwap(m, { r: 0, c: 0 }, { r: 5, c: 5 });
check('non-adjacent swap rejected', !res.legal && res.events.length === 0);
res = applySwap(m, { r: 7, c: 0 }, { r: 7, c: 1 });
check('no-match swap rejected', !res.legal);
check('rejected swap leaves board unchanged', JSON.stringify(m.board) === before);
check('rejected swap leaves turn unchanged', m.turn === 0);
}
{
// Horizontal 3-match credits the mover.
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']]);
assertClean('3-match', m);
stubRng(m, ['yellow', 'blue']);
const res = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('3-match swap is legal', res.legal);
check('mover gains 3 red mana', m.players[0].mana.red === 3, `got ${m.players[0].mana.red}`);
check('3-match deals no damage', m.players[1].hp === 50);
check('turn passes after a 3-match', m.turn === 1);
check('board refilled to 64 cells', fullBoard(m.board));
check('board rests with no runs', findRuns(m.board).length === 0);
check('no shuffle needed', !res.events.some((e) => e.type === 'shuffle'));
}
{
// Player 1's swaps credit player 1.
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']]);
m.turn = 1;
stubRng(m, ['yellow', 'blue']);
applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('player 1 credited as mover', m.players[1].mana.red === 3 && m.players[0].mana.red === 0);
check('turn returns to player 0', m.turn === 0);
}
{
// 4-run grants an extra turn.
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']]);
m.board[6][2] = cell('red'); // BASE already has red at (6,2); explicit for clarity
assertClean('4-run', m);
stubRng(m, ['yellow', 'blue']);
const res = applySwap(m, { r: 6, c: 2 }, { r: 7, c: 2 });
check('4-run swap is legal', res.legal);
check('mover gains 4 red mana', m.players[0].mana.red === 4, `got ${m.players[0].mana.red}`);
check('4-run keeps the turn', m.turn === 0);
check('extraTurn event emitted', res.events.some((e) => e.type === 'extraTurn'));
}
{
// L-shape merges into one group: 5 cells, counted once, no extra turn.
const m = fixtureMatch(BASE, [[7, 2, 'red'], [7, 4, 'red'], [6, 3, 'red']]);
assertClean('L-merge', m);
stubRng(m, ['yellow', 'blue']);
const res = applySwap(m, { r: 7, c: 3 }, { r: 7, c: 4 });
check('L-merge swap is legal', res.legal);
check('L-shape yields exactly 5 red mana', m.players[0].mana.red === 5, `got ${m.players[0].mana.red}`);
const clears = res.events.filter((e) => e.type === 'clear');
check('L-shape clears as one group', clears.length === 1 && clears[0].groups.length === 1);
check('two 3-runs do not grant an extra turn', m.turn === 1);
}
{
// Wild multiplier: R R W(x2) = 2 base x 2 = 4 mana.
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, wild(2)]]);
assertClean('wild', m);
stubRng(m, ['yellow', 'blue']);
const res = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('wild swap is legal', res.legal);
check('wild x2 doubles run mana (2 reds -> 4)', m.players[0].mana.red === 4, `got ${m.players[0].mana.red}`);
}
{
// Wilds never bridge skulls.
const m = fixtureMatch(BASE, [[7, 0, 'skull'], [7, 1, 'skull'], [7, 2, wild(2)]]);
check('S S W is not a skull run', findRuns(m.board).length === 0);
}
{
// Skull damage: skull=1, skull5=6.
const m = fixtureMatch(BASE, [[7, 0, 'skull'], [7, 1, 'skull'], [7, 2, 'green'], [7, 3, 'skull5']]);
assertClean('skull5', m);
stubRng(m, ['yellow', 'blue']);
const res = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('skull5 swap is legal', res.legal);
check('S+S+F deals 8 damage', m.players[1].hp === 42, `hp ${m.players[1].hp}`);
check('damage recorded in stats', m.players[0].stats.damageDealt === 8);
}
{
// Skull buff adds once per clear step.
const m = fixtureMatch(BASE, [[7, 0, 'skull'], [7, 1, 'skull'], [7, 2, 'green'], [7, 3, 'skull']]);
m.players[0].status.skullBuff = 2;
stubRng(m, ['yellow', 'blue']);
applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('skull buff adds +2 to a skull step', m.players[1].hp === 45, `hp ${m.players[1].hp}`);
}
{
// Mana caps at MANA_CAP.
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']]);
m.players[0].mana.red = 24;
stubRng(m, ['yellow', 'blue']);
applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('mana caps at 25', m.players[0].mana.red === MANA_CAP, `got ${m.players[0].mana.red}`);
}
{
// Two-step cascade: both steps credit the mover.
const m = fixtureMatch(BASE, [
[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red'],
[6, 1, 'green'], [6, 2, 'green'], [6, 3, 'blue'],
]);
assertClean('cascade', m);
stubRng(m, ['yellow', 'blue']);
const res = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('cascade swap is legal', res.legal);
check('cascade step 1 credits 3 red', m.players[0].mana.red === 3, `got ${m.players[0].mana.red}`);
check('cascade step 2 credits 3 green', m.players[0].mana.green === 3, `got ${m.players[0].mana.green}`);
check('bestCascade recorded', m.players[0].stats.bestCascade === 2);
check('3-run cascade still passes turn', m.turn === 1);
const clears = res.events.filter((e) => e.type === 'clear');
check('two clear events with cascade index', clears.length === 2 && clears[1].cascade === 2);
}
{
// A 4-run formed BY a cascade also grants the extra turn.
const m = fixtureMatch(BASE, [
[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red'], [7, 4, 'green'],
[6, 1, 'green'], [6, 2, 'green'], [6, 3, 'blue'],
]);
assertClean('cascade-extra', m);
stubRng(m, ['yellow', 'blue']);
applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('cascade 4-run grants extra turn', m.turn === 0);
check('cascade 4-run credits 4 green', m.players[0].mana.green === 4, `got ${m.players[0].mana.green}`);
}
{
// Refill determinism: same seed + same action => identical event streams.
const run = () => {
const m = createMatch({ seed: 123 });
const swap = legalSwaps(m.board)[0];
const res = applySwap(m, swap.a, swap.b);
return JSON.stringify(res.events);
};
check('same seed produces identical event streams', run() === run());
}
{
// Board generation: clean and playable across many seeds.
const N = QUICK ? 80 : 300;
let ok = true;
for (let seed = 1; seed <= N; seed++) {
const m = createMatch({ seed });
if (!fullBoard(m.board) || findRuns(m.board).length || !legalSwaps(m.board).length) { ok = false; break; }
}
check(`board gen clean + playable over ${N} seeds`, ok);
}
console.log('Spell fixtures:');
{
// Damage spell: cost deducted, hp reduced, turn passes.
const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] });
m.players[0].mana.red = 4;
const res = castSpell(m, 'shieldBash');
check('shieldBash legal', res.legal);
check('shieldBash deals 3', m.players[1].hp === 47);
check('mana deducted', m.players[0].mana.red === 0);
check('casting ends the turn', m.turn === 1);
check('spellsCast recorded', m.players[0].stats.spellsCast === 1);
}
{
// Unaffordable cast rejected without deduction.
const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] });
m.players[0].mana.red = 3;
const res = castSpell(m, 'shieldBash');
check('unaffordable cast rejected', !res.legal && res.events.length === 0);
check('no mana deducted on rejection', m.players[0].mana.red === 3);
check('turn unchanged on rejection', m.turn === 0);
}
{
// Locked spell slots can't be cast.
const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'], spellCounts: [3, 5] });
m.players[0].mana.red = 25;
const res = castSpell(m, 'skullForge');
check('locked spell slot rejected', !res.legal);
}
{
// Heal clamps at maxHp.
const m = fixtureMatch(BASE, [], { classes: ['druid', 'knight'] });
m.players[0].hp = 47;
m.players[0].mana.green = 5;
castSpell(m, 'regrowth');
check('heal clamps at maxHp', m.players[0].hp === 50);
}
{
// Drain hits the opponent's largest pool.
const m = fixtureMatch(BASE, [], { classes: ['assassin', 'knight'] });
m.players[0].mana.green = 4;
m.players[1].mana.blue = 7;
m.players[1].mana.red = 2;
castSpell(m, 'poisonDart');
check('poisonDart deals 3', m.players[1].hp === 47);
check('poisonDart drains largest pool', m.players[1].mana.blue === 4);
}
{
// Steal caps the caster's pool; victim still loses the full amount.
const m = fixtureMatch(BASE, [], { classes: ['assassin', 'knight'] });
m.players[0].mana.blue = 5;
m.players[0].mana.red = 23;
m.players[1].mana.red = 9;
castSpell(m, 'pickpocket');
check('steal removes from victim', m.players[1].mana.red === 3);
check('stolen mana caps at 25', m.players[0].mana.red === MANA_CAP);
}
{
// Column destruction credits all mana to the caster.
const m = fixtureMatch(BASE, [], { classes: ['sorcerer', 'knight'] });
m.players[0].mana.blue = 7;
stubRng(m, [0.05, 'yellow', 'blue']); // column 0, then refill colors
const res = castSpell(m, 'arcaneFunnel');
check('arcaneFunnel legal', res.legal);
const destroy = res.events.find((e) => e.type === 'destroy');
check('column destroy removes 8 cells', destroy && destroy.cells.length === 8);
const p = m.players[0].mana;
check('caster collects column mana (+2 each color)',
p.red === 2 && p.green === 2 && p.yellow === 2 && p.blue === 2,
JSON.stringify(p));
check('board refilled after column destroy', fullBoard(m.board));
}
{
// Stun: opponent's next turn is skipped.
const m = fixtureMatch(BASE, [], { classes: ['druid', 'knight'] });
m.players[0].mana.green = 9;
const res = castSpell(m, 'entangle');
check('stun returns the turn to the caster', m.turn === 0);
check('stun flag cleared after the skip', m.players[1].status.stunned === false);
check('skipTurn event emitted', res.events.some((e) => e.type === 'skipTurn'));
}
{
// Transform-all + spell-triggered cascade credits caster, never grants
// an extra turn. base3 is a 3-color tiling with no yellow; the two added
// yellows transmute to blue and complete a vertical 4-run in column 0.
const base3 = [
'RGBRGBRG',
'BRGBRGBR',
'GBRGBRGB',
'RGBRGBRG',
'BRGBRGBR',
'GBRGBRGB',
'RGBRGBRG',
'BRGBRGBR',
];
const m = fixtureMatch(base3, [[2, 0, 'yellow'], [3, 0, 'yellow']], { classes: ['sorcerer', 'knight'] });
assertClean('transmute', m);
m.players[0].mana.blue = 10;
stubRng(m, ['yellow', 'blue']);
const res = castSpell(m, 'transmute');
check('transmute legal', res.legal);
const tf = res.events.find((e) => e.type === 'transform');
check('transmute converts exactly the 2 yellows', tf && tf.cells.length === 2);
check('transform event board has no yellow', tf && countCells(tf.board, (x) => x.type === 'yellow') === 0);
check('spell cascade credits caster 4 blue', m.players[0].mana.blue === 4, `got ${m.players[0].mana.blue}`);
check('spell 4-run does NOT grant extra turn', m.turn === 1);
}
{
// Harmless skull destruction deals no damage.
const base3 = [
'RGBRGBRG',
'BRGBRGBR',
'GBRGBRGB',
'RGBRGBRG',
'BRGBRGBR',
'GBRGBRGB',
'RGBRGBRG',
'BRGBRGBR',
];
const m = fixtureMatch(base3, [[0, 0, 'skull'], [4, 4, 'skull'], [2, 6, 'skull']], { classes: ['druid', 'knight'] });
assertClean('naturesBalance', m);
m.players[0].hp = 30;
m.players[0].mana.green = 12;
m.players[0].mana.yellow = 8;
stubRng(m, ['yellow', 'blue']);
const res = castSpell(m, 'naturesBalance');
check('naturesBalance legal', res.legal);
const destroy = res.events.find((e) => e.type === 'destroy');
check('all skulls destroyed', destroy && destroy.cells.length === 3 && countCells(m.board, (x) => x.type === 'skull' || x.type === 'skull5') === 0);
check('harmless destroy deals 0 damage', destroy.damage === 0 && m.players[1].hp === 50);
check('heal applied first', m.players[0].hp === 40);
}
{
// Random destruction: count + direct damage land.
const base3 = [
'RGBRGBRG',
'BRGBRGBR',
'GBRGBRGB',
'RGBRGBRG',
'BRGBRGBR',
'GBRGBRGB',
'RGBRGBRG',
'BRGBRGBR',
];
const m = fixtureMatch(base3, [], { classes: ['sorcerer', 'knight'] });
m.players[0].mana.red = 14;
m.players[0].mana.blue = 10;
const res = castSpell(m, 'meteorStorm');
check('meteorStorm legal', res.legal);
const destroy = res.events.find((e) => e.type === 'destroy');
check('meteorStorm destroys 8 cells', destroy && destroy.cells.length === 8);
check('meteorStorm direct damage lands', m.players[1].hp <= 44);
check('meteorStorm mana credited to caster', m.players[0].stats.manaGained >= 8);
check('board valid after meteorStorm', fullBoard(m.board) && findRuns(m.board).length === 0);
}
{
// buffSkullDamage persists and boosts later skull matches.
const m = fixtureMatch(BASE, [[7, 0, 'skull'], [7, 1, 'skull'], [7, 2, 'green'], [7, 3, 'skull']],
{ classes: ['knight', 'druid'] });
m.players[0].mana.red = 12;
m.players[0].mana.yellow = 8;
castSpell(m, 'crusadersWrath');
check('crusadersWrath direct damage', m.players[1].hp === 42);
check('skullBuff applied', m.players[0].status.skullBuff === 2);
m.turn = 0; // hand the turn back to test the buffed match
stubRng(m, ['yellow', 'blue']);
applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
check('buffed skull match deals 3+2', m.players[1].hp === 37, `hp ${m.players[1].hp}`);
}
{
// No-moves board reshuffles after an action.
const m = fixtureMatch(BASE_DEAD, [], { classes: ['knight', 'druid'] });
m.players[0].mana.red = 4;
const res = castSpell(m, 'shieldBash');
check('dead board triggers reshuffle', res.events.some((e) => e.type === 'shuffle'));
check('reshuffled board is playable', legalSwaps(m.board).length >= 1 && findRuns(m.board).length === 0);
}
{
// playerLoadout milestone math against the shipped config.
const config = JSON.parse(readFileSync(join(__dirname, '../../public/data/jewelquest.json'), 'utf8'));
const cases = [
[0, 50, 3], [4, 50, 3], [5, 55, 4], [10, 60, 5], [15, 70, 5], [20, 70, 5],
];
let ok = true;
for (const [done, hp, spells] of cases) {
const lo = playerLoadout(config, done);
if (lo.maxHp !== hp || lo.spellCount !== spells) { ok = false; check(`playerLoadout(${done})`, false, JSON.stringify(lo)); }
}
if (ok) check('playerLoadout milestone math', true);
}
// ── 2. Self-play invariants ──────────────────────────────────────────────────
function checkInvariants(match) {
// The engine stops resolving the instant someone dies, so the board may
// legitimately hold mid-cascade holes once the match is over.
if (match.over) {
return match.players[1 - match.winner].hp === 0 ? null : 'game over but loser hp != 0';
}
for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) {
if (!match.board[r][c]) return `hole at ${r},${c}`;
}
if (findRuns(match.board).length) return 'resting runs on board';
for (const p of match.players) {
for (const color of MANA_COLORS) {
if (p.mana[color] < 0 || p.mana[color] > MANA_CAP) return `mana out of range: ${color}=${p.mana[color]}`;
}
if (p.hp < 0 || p.hp > p.maxHp) return `hp out of range: ${p.hp}`;
}
if (!legalSwaps(match.board).length) return 'no legal moves left';
return null;
}
function playGame({ skills, classes, seed, hp = [50, 50], invariants = false }) {
const match = createMatch({ seed, classes, hp, spellCounts: [5, 5] });
match.headless = true;
const ais = [
createAI({ skill: skills[0], seed: seed * 7 + 1 }),
createAI({ skill: skills[1], seed: seed * 13 + 5 }),
];
let turns = 0;
const MAX_TURNS = 300;
while (!match.over && turns < MAX_TURNS) {
const pIdx = match.turn;
const action = chooseAction(ais[pIdx], match, pIdx);
if (!action) return { error: 'AI returned no action', turns };
const res = action.type === 'spell'
? castSpell(match, action.spellId)
: applySwap(match, action.a, action.b);
if (!res.legal) return { error: `AI chose illegal ${action.type}`, turns };
turns++;
if (invariants) {
const err = checkInvariants(match);
if (err) return { error: err, turns };
}
}
return { winner: match.over ? match.winner : null, turns };
}
console.log('Self-play invariants:');
{
const N = QUICK ? 8 : 30;
const classIds = Object.keys(CLASSES);
let bad = null;
let finished = 0;
let totalTurns = 0;
for (let g = 0; g < N && !bad; g++) {
const classes = [classIds[g % 4], classIds[(g + g % 3 + 1) % 4]];
const res = playGame({ skills: [5, 5], classes, seed: 1000 + g, invariants: true });
if (res.error) bad = `game ${g} (${classes.join(' vs ')}): ${res.error}`;
else {
if (res.winner != null) finished++;
totalTurns += res.turns;
}
}
check(`invariants hold across ${N} games`, !bad, bad || '');
check('most games reach a decision', finished >= N * 0.9, `${finished}/${N}`);
if (!bad) console.log(` info avg turns/game: ${(totalTurns / N).toFixed(1)}`);
}
// ── 3. Skill differentiation ─────────────────────────────────────────────────
console.log('Skill differentiation:');
{
const pairings = [
{ skills: [1, 10], minWin: 0.70 },
{ skills: [2, 8], minWin: 0.58 },
{ skills: [3, 6], minWin: 0.52 },
{ skills: [5, 7], minWin: 0.50 },
];
const N = QUICK ? 10 : 30;
for (const { skills, minWin } of pairings) {
let highWins = 0, decided = 0;
for (let g = 0; g < N; g++) {
// alternate which seat the stronger AI takes to cancel seat bias
const flip = g % 2 === 1;
const seatSkills = flip ? [skills[1], skills[0]] : skills;
const res = playGame({ skills: seatSkills, classes: ['knight', 'knight'], seed: 5000 + skills[1] * 100 + g });
if (res.error) { check(`skill ${skills[0]}v${skills[1]}`, false, res.error); decided = -1; break; }
if (res.winner == null) continue;
decided++;
const highSeat = flip ? 0 : 1;
if (res.winner === highSeat) highWins++;
}
if (decided > 0) {
const rate = highWins / decided;
check(`skill ${skills[1]} beats skill ${skills[0]} >= ${Math.round(minWin * 100)}%`,
rate >= minWin, `won ${(rate * 100).toFixed(0)}% (${highWins}/${decided})`);
}
}
}
// ── 4. Class balance matrix (skill 5) ────────────────────────────────────────
console.log('Class balance:');
{
const classIds = Object.keys(CLASSES);
const N = QUICK ? 2 : 8;
const wins = Object.fromEntries(classIds.map((c) => [c, 0]));
const games = Object.fromEntries(classIds.map((c) => [c, 0]));
let errors = 0;
for (const a of classIds) {
for (const b of classIds) {
if (a === b) continue;
for (let g = 0; g < N; g++) {
const res = playGame({ skills: [5, 5], classes: [a, b], seed: 9000 + classIds.indexOf(a) * 997 + classIds.indexOf(b) * 131 + g });
if (res.error || res.winner == null) { errors++; continue; }
games[a]++; games[b]++;
wins[res.winner === 0 ? a : b]++;
}
}
}
for (const c of classIds) {
const rate = games[c] ? wins[c] / games[c] : 0;
console.log(` info ${CLASSES[c].name.padEnd(9)} win rate: ${(rate * 100).toFixed(0)}% (${wins[c]}/${games[c]})`);
if (!QUICK) {
check(`${CLASSES[c].name} within 25-75% band`, rate >= 0.25 && rate <= 0.75,
`${(rate * 100).toFixed(0)}%`);
}
}
check('class matrix games completed', errors === 0, `${errors} undecided/errored`);
}
// ── 5. Ladder lint ───────────────────────────────────────────────────────────
console.log('Ladder lint:');
{
const config = JSON.parse(readFileSync(join(__dirname, '../../public/data/jewelquest.json'), 'utf8'));
const roster = JSON.parse(readFileSync(join(__dirname, '../../public/data/opponents.json'), 'utf8'));
const rosterIds = new Set((Array.isArray(roster) ? roster : roster.opponents || []).map((o) => o.id));
const levels = config.levels || [];
check('ladder has levels', levels.length > 0);
check('levels contiguous from 1', levels.every((l, i) => l.level === i + 1));
check('skills in 1-10', levels.every((l) => l.skill >= 1 && l.skill <= 10));
check('hp positive', levels.every((l) => l.hp > 0));
check('classes valid', levels.every((l) => CLASSES[l.class]),
levels.filter((l) => !CLASSES[l.class]).map((l) => l.class).join(','));
check('spellCount in 1-5', levels.every((l) => l.spellCount >= 1 && l.spellCount <= 5));
check('weights (when present) positive', levels.every((l) =>
!l.weights || Object.values(l.weights).every((v) => typeof v === 'number' && v > 0)));
const unknown = levels.filter((l) => !rosterIds.has(l.opponentId)).map((l) => l.opponentId);
check('opponentIds exist in roster', unknown.length === 0, unknown.join(','));
const ms = config.milestones || [];
check('milestones sorted by afterLevel', ms.every((m, i) => i === 0 || ms[i - 1].afterLevel < m.afterLevel));
check('milestones within ladder', ms.every((m) => m.afterLevel >= 1 && m.afterLevel <= levels.length));
const slots = ms.filter((m) => m.unlockSpellSlot).map((m) => m.unlockSpellSlot);
check('unlock slots are 4 and 5, once each', slots.length === new Set(slots).size && slots.every((s) => s === 4 || s === 5));
check('playerBaseHp positive', config.playerBaseHp > 0);
// every spell id referenced by classes exists
const missing = Object.values(CLASSES).flatMap((c) => c.spells).filter((id) => !SPELLS[id]);
check('all class spell ids defined', missing.length === 0, missing.join(','));
}
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed');
process.exit(failures ? 1 : 0);