382 lines
18 KiB
JavaScript
382 lines
18 KiB
JavaScript
// 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);
|