// 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()); } // ── 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);