604 lines
25 KiB
JavaScript
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);
|