fertig-classic-games/tools/verifySuperKart.js

278 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Headless verification for Super Kart. Run: node tools/verifySuperKart.js
//
// Covers: JSON schemas (racers/rules/artwork/cups/tracks), track model
// invariants (checkpoints, grid, surfaces), physics invariants (speed caps,
// NaN, off-road, weight bumping), the position-weighted item roulette, a
// 9-AI soak on every track, and a full 3-class × 3-cup GP soak with a
// determinism check.
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import {
buildTrackModel, validateTrack, surfaceAt, SURFACE,
} from '../src/games/superkart/SuperKartTrack.js';
import {
createRace, step, finalizeRace, kartInputsNeutral, mulberry32, rollItem,
topSpeedOf, offroadMultOf, surfaceMult, pointsFor, COUNTDOWN_MS,
} from '../src/games/superkart/SuperKartLogic.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const GAMEDATA = join(ROOT, 'assets', 'gamedata', 'superkart');
let pass = 0;
let fail = 0;
function check(name, cond, detail = '') {
if (cond) { pass += 1; return; }
fail += 1;
console.error(`FAIL: ${name}${detail ? `${detail}` : ''}`);
}
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
// ── 1. Schemas ──────────────────────────────────────────────────────────────
const racers = readJson(join(ROOT, 'data', 'superkart-racers.json')).racers;
const rules = readJson(join(ROOT, 'data', 'superkart-rules.json'));
const artwork = readJson(join(ROOT, 'data', 'superkart-artwork.json'));
const cups = readJson(join(GAMEDATA, 'cups.json'));
const opponents = readJson(join(ROOT, 'data', 'opponents.json')).opponents;
const opIds = new Set(opponents.map((o) => o.id));
check('9 racers', racers.length === 9, `got ${racers.length}`);
for (const r of racers) {
check(`racer ${r.id} exists in opponents.json`, opIds.has(r.id));
check(`racer ${r.id} has color`, /^#[0-9a-f]{6}$/i.test(r.color ?? ''));
for (const [k, v] of Object.entries(r.stats)) {
check(`racer ${r.id} stat ${k} in 0..1`, v >= 0 && v <= 1, String(v));
}
check(`racer ${r.id} aiSkill in 0..1`, r.aiSkill >= 0 && r.aiSkill <= 1);
check(`racer ${r.id} has artwork sheet entry`, !!artwork.racerSheets?.[r.id]);
}
const statKeys = ['topSpeed', 'accel', 'handling', 'weight', 'offroad', 'drift'];
for (const key of statKeys) {
const vals = racers.map((r) => r.stats[key]);
check(`stat ${key} varies across roster`, Math.max(...vals) - Math.min(...vals) > 0.2);
}
check('3 engine classes', rules.engineClasses.length === 3);
check('classes ordered by speed', rules.engineClasses.every((c, i, a) => i === 0 || c.speedMult > a[i - 1].speedMult));
check('classes ordered by inverse rubber-band strength (cruiser most forgiving)',
rules.engineClasses.every((c, i, a) => i === 0 || c.rubberBandMult < a[i - 1].rubberBandMult));
check('points table has 9 entries', rules.pointsTable.length === 9);
check('points table descending', rules.pointsTable.every((p, i, a) => i === 0 || p <= a[i - 1]));
for (const item of rules.items) {
const row = rules.itemWeights.byPosition[item.id];
check(`item ${item.id} has 9 weight columns`, Array.isArray(row) && row.length === 9);
}
for (let col = 0; col < 9; col += 1) {
const total = rules.items.reduce((sum, it) => sum + (rules.itemWeights.byPosition[it.id]?.[col] ?? 0), 0);
check(`weight column ${col + 1} sums > 0`, total > 0);
}
const physKeys = ['baseTopSpeed', 'baseAccel', 'turnRateBase', 'hopMs', 'offroadMultMin',
'boostPadMult', 'spinMs', 'gridSpacing', 'rouletteMs', 'rubberBandGain', 'rubberBandRange'];
for (const k of physKeys) check(`physics.${k} present`, typeof rules.physics[k] === 'number');
check('3 cups', cups.cups.length === 3);
check('12 tracks in index', cups.tracks.length === 12);
check('cup tracks are 4 each', cups.cups.every((c) => c.tracks.length === 4));
const trackIds = new Set(cups.tracks.map((t) => t.id));
check('track ids unique', trackIds.size === cups.tracks.length);
for (const cup of cups.cups) {
for (const tid of cup.tracks) check(`cup ${cup.id} track ${tid} in index`, trackIds.has(tid));
}
for (const t of cups.tracks) {
check(`track ${t.id} theme defined`, !!rules.themes[t.theme]);
check(`track file ${t.file} exists`, existsSync(join(GAMEDATA, t.file)));
}
// ── 2. Track models ─────────────────────────────────────────────────────────
const models = new Map();
for (const t of cups.tracks) {
const json = readJson(join(GAMEDATA, t.file));
check(`${t.id} id matches file`, json.id === t.id);
const model = buildTrackModel(json);
models.set(t.id, model);
const issues = validateTrack(model);
check(`${t.id} validates`, issues.length === 0, issues.join('; '));
check(`${t.id} lap length sane`, model.totalLength > 5000 && model.totalLength < 16000,
String(Math.round(model.totalLength)));
check(`${t.id} has checkpoints`, model.checkpoints.length >= 60);
const cpS = model.checkpoints.map((cp) => (cp.s - model.startS + model.totalLength) % model.totalLength);
check(`${t.id} checkpoints ordered`, cpS.every((s, i, a) => i === 0 || s > a[i - 1]));
const roadish = [SURFACE.ROAD, SURFACE.CURB, SURFACE.BOOST];
const gridOk = model.gridSlots.filter((g) => roadish.includes(surfaceAt(model, g.x, g.y)));
check(`${t.id} 9 grid slots on road`, gridOk.length === 9, `${gridOk.length}/9`);
const centerOk = model.samples.filter((p) => roadish.includes(surfaceAt(model, p.x, p.y)));
check(`${t.id} centerline ≥95% on road`, centerOk.length / model.samples.length >= 0.95,
`${centerOk.length}/${model.samples.length}`);
const boxOk = model.itemBoxes.filter((b) => roadish.includes(surfaceAt(model, b.x, b.y)));
check(`${t.id} item boxes on road`, boxOk.length === model.itemBoxes.length);
const coinOk = model.coins.filter((c) => roadish.includes(surfaceAt(model, c.x, c.y)));
check(`${t.id} coins on road`, coinOk.length === model.coins.length,
`${coinOk.length}/${model.coins.length}`);
check(`${t.id} has item rows`, model.itemBoxes.length >= 8);
}
// ── 3. Physics invariants ───────────────────────────────────────────────────
const cls = rules.engineClasses[1];
const model1 = models.get('track-001');
// Speed cap + NaN across a full AI race, checked every step.
{
const state = createRace({
trackModel: model1, rules, engineClass: cls, racers, playerIndex: -1, mode: 'gp', seed: 7,
});
const absCap = topSpeedOf({ ...racers.find((r) => r.id === 'gerome').stats, topSpeed: 1 }, rules.physics, cls.speedMult)
* (1 + rules.physics.maxCoins * rules.physics.coinTopSpeedBonus)
* Math.max(rules.physics.boostPadMult, 1.5) * 1.25 + 1;
let capOk = true;
let nanOk = true;
for (let i = 0; i < 60 * 150 && !state.karts.every((k) => k.finished); i += 1) {
step(state, kartInputsNeutral());
for (const k of state.karts) {
if (k.speed > absCap) capOk = false;
if (!Number.isFinite(k.x) || !Number.isFinite(k.y) || !Number.isFinite(k.speed)) nanOk = false;
}
}
check('speed never exceeds absolute cap', capOk);
check('no NaN/Infinity in kart state', nanOk);
}
check('offroad slows a default racer', offroadMultOf(racers[0].stats, rules.physics) < 0.75);
const zan = racers.find((r) => r.id === 'zanthor');
const mario = racers.find((r) => r.id === 'mario');
check('zanthor beats mario off-road',
surfaceMult(SURFACE.OFFROAD, zan.stats, rules.physics) > surfaceMult(SURFACE.OFFROAD, mario.stats, rules.physics));
check('deep is a crawl', surfaceMult(SURFACE.DEEP, mario.stats, rules.physics) <= 0.3);
// Weight bump: heavy kart displaces the light one.
{
const smasher = racers.find((r) => r.id === 'smasher');
const kona = racers.find((r) => r.id === 'kona');
const state = createRace({
trackModel: model1, rules, engineClass: cls, racers: [smasher, kona], playerIndex: -1, mode: 'gp', seed: 3,
});
const [a, b] = state.karts;
a.x = 2000; a.y = 2000;
b.x = 2000 + rules.physics.kartRadius; b.y = 2000; // overlapping
const ax0 = a.x;
const bx0 = b.x;
step(state, kartInputsNeutral());
const aMove = Math.abs(a.x - ax0);
const bMove = Math.abs(b.x - bx0);
check('lighter kart displaced more in a bump', bMove > aMove, `smasher ${aMove.toFixed(1)} vs kona ${bMove.toFixed(1)}`);
}
// pointsFor sanity.
check('winner gets top points', pointsFor(1, rules) === rules.pointsTable[0]);
check('9th gets bottom points', pointsFor(9, rules) === rules.pointsTable[8]);
// ── 4. Item roulette distribution ───────────────────────────────────────────
for (const position of [1, 5, 9]) {
const rng = mulberry32(1234 + position);
const counts = {};
const N = 20000;
for (let i = 0; i < N; i += 1) {
const id = rollItem(rng, position, rules);
counts[id] = (counts[id] ?? 0) + 1;
}
const col = position - 1;
const total = rules.items.reduce((s, it) => s + rules.itemWeights.byPosition[it.id][col], 0);
for (const it of rules.items) {
const w = rules.itemWeights.byPosition[it.id][col];
const expected = (w / total) * N;
const got = counts[it.id] ?? 0;
if (w === 0) check(`P${position} never rolls ${it.id}`, got === 0, String(got));
else if (w >= 5) {
check(`P${position} ${it.id} frequency ≈ weight`, got > expected * 0.85 && got < expected * 1.15,
`expected ~${Math.round(expected)}, got ${got}`);
}
}
}
// ── 5. AI soak on every track ───────────────────────────────────────────────
console.log('AI soak: 9-kart race on all 12 tracks…');
for (const t of cups.tracks) {
const model = models.get(t.id);
const state = createRace({
trackModel: model, rules, engineClass: cls, racers, playerIndex: -1, mode: 'gp', seed: 11,
});
const cap = 60 * 60 * 8; // 8 sim minutes
let ticks = 0;
let permOk = true;
let sawItems = 0;
while (ticks < cap && !state.karts.every((k) => k.finished)) {
step(state, kartInputsNeutral());
ticks += 1;
if (ticks % 600 === 0) {
const positions = new Set(state.karts.map((k) => k.position));
if (positions.size !== 9) permOk = false;
}
for (const ev of state.events) if (ev.type === 'item-use') sawItems += 1;
}
check(`${t.id} all 9 finish`, state.karts.every((k) => k.finished),
`${state.karts.filter((k) => k.finished).length}/9 in ${Math.round(ticks / 60)}s sim`);
check(`${t.id} positions always a permutation`, permOk);
check(`${t.id} AI uses items`, sawItems > 3, String(sawItems));
const worstReverse = Math.max(...state.karts.map((k) => k.reverseCount ?? 0));
check(`${t.id} no chronic stuck loops`, worstReverse <= 15, `worst kart reversed ${worstReverse}x`);
const winner = finalizeRace(state)[0];
check(`${t.id} winner has best lap`, winner.bestLapMs > 5000 && winner.bestLapMs < 120000,
`${Math.round(winner.bestLapMs / 100) / 10}s`);
}
// ── 6. Full GP soak + determinism ───────────────────────────────────────────
console.log('GP soak: 3 classes × 3 cups…');
for (const [ci, engineClass] of rules.engineClasses.entries()) {
for (const cup of cups.cups) {
const points = Object.fromEntries(racers.map((r) => [r.id, 0]));
for (const [ri, tid] of cup.tracks.entries()) {
const state = createRace({
trackModel: models.get(tid), rules, engineClass, racers, playerIndex: -1, mode: 'gp',
seed: 100 + ci * 37 + ri,
});
let ticks = 0;
while (ticks < 60 * 60 * 8 && !state.karts.every((k) => k.finished)) {
step(state, kartInputsNeutral());
ticks += 1;
}
const results = finalizeRace(state);
const sum = results.reduce((s, r) => s + r.points, 0);
const expectedSum = rules.pointsTable.reduce((a, b) => a + b, 0);
check(`${engineClass.id}/${cup.id}/${tid} points sum invariant`, sum === expectedSum, `${sum} vs ${expectedSum}`);
for (const r of results) points[r.racerId] += r.points;
}
const standings = Object.values(points);
check(`${engineClass.id}/${cup.id} standings accumulate`, Math.max(...standings) > 0);
}
}
// Determinism: identical seed → identical outcome.
{
const run = () => {
const state = createRace({
trackModel: model1, rules, engineClass: cls, racers, playerIndex: -1, mode: 'gp', seed: 424242,
});
let ticks = 0;
while (ticks < 60 * 60 * 8 && !state.karts.every((k) => k.finished)) {
step(state, kartInputsNeutral());
ticks += 1;
}
return state.karts.map((k) => `${k.racer.id}:${k.finishTimeMs}`).join('|');
};
check('deterministic for identical seed', run() === run());
}
// Countdown export used by the scene HUD timer.
check('COUNTDOWN_MS sane', COUNTDOWN_MS > 2000 && COUNTDOWN_MS < 6000);
console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);