335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
// Headless verification for Block Fighter.
|
|
// node server/scripts/verifyBlockFighter.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 lock.
|
|
// 3. Skill differentiation matrix (higher skill should win more).
|
|
// 4. Level bank lint (public/data/blockfighter.json vs opponents.json).
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
import {
|
|
WIDTH, HEIGHT, SPAWN_COL, KIND, COUNTER_START, SPEED_GRAVITY_MS,
|
|
createMatch, spawnPiece, stepDown, hardDrop,
|
|
moveLeft, moveRight, rotateCW, rotateCCW,
|
|
} from '../../public/src/games/blockfighter/BlockFighterLogic.js';
|
|
import { createAI, planPlacement, nextAction } from '../../public/src/games/blockfighter/BlockFighterAI.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 ──────────────────────────────────────────────────────────
|
|
const gem = (color) => ({ color, kind: KIND.GEM });
|
|
const crash = (color) => ({ color, kind: KIND.CRASH });
|
|
const counter = (color, count = COUNTER_START) => ({ color, kind: KIND.COUNTER, count });
|
|
|
|
function freshMatch() { return createMatch({ seed: 42 }); }
|
|
|
|
function put(player, r, c, cell) { player.board[r][c] = cell; }
|
|
|
|
// Give the player a specific piece and hard-drop it.
|
|
function dropPiece(match, pIdx, a, b, col, orient = 0) {
|
|
const player = match.players[pIdx];
|
|
player.piece = { a, b, row: 1, col, orient };
|
|
return hardDrop(match, pIdx);
|
|
}
|
|
|
|
function cellAt(player, r, c) { return player.board[r][c]; }
|
|
function countCells(player, pred) {
|
|
let n = 0;
|
|
for (let r = 0; r < HEIGHT; r++) for (let c = 0; c < WIDTH; c++) {
|
|
if (player.board[r][c] && pred(player.board[r][c])) n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
const BOTTOM = HEIGHT - 1;
|
|
|
|
// ── 1. Fixtures ──────────────────────────────────────────────────────────────
|
|
console.log('Fixtures:');
|
|
{
|
|
// Crash gem clears its connected same-color group.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
put(p, BOTTOM, 0, gem(0));
|
|
put(p, BOTTOM - 1, 0, gem(0));
|
|
dropPiece(m, 0, crash(0), gem(1), 0);
|
|
check('crash clears connected group', countCells(p, (c) => c.color === 0) === 0);
|
|
check('non-matching half survives', countCells(p, (c) => c.color === 1) === 1);
|
|
check('cleared cells settle', cellAt(p, BOTTOM, 0)?.color === 1);
|
|
}
|
|
{
|
|
// Lone crash gem stays put.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
dropPiece(m, 0, crash(0), gem(1), 0);
|
|
check('lone crash gem stays', countCells(p, (c) => c.kind === KIND.CRASH) === 1);
|
|
}
|
|
{
|
|
// 2x2 fusion into a power gem; then column extension to 2x3.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
dropPiece(m, 0, gem(2), gem(2), 0);
|
|
dropPiece(m, 0, gem(2), gem(2), 1);
|
|
check('2x2 fuses into power gem', p.powerGems.size === 1);
|
|
const g0 = [...p.powerGems.values()][0];
|
|
check('power gem is 2x2', g0 && g0.w === 2 && g0.h === 2);
|
|
dropPiece(m, 0, gem(2), gem(2), 2);
|
|
const g1 = [...p.powerGems.values()][0];
|
|
check('power gem extends to 3 wide', p.powerGems.size === 1 && g1.w === 3 && g1.h === 2,
|
|
JSON.stringify([...p.powerGems.values()]));
|
|
}
|
|
{
|
|
// Rigid power-gem gravity: gem bridges a hole and falls as a unit.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
// pillar in col 0 only; power gem sits on rows 10-11 across cols 0-1, hole below col 1
|
|
put(p, BOTTOM, 0, gem(3));
|
|
put(p, BOTTOM - 1, 0, gem(3));
|
|
const pg = { id: 99, color: 1, x: 0, y: BOTTOM - 3, w: 2, h: 2 };
|
|
p.powerGems.set(pg.id, pg);
|
|
for (let r = pg.y; r < pg.y + pg.h; r++) for (let c = pg.x; c < pg.x + pg.w; c++) {
|
|
put(p, r, c, { color: 1, kind: KIND.GEM, powerId: 99 });
|
|
}
|
|
// drop something far away to trigger a resolution pass
|
|
dropPiece(m, 0, gem(0), gem(2), 5);
|
|
const g = p.powerGems.get(99);
|
|
check('power gem rests on support, bridging the hole', g && g.y === BOTTOM - 3 && !cellAt(p, BOTTOM, 1),
|
|
JSON.stringify(g));
|
|
// remove the pillar support and resolve again: gem should drop as a unit
|
|
put(p, BOTTOM, 0, null);
|
|
put(p, BOTTOM - 1, 0, null);
|
|
dropPiece(m, 0, gem(0), gem(2), 5);
|
|
const g2 = p.powerGems.get(99);
|
|
check('power gem falls rigidly when support clears', g2 && g2.y === BOTTOM - 1,
|
|
JSON.stringify(g2));
|
|
}
|
|
{
|
|
// Counter gems: tick per lock, mature into normal gems at 0.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
put(p, BOTTOM, 0, counter(2, 2));
|
|
dropPiece(m, 0, gem(0), gem(1), 5);
|
|
check('counter ticks down on lock', cellAt(p, BOTTOM, 0)?.count === 1);
|
|
dropPiece(m, 0, gem(0), gem(1), 4);
|
|
check('counter matures into gem', cellAt(p, BOTTOM, 0)?.kind === KIND.GEM);
|
|
}
|
|
{
|
|
// Counter destroyed when an adjacent same-color group clears.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
put(p, BOTTOM, 0, gem(0));
|
|
put(p, BOTTOM, 1, counter(0));
|
|
dropPiece(m, 0, crash(0), gem(1), 0);
|
|
check('adjacent same-color counter cleared', countCells(p, (c) => c.kind === KIND.COUNTER) === 0);
|
|
}
|
|
{
|
|
// Diamond wipes the color beneath it; vanishes on bare floor.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
put(p, BOTTOM, 0, gem(3));
|
|
put(p, BOTTOM, 3, gem(3));
|
|
put(p, BOTTOM, 5, counter(3));
|
|
put(p, BOTTOM, 1, gem(2));
|
|
dropPiece(m, 0, { color: null, kind: KIND.DIAMOND }, gem(2), 0, 0); // diamond (bottom half) lands on yellow
|
|
check('diamond wipes all of that color (incl. counters)', countCells(p, (c) => c.color === 3) === 0);
|
|
check('other colors survive diamond', countCells(p, (c) => c.color === 2) === 2);
|
|
check('diamond itself is gone', countCells(p, (c) => c.kind === KIND.DIAMOND) === 0);
|
|
const m2 = freshMatch();
|
|
dropPiece(m2, 0, { color: null, kind: KIND.DIAMOND }, gem(2), 0, 2);
|
|
check('diamond on bare floor vanishes', countCells(m2.players[0], (c) => c.kind === KIND.DIAMOND) === 0);
|
|
}
|
|
{
|
|
// Attack is offset by own pending garbage before reaching the opponent.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
p.pendingGarbage = 100;
|
|
put(p, BOTTOM, 0, gem(0));
|
|
put(p, BOTTOM - 1, 0, gem(0));
|
|
put(p, BOTTOM, 1, gem(0));
|
|
put(p, BOTTOM - 1, 1, gem(0));
|
|
dropPiece(m, 0, crash(0), gem(1), 2, 3); // crash next to the 2x2... orient 3: b left
|
|
check('clear happened for offset test', p.lastResolve.cleared >= 5, `cleared=${p.lastResolve.cleared}`);
|
|
check('attack offsets own pending garbage', p.pendingGarbage < 100 && m.players[1].pendingGarbage === 0,
|
|
`pending=${p.pendingGarbage}, opp=${m.players[1].pendingGarbage}`);
|
|
}
|
|
{
|
|
// Unoffset attack lands on the opponent; garbage drops as counters.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
for (let r = 0; r < 4; r++) for (let c = 0; c < 2; c++) put(p, BOTTOM - r, c, gem(1));
|
|
dropPiece(m, 0, crash(1), gem(0), 2, 3);
|
|
const sent = m.players[1].pendingGarbage;
|
|
check('attack reaches opponent', sent > 0, `sent=${sent}`);
|
|
spawnPiece(m, 1);
|
|
const counters = countCells(m.players[1], (c) => c.kind === KIND.COUNTER);
|
|
check('garbage drops as counter gems on spawn', counters === Math.min(sent, 24),
|
|
`counters=${counters} sent=${sent}`);
|
|
check('counter colors follow the drop pattern',
|
|
m.players[1].board.flat().filter(Boolean).every((c) => c.kind !== KIND.COUNTER || c.color != null));
|
|
}
|
|
{
|
|
// Chain: red crash dropped in col 1 clears the reds; the green crash above
|
|
// them falls beside the green gem and triggers a second clear.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
put(p, BOTTOM, 0, gem(0));
|
|
put(p, BOTTOM - 1, 0, gem(0));
|
|
put(p, BOTTOM - 2, 0, crash(1));
|
|
put(p, BOTTOM, 1, gem(1));
|
|
dropPiece(m, 0, crash(0), gem(2), 1); // lands beside the red pair
|
|
check('chain of 2 detected', p.lastResolve.chain === 2, `chain=${p.lastResolve.chain}`);
|
|
check('chain cleared everything green', countCells(p, (c) => c.color === 1) === 0);
|
|
}
|
|
{
|
|
// Spawn-blocked loss in the spawn column.
|
|
const m = freshMatch();
|
|
const p = m.players[0];
|
|
for (let r = 0; r < HEIGHT; r++) put(p, r, SPAWN_COL, gem(r % 4));
|
|
spawnPiece(m, 0);
|
|
check('blocked spawn loses the match', p.lost && m.over && m.winner === 1);
|
|
}
|
|
|
|
// ── 2 & 3. Self-play with invariants + skill matrix ─────────────────────────
|
|
function checkInvariants(match, tag) {
|
|
for (const p of match.players) {
|
|
for (let r = 0; r < HEIGHT - 1; r++) {
|
|
for (let c = 0; c < WIDTH; c++) {
|
|
const cell = p.board[r][c];
|
|
if (cell && !p.board[r + 1][c] && cell.powerId == null) {
|
|
throw new Error(`${tag}: floating cell at ${r},${c}`);
|
|
}
|
|
}
|
|
}
|
|
for (const g of p.powerGems.values()) {
|
|
if (g.w < 2 || g.h < 2) throw new Error(`${tag}: degenerate power gem ${JSON.stringify(g)}`);
|
|
for (let r = g.y; r < g.y + g.h; r++) for (let c = g.x; c < g.x + g.w; c++) {
|
|
const cell = p.board[r]?.[c];
|
|
if (!cell || cell.powerId !== g.id || cell.color !== g.color) {
|
|
throw new Error(`${tag}: power gem cell mismatch at ${r},${c}: ${JSON.stringify(g)}`);
|
|
}
|
|
}
|
|
}
|
|
for (let r = 0; r < HEIGHT; r++) for (let c = 0; c < WIDTH; c++) {
|
|
const cell = p.board[r][c];
|
|
if (cell?.powerId != null && !p.powerGems.has(cell.powerId)) {
|
|
throw new Error(`${tag}: orphaned powerId at ${r},${c}`);
|
|
}
|
|
}
|
|
if (p.pendingGarbage < 0) throw new Error(`${tag}: negative pendingGarbage`);
|
|
}
|
|
}
|
|
|
|
const TICK_MS = 25;
|
|
function playMatch(skillA, skillB, seed, speed = 3, pieceCap = 1200) {
|
|
const match = createMatch({ seed });
|
|
const ais = [
|
|
createAI({ skill: skillA, speed, seed: seed * 2 + 1 }),
|
|
createAI({ skill: skillB, speed, seed: seed * 3 + 7 }),
|
|
];
|
|
const gravityMs = SPEED_GRAVITY_MS[speed];
|
|
const gravTimer = [0, 0];
|
|
let now = 0;
|
|
let pieces = 0;
|
|
for (const i of [0, 1]) {
|
|
spawnPiece(match, i);
|
|
if (match.players[i].piece) planPlacement(ais[i], match, i);
|
|
}
|
|
while (!match.over && pieces < pieceCap) {
|
|
now += TICK_MS;
|
|
for (const i of [0, 1]) {
|
|
if (match.over) break;
|
|
const p = match.players[i];
|
|
if (!p.piece) continue;
|
|
let locked = false;
|
|
const act = nextAction(ais[i], match, i, now);
|
|
if (act === 'left') moveLeft(match, i);
|
|
else if (act === 'right') moveRight(match, i);
|
|
else if (act === 'rotateCW') rotateCW(match, i);
|
|
else if (act === 'rotateCCW') rotateCCW(match, i);
|
|
else if (act === 'softDrop') locked = stepDown(match, i).locked;
|
|
else if (act === 'hardDrop') { hardDrop(match, i); locked = true; }
|
|
gravTimer[i] += TICK_MS;
|
|
if (!locked && p.piece && gravTimer[i] >= gravityMs) {
|
|
gravTimer[i] = 0;
|
|
locked = stepDown(match, i).locked;
|
|
}
|
|
if (locked) {
|
|
pieces += 1;
|
|
checkInvariants(match, `match(seed=${seed},${skillA}v${skillB})`);
|
|
if (!match.over) {
|
|
spawnPiece(match, i);
|
|
if (match.players[i].piece) planPlacement(ais[i], match, i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { winner: match.over ? match.winner : null, pieces };
|
|
}
|
|
|
|
console.log('\nSelf-play invariants:');
|
|
{
|
|
const games = QUICK ? 10 : 40;
|
|
let decided = 0, totalPieces = 0;
|
|
for (let s = 1; s <= games; s++) {
|
|
const { winner, pieces } = playMatch(5, 5, s * 101);
|
|
if (winner !== null) decided += 1;
|
|
totalPieces += pieces;
|
|
}
|
|
check(`self-play runs clean (${games} games)`, true);
|
|
check('most games reach a decision', decided >= games * 0.8, `${decided}/${games}`);
|
|
console.log(` avg pieces/game: ${(totalPieces / games).toFixed(1)}`);
|
|
}
|
|
|
|
console.log('\nSkill differentiation:');
|
|
{
|
|
const games = QUICK ? 8 : 24;
|
|
const pairs = [[2, 8], [3, 6], [5, 6], [1, 10]];
|
|
for (const [lo, hi] of pairs) {
|
|
let hiWins = 0, decided = 0;
|
|
for (let s = 1; s <= games; s++) {
|
|
// alternate sides to cancel any side bias
|
|
const flip = s % 2 === 1;
|
|
const { winner } = playMatch(flip ? lo : hi, flip ? hi : lo, s * 977 + lo * 13 + hi);
|
|
if (winner === null) continue;
|
|
decided += 1;
|
|
if ((flip && winner === 1) || (!flip && winner === 0)) hiWins += 1;
|
|
}
|
|
const rate = decided ? hiWins / decided : 0;
|
|
const need = hi - lo >= 5 ? 0.7 : 0.5;
|
|
check(`skill ${hi} beats ${lo} (${(rate * 100).toFixed(0)}% of ${decided})`, rate >= need);
|
|
}
|
|
}
|
|
|
|
// ── 4. Level bank lint ───────────────────────────────────────────────────────
|
|
console.log('\nLevel bank:');
|
|
{
|
|
const bank = JSON.parse(readFileSync(join(__dirname, '../../public/data/blockfighter.json'), 'utf8'));
|
|
const roster = JSON.parse(readFileSync(join(__dirname, '../../public/data/opponents.json'), 'utf8'));
|
|
const ids = new Set((roster.opponents ?? roster).map((o) => o.id));
|
|
const levels = bank.levels ?? [];
|
|
check('bank has levels', levels.length > 0);
|
|
let ok = true;
|
|
levels.forEach((lv, i) => {
|
|
if (lv.level !== i + 1) ok = false;
|
|
if (!(lv.skill >= 1 && lv.skill <= 10)) ok = false;
|
|
if (!(lv.speed >= 1 && lv.speed <= 5)) ok = false;
|
|
if (lv.dropPattern && !(Array.isArray(lv.dropPattern) && lv.dropPattern.length === 4
|
|
&& lv.dropPattern.every((row) => /^[RGBY]{6}$/.test(row)))) ok = false;
|
|
if (!ids.has(lv.opponentId)) console.warn(` warn: level ${lv.level} opponent '${lv.opponentId}' not in roster`);
|
|
});
|
|
check('levels contiguous with valid skill/speed/dropPattern', ok);
|
|
}
|
|
|
|
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.');
|
|
process.exit(failures ? 1 : 0);
|