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