fertig-classic-games/tools/verifySuperKart.js

382 lines
18 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, normAngle,
} 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',
'checkpointMissSkip', 'checkpointMissMs'];
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)}`);
}
// Missed-checkpoint correction: a player who shortcuts past gates is stopped
// and put back on the last gate they legitimately crossed.
{
const state = createRace({
trackModel: model1, rules, engineClass: cls, racers, playerIndex: 0, mode: 'gp', seed: 11,
});
let missedInCountdown = 0;
while (state.phase === 'countdown') {
step(state, kartInputsNeutral());
missedInCountdown += state.events.filter((e) => e.type === 'checkpoint-miss').length;
}
check('no checkpoint miss on the starting grid / countdown', missedInCountdown === 0);
const player = state.karts[0];
const cpsArr = model1.checkpoints;
const from = player.cpIndex;
const ahead = cpsArr[(from + 6) % cpsArr.length];
player.x = ahead.x;
player.y = ahead.y;
player.splineS = ahead.s; // as if the spline projection snapped across the cut
player.speed = 400;
step(state, kartInputsNeutral());
const ev = state.events.find((e) => e.type === 'checkpoint-miss');
check('shortcutting gates emits checkpoint-miss', !!ev);
check('checkpoint-miss names the first gate skipped', ev?.checkpoint === (from + 1) % cpsArr.length,
`got ${ev?.checkpoint} want ${(from + 1) % cpsArr.length}`);
check('missed checkpoint zeroes the player speed', player.speed === 0, String(player.speed));
const home = cpsArr[from];
check('player moved back to the gate behind the one missed',
Math.hypot(player.x - home.x, player.y - home.y) < 1,
`${Math.hypot(player.x - home.x, player.y - home.y).toFixed(1)} units off`);
check('missed checkpoint starts the blink window', player.cpMissMs === rules.physics.checkpointMissMs);
check('lap chain left intact for re-driving', player.cpIndex === from);
// Settled back in its own bucket, so the correction must not retrigger.
step(state, kartInputsNeutral());
check('checkpoint miss does not retrigger next step',
!state.events.some((e) => e.type === 'checkpoint-miss'));
}
// Clean racing must never trip the miss detector. The detector only arms for
// the player seat, so the player is driven round the centerline by a simple
// lookahead autopilot (the AI's own inputs aren't exported).
{
const autopilot = (model, kart) => {
const idx = Math.round(kart.splineS / model.step) % model.samples.length;
const target = model.samples[(idx + 8) % model.samples.length];
const want = Math.atan2(target.y - kart.y, target.x - kart.x);
return {
steer: Math.max(-1, Math.min(1, 3.4 * normAngle(want - kart.heading))),
accel: true, brake: false, hop: false, item: false,
};
};
let spurious = 0;
let lapsDriven = 0;
for (const tid of ['track-001', 'track-005', 'track-009']) {
const model = models.get(tid);
const state = createRace({
trackModel: model, rules, engineClass: cls, racers, playerIndex: 0, mode: 'gp', seed: 5,
});
const player = state.karts[0];
for (let i = 0; i < 60 * 200 && !player.finished; i += 1) {
step(state, autopilot(model, player));
spurious += state.events.filter((e) => e.type === 'checkpoint-miss').length;
}
lapsDriven += player.lap - 1;
}
check('autopilot actually drove full laps', lapsDriven >= 9, `${lapsDriven} laps`);
check('clean racing never trips the checkpoint-miss detector', spurious === 0, `${spurious} fired`);
}
// 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());
}
// ── 7. Player autopilot after finishing (post-race victory-lap window) ─────
// Nothing above exercises playerIndex >= 0 at all — this is the only check
// that drives the new "AI takes over the player's kart once it's finished"
// dispatch path and the "wait for the whole field" phase-transition change.
{
const state = createRace({
trackModel: model1, rules, engineClass: cls, racers, playerIndex: 0, mode: 'gp', seed: 777,
});
while (state.phase === 'countdown') step(state, kartInputsNeutral());
const player = state.karts[0];
// Simulate the player crossing the line on the spot, mirroring what the
// checkpoint/lap code does at the real finish transition.
player.finished = true;
player.finishTimeMs = state.timeMs;
state.finishOrder.push(player.index);
const x0 = player.x;
const y0 = player.y;
let ticks = 0;
const cap = 60 * 90; // matches POST_FINISH_SAFETY_MS as an outer bound
while (ticks < cap && state.phase === 'racing') {
step(state, kartInputsNeutral());
ticks += 1;
}
const moved = Math.hypot(player.x - x0, player.y - y0);
check('autopiloted player kart keeps driving after finishing', moved > 50, `moved ${moved.toFixed(1)} units`);
check('field is allowed to finish rather than being cut off early',
state.karts.every((k) => k.finished),
`${state.karts.filter((k) => k.finished).length}/9 finished after ${Math.round(ticks / 60)}s, phase=${state.phase}`);
check('race phase ends up finished', state.phase === 'finished', `phase=${state.phase}`);
}
// 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);