fertig-classic-games/tools/verifyExcitebike.js

853 lines
38 KiB
JavaScript

// Excitebike verification harness.
//
// node tools/verifyExcitebike.js
// node tools/verifyExcitebike.js --seeds=40 # deeper rival soak
//
// Sections, cheapest first:
// 1 NES hardware invariants — palette, tiles, sprites, font
// 2 The hurdle catalogue A-S
// 3 Terrain compilation and the shipped track bank
// 4 Physics invariants over a long run
// 5 Determinism
// 6 The difficulty gate: the reference rider qualifies, a naive one does not
// 7 Rival soak
// 8 Raster output, through the headless canvas stub
// 9 DESIGN mode round-trip
//
// Exits non-zero on any failure.
import './lib/canvasStub.js';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
NES_PALETTE, PALETTE_SIZE, nesColor, subpalette, SUBPALETTE_SLOTS, TILE,
SCREEN_W, SCREEN_H, PLAYFIELD_H, LANE_H, LANES_Y, INFIELD_Y, LANE_COUNT as NES_LANES,
gridSlots, fitsSubpalette,
} from '../src/games/excitebike/ExcitebikeNES.js';
import {
TILES, TILE_NAMES, THEMES, THEME_IDS, FONT_CHARS, buildFontGrid,
buildBikeFrames, buildBikeFrame, buildTumbleFrame, buildRunFrame, buildDustFrame,
buildDownedBike, packStrip, pitchFrameIndex, pitchFrameAngle, bikeFrame,
BIKE_FRAME, BIKE_FRAME_COUNT, PITCH_FRAMES, PITCH_MAX, TREAD_FRAMES,
TUMBLE_FRAMES, RUN_FRAMES, DUST_FRAMES, S,
} from '../src/games/excitebike/ExcitebikeArt.js';
import {
HURDLES, HURDLE_IDS, SURFACE, LANE_COUNT, MAX_HURDLES, MAX_LAPS,
START_PAD, FINISH_PAD, buildTrackModel, validateTrack, bestWallMs,
groundAt, surfaceAt, slopeAt, isLaunchEdge, curvatureAt,
} from '../src/games/excitebike/ExcitebikeTrack.js';
import {
createRace, step, neutralInput, finalizeRace, formatTime, standings,
STATE, MODE, STEP_MS, TUNE, mulberry32,
} from '../src/games/excitebike/ExcitebikeLogic.js';
import { runAuto, probeTrack, AUTO_SKILL } from '../src/games/excitebike/ExcitebikeAuto.js';
import { rasterizeTrack, gridToCanvas, buildHudCanvas, buildFontCanvas } from '../src/games/excitebike/ExcitebikeRaster.js';
import { canvasPixels } from './lib/canvasStub.js';
import { blankTrack, TOOLS, DESIGN_LENGTH } from '../src/games/excitebike/ExcitebikeDesignData.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const GAMEDATA = join(ROOT, 'assets', 'gamedata', 'excitebike');
const args = process.argv.slice(2);
const SEEDS = Number((args.find((a) => a.startsWith('--seeds=')) ?? '--seeds=12').slice(8));
let checks = 0;
let failures = 0;
function check(name, cond, detail = '') {
checks += 1;
if (cond) return;
failures += 1;
console.error(`FAIL ${name}${detail ? `${detail}` : ''}`);
}
function section(title) {
console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 62 - title.length))}`);
}
// ---------------------------------------------------------------------------
// 1. NES hardware invariants
// ---------------------------------------------------------------------------
section('1. NES hardware');
check('master palette is 64 entries', PALETTE_SIZE === 64, `got ${PALETTE_SIZE}`);
check('every palette entry is a 24-bit colour',
NES_PALETTE.every((c) => Number.isInteger(c) && c >= 0 && c <= 0xffffff));
check('nesColor rejects an out-of-range index', (() => {
try { nesColor(64); return false; } catch (_) { return true; }
})());
const PALETTE_SET = new Set(NES_PALETTE);
check('subpalette() demands exactly four entries', (() => {
try { subpalette(0, 1, 2); return false; } catch (_) { return true; }
})());
// Background tiles must never draw with slot 0 — that slot is the shared
// backdrop, and a tile that relies on it punches a hole in the track.
for (const name of TILE_NAMES) {
const t = TILES[name];
check(`tile ${name} is ${TILE}x${TILE}`, t.w === TILE && t.h === TILE);
check(`tile ${name} stays inside one subpalette`, fitsSubpalette(t));
check(`tile ${name} does not draw with the backdrop slot`, !gridSlots(t).has(S.CLEAR),
'background tiles must use slots 1-3 only');
}
for (const id of THEME_IDS) {
const theme = THEMES[id];
check(`theme ${id} has 4 background subpalettes`, theme.bg.length === 4);
check(`theme ${id} has 4 sprite subpalettes`, theme.sprites.length === 4);
for (const sp of [...theme.bg, ...theme.sprites]) {
check(`theme ${id} subpalette is ${SUBPALETTE_SLOTS} entries`, sp.length === SUBPALETTE_SLOTS);
check(`theme ${id} subpalette indices are valid`, sp.every((i) => i >= 0 && i < PALETTE_SIZE));
}
// Three drawable colours that are actually distinguishable; two identical
// entries would silently waste a third of every sprite's palette.
for (const sp of theme.sprites) {
check(`theme ${id} sprite colours are distinct`,
new Set(sp.slice(1)).size === 3, `${sp}`);
}
}
const font = buildFontGrid();
check('font has a glyph for every declared character',
font.grid.w === font.cols * font.cell, `${font.grid.w}`);
check('font characters are unique', new Set(FONT_CHARS).size === FONT_CHARS.length);
check('font covers digits and A-Z',
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').every((c) => FONT_CHARS.includes(c)));
check('font stays inside one subpalette', fitsSubpalette(font.grid));
const bikeFrames = buildBikeFrames();
check('bike sheet has pitch x tread frames', bikeFrames.length === BIKE_FRAME_COUNT);
check('bike frames are square and tile-aligned',
bikeFrames.every((f) => f.w === BIKE_FRAME && f.h === BIKE_FRAME && f.w % TILE === 0));
check('every bike frame stays inside one subpalette', bikeFrames.every(fitsSubpalette));
check('every bike frame draws something', bikeFrames.every((f) => gridSlots(f).size > 1));
// A pitch frame must actually depict its angle: the extremes have to differ.
const level = buildBikeFrame(0, 0);
const noseUp = buildBikeFrame(PITCH_MAX, 0);
const noseDown = buildBikeFrame(-PITCH_MAX, 0);
const differs = (a, b) => a.data.some((v, i) => v !== b.data[i]);
check('nose-up is drawn differently from level', differs(level, noseUp));
check('nose-down is drawn differently from level', differs(level, noseDown));
check('nose-up and nose-down are drawn differently', differs(noseUp, noseDown));
// And the wheels must visibly turn, or the bike looks like it is sliding.
check('tread phases differ', differs(buildBikeFrame(0, 0), buildBikeFrame(0, 1)));
check('pitchFrameIndex clamps below', pitchFrameIndex(-99) === 0);
check('pitchFrameIndex clamps above', pitchFrameIndex(99) === PITCH_FRAMES - 1);
check('pitchFrameIndex is centred on level', pitchFrameIndex(0) === (PITCH_FRAMES - 1) / 2);
check('pitchFrameAngle round-trips', Math.abs(pitchFrameAngle(pitchFrameIndex(0.3)) - 0.3) < 0.05);
check('bikeFrame indexes inside the sheet',
bikeFrame(PITCH_FRAMES - 1, TREAD_FRAMES - 1) === BIKE_FRAME_COUNT - 1);
for (let i = 0; i < TUMBLE_FRAMES; i += 1) {
check(`tumble frame ${i} stays inside one subpalette`, fitsSubpalette(buildTumbleFrame(i)));
}
for (let i = 0; i < RUN_FRAMES; i += 1) {
check(`run frame ${i} stays inside one subpalette`, fitsSubpalette(buildRunFrame(i)));
}
for (let i = 0; i < DUST_FRAMES; i += 1) {
check(`dust frame ${i} stays inside one subpalette`, fitsSubpalette(buildDustFrame(i)));
}
check('run cycle animates', differs(buildRunFrame(0), buildRunFrame(1)));
check('tumble cycle animates', differs(buildTumbleFrame(0), buildTumbleFrame(2)));
check('downed bike draws something', gridSlots(buildDownedBike()).size > 1);
const strip = packStrip([buildDustFrame(0), buildDustFrame(1)]);
check('packStrip lays frames out in a row', strip.grid.w === strip.frameWidth * 2);
check('packStrip rejects mismatched frames', (() => {
try { packStrip([buildDustFrame(0), buildRunFrame(0)]); return false; } catch (_) { return true; }
})());
// Screen geometry has to add up, or the HUD and the track overlap.
check('playfield plus HUD is one NES frame', PLAYFIELD_H < SCREEN_H && SCREEN_W === 256);
check('four lanes fit the playfield', LANES_Y + LANE_H * NES_LANES <= PLAYFIELD_H);
// The playfield's bands sit on the tile lattice. Individual lanes deliberately
// do not: the original's lanes are 12px, a tile and a half, so a lane divider
// lands halfway down a tile. That is the real geometry, not a rounding slip.
check('playfield bands are tile-aligned',
LANES_Y % TILE === 0 && (LANE_H * NES_LANES) % TILE === 0 && PLAYFIELD_H % TILE === 0);
check('a lane is a tile and a half deep, as on the original', LANE_H === 12);
// ---------------------------------------------------------------------------
// 2. Hurdle catalogue
// ---------------------------------------------------------------------------
section('2. Hurdle catalogue');
const EXPECTED_IDS = 'ABCDEFGHIJKLMNOPQRS'.split('');
check('all 19 hurdles A-S are present', HURDLE_IDS.length === 19, `${HURDLE_IDS.length}`);
check('hurdle ids are the manual\'s letters, in order',
HURDLE_IDS.join('') === EXPECTED_IDS.join(''), HURDLE_IDS.join(''));
for (const id of HURDLE_IDS) {
const h = HURDLES[id];
check(`hurdle ${id} has a name`, typeof h.name === 'string' && h.name.length > 0);
check(`hurdle ${id} has a positive footprint`, h.profile.length > 0);
check(`hurdle ${id} occupies real lanes`,
h.lanes.length > 0 && h.lanes.every((l) => l >= 0 && l < LANE_COUNT));
// Every profile must start and end at a height the rest of the track can meet.
check(`hurdle ${id} starts at ground level`, Math.abs(h.profile.at(0)) < 0.001);
let maxH = 0;
for (let u = 0; u < h.profile.length; u += 1) maxH = Math.max(maxH, h.profile.at(u));
// A hurdle stands up out of the lane band into the infield. It must not reach
// the stadium wall, or it would be drawn over the BEST time.
check(`hurdle ${id} stays below the stadium wall`, maxH <= LANES_Y - INFIELD_Y,
`peak ${maxH.toFixed(1)}`);
}
// The manual's far/near pairs must genuinely mirror each other.
for (const [far, near] of [['F', 'G'], ['I', 'J'], ['K', 'L'], ['M', 'N'], ['P', 'O']]) {
check(`${far}/${near} are a far/near pair`,
HURDLES[far].lanes.join() !== HURDLES[near].lanes.join()
&& HURDLES[far].profile.length === HURDLES[near].profile.length);
}
check('Q spans every lane', HURDLES.Q.lanes.length === LANE_COUNT);
check('Q is a gap', HURDLES.Q.surface === SURFACE.GAP);
check('M and N are cool zones',
HURDLES.M.surface === SURFACE.COOL && HURDLES.N.surface === SURFACE.COOL);
check('K and L are mud', HURDLES.K.surface === SURFACE.MUD && HURDLES.L.surface === SURFACE.MUD);
check('I and J are solid obstacles',
HURDLES.I.surface === SURFACE.OBSTACLE && HURDLES.J.surface === SURFACE.OBSTACLE);
// The original's courses are built almost entirely from hills you ride up and
// over, not ramps with a cliff at the end — only E and S finish high. So the
// catalogue has to be shaped that way, and a hill has to launch you by its
// crest rather than by an edge.
const peakOf = (id) => {
let p = 0;
const h = HURDLES[id];
for (let u = 0; u < h.profile.length; u += 1) p = Math.max(p, h.profile.at(u));
return p;
};
const endsHigh = (id) => HURDLES[id].profile.at(HURDLES[id].profile.length - 1) > peakOf(id) * 0.5;
for (const id of ['E', 'S']) {
check(`${id} ends high, so its far edge is a cliff`, endsHigh(id));
}
for (const id of ['A', 'B', 'C', 'D', 'F', 'G', 'H', 'R']) {
check(`${id} is a hill that comes back down`, !endsHigh(id),
`ends at ${HURDLES[id].profile.at(HURDLES[id].profile.length - 1).toFixed(1)}`);
}
check('the hills rise in size A < B < C < H < R',
peakOf('A') < peakOf('B') && peakOf('B') < peakOf('C')
&& peakOf('C') < peakOf('H') && peakOf('H') < peakOf('R'),
[peakOf('A'), peakOf('B'), peakOf('C'), peakOf('H'), peakOf('R')].join('<'));
// The crest rule, pinned directly: a hill throws you off it at speed and does
// not at a crawl. Lose this and every course in the bank becomes a flat road.
{
const hill = buildTrackModel({
id: 'hill', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'H', x: 400 }],
});
const crest = 400 + Math.round(HURDLES.H.profile.length / 2);
const curve = curvatureAt(hill, 0, crest);
check('a hill is convex at its crest', curve < 0, `${curve}`);
check('a hill launches at racing speed', TUNE.speedTurbo ** 2 * -curve > TUNE.gravity);
check('a hill does not launch at walking pace', 30 ** 2 * -curve < TUNE.gravity);
check('flat ground is never convex', Math.abs(curvatureAt(hill, 0, 200)) < 1e-6);
}
// ---------------------------------------------------------------------------
// 3. Terrain compiler and the shipped bank
// ---------------------------------------------------------------------------
section('3. Terrain and the track bank');
const indexPath = join(GAMEDATA, 'tracks.json');
if (!existsSync(indexPath)) {
console.error('FAIL the track bank is missing');
console.error(' run: node tools/genExcitebikeTracks.js');
process.exit(1);
}
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
check('the bank holds ten tracks', index.tracks.length === 10, `${index.tracks.length}`);
// Course lengths as measured off the NES track maps by tools/readExcitebikeMaps.js.
// These are NOT the map images' widths — each image carries a legend panel on
// the left that is not track, so the course is shorter than the file.
const ORIGINAL_LENGTHS = { 1: 5888, 2: 5393, 3: 6416, 4: 6528, 5: 5752 };
const bank = [];
for (const entry of index.tracks) {
const file = join(GAMEDATA, entry.file);
check(`${entry.id} exists on disk`, existsSync(file));
if (!existsSync(file)) continue;
const json = JSON.parse(readFileSync(file, 'utf8'));
const model = buildTrackModel(json);
const errors = validateTrack(model, json);
check(`${entry.id} validates`, errors.length === 0, errors[0]);
check(`${entry.id} names a real theme`, THEME_IDS.includes(json.theme), json.theme);
check(`${entry.id} laps are in range`, json.laps >= 1 && json.laps <= MAX_LAPS);
check(`${entry.id} stays under the hurdle cap`, json.hurdles.length <= MAX_HURDLES);
check(`${entry.id} wall time is inside the qualifier`, bestWallMs(model) < model.qualifyMs);
bank.push({ entry, json, model });
}
// The five recreations must keep the original courses' lengths.
for (const [n, len] of Object.entries(ORIGINAL_LENGTHS)) {
const t = bank.find((b) => b.entry.n === Number(n));
check(`track ${n} keeps the NES course length`, t && t.json.length === len,
t ? `${t.json.length} != ${len}` : 'missing');
}
// Terrain sampling has to be sane everywhere, including across the lap seam.
for (const { entry, model } of bank) {
let badHeight = 0;
let badSlope = 0;
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (let x = 0; x < model.length; x += 7) {
// Terrain stands up out of the lane band into the infield. Anything that
// reached the stadium wall would be drawn over the BEST time.
const h = groundAt(model, lane, x);
if (!Number.isFinite(h) || h < -0.001 || h > LANES_Y - INFIELD_Y) badHeight += 1;
const s = slopeAt(model, lane, x);
if (!Number.isFinite(s)) badSlope += 1;
}
}
check(`${entry.id} heights are finite and bounded`, badHeight === 0, `${badHeight} samples`);
check(`${entry.id} slopes are finite`, badSlope === 0, `${badSlope} samples`);
check(`${entry.id} wraps at the lap seam`,
groundAt(model, 0, model.length) === groundAt(model, 0, 0));
check(`${entry.id} negative x wraps too`,
groundAt(model, 0, -1) === groundAt(model, 0, model.length - 1));
// Every course has to give the rider a way to manage heat.
let coolPx = 0;
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (let x = 0; x < model.length; x += 1) {
if (model.surface[lane][x] === SURFACE.COOL) coolPx += 1;
}
}
check(`${entry.id} has cool zones to manage heat with`, coolPx > 0);
}
// The compiler itself: overlapping hurdles add, gaps beat everything.
{
const stacked = buildTrackModel({
id: 'stack', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'A', x: 400 }, { t: 'A', x: 400 }],
});
const single = buildTrackModel({
id: 'single', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'A', x: 400 }],
});
check('stacked hurdles add their heights',
Math.abs(groundAt(stacked, 0, 420) - 2 * groundAt(single, 0, 420)) < 0.01);
const paved = buildTrackModel({
id: 'paved', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'Q', x: 500 }, { t: 'K', x: 500 }],
});
check('a gap cannot be paved over', surfaceAt(paved, 0, 510) === SURFACE.GAP);
const cliff = buildTrackModel({
id: 'cliff', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'E', x: 400 }],
});
const edge = 400 + HURDLES.E.profile.length - 1;
check('a cliff-ended ramp reads as a launch edge', isLaunchEdge(cliff, 0, edge));
check('flat ground does not read as a launch edge', !isLaunchEdge(cliff, 0, 200));
}
// Validation must reject the things it exists to reject.
{
const bad = (json) => validateTrack(buildTrackModel(json), json).length > 0;
const base = { id: 'x', length: 3000, laps: 1, mainLaps: 1, qualifyMs: 30000, hurdles: [] };
check('a hurdle in the start run-up is rejected', bad({ ...base, hurdles: [{ t: 'A', x: 10 }] }));
check('a hurdle past the finish run-out is rejected',
bad({ ...base, hurdles: [{ t: 'A', x: 2990 }] }));
check('an unknown hurdle letter is rejected', bad({ ...base, hurdles: [{ t: 'Z', x: 500 }] }));
check('an unjumpable full-width gap is rejected',
bad({ ...base, hurdles: [{ t: 'Q', x: 900 }] }));
check('a full-width gap with a launcher in front of it is accepted',
!bad({ ...base, hurdles: [{ t: 'E', x: 885 }, { t: 'Q', x: 900 }] }));
check('too many laps is rejected', bad({ ...base, laps: MAX_LAPS + 1 }));
check('an empty flat track is accepted', !bad(base));
}
// ---------------------------------------------------------------------------
// 4. Physics invariants
// ---------------------------------------------------------------------------
section('4. Physics invariants');
{
const { model } = bank[Math.min(3, bank.length - 1)];
const rng = mulberry32(1234);
const state = createRace({ model, mode: MODE.RACE, rivalCount: 5, seed: 99 });
let nan = 0;
let overSpeed = 0;
let badTemp = 0;
let badLane = 0;
let sunk = 0;
const seenStates = new Set();
for (let f = 0; f < 60 * 240; f += 1) {
// Random inputs, deliberately: the sim has to survive a mashing monkey,
// not just a rider who does sensible things.
const inp = {
a: rng() < 0.85, b: rng() < 0.5,
up: rng() < 0.08, down: rng() < 0.08,
left: rng() < 0.15, right: rng() < 0.15,
};
step(state, inp);
for (const b of state.bikes) {
seenStates.add(b.state);
if (![b.x, b.vx, b.y, b.vy, b.lane, b.pitch, b.temp].every(Number.isFinite)) nan += 1;
if (b.vx > TUNE.speedTurbo + 1 || b.vx < -0.001) overSpeed += 1;
if (b.temp < -0.001 || b.temp > 1.001) badTemp += 1;
if (b.lane < -0.001 || b.lane > LANE_COUNT - 1 + 0.001) badLane += 1;
if (b.state === STATE.RIDING && b.y < -0.5) sunk += 1;
}
if (state.phase === STATE.FINISHED) break;
}
check('nothing ever goes NaN', nan === 0, `${nan} samples`);
check('speed never exceeds the turbo cap', overSpeed === 0, `${overSpeed} samples`);
check('temperature stays in 0..1', badTemp === 0, `${badTemp} samples`);
check('bikes stay on the four lanes', badLane === 0, `${badLane} samples`);
check('a riding bike never sinks below the ground', sunk === 0, `${sunk} samples`);
check('random play reaches the airborne state', seenStates.has(STATE.AIRBORNE));
check('random play reaches the crashed state', seenStates.has(STATE.CRASHED));
}
// A crash must always resolve — tumble, then run, then back on the bike.
{
const model = buildTrackModel({
id: 'crashy', length: 3000, laps: 1, mainLaps: 1, qualifyMs: 60000,
hurdles: [{ t: 'J', x: 600 }],
});
const state = createRace({ model, mode: MODE.SOLO, seed: 4 });
const inp = neutralInput();
inp.a = true;
let crashed = false;
let recovered = false;
let ranOnFoot = false;
for (let f = 0; f < 60 * 120; f += 1) {
// Hold A the whole way: the mash bonus is optional, recovery is not.
step(state, inp);
if (state.player.state === STATE.CRASHED) crashed = true;
if (state.player.state === STATE.RUNNING) ranOnFoot = true;
if (crashed && state.player.state === STATE.RIDING) { recovered = true; break; }
}
check('riding into an obstacle crashes you', crashed);
check('a crash puts the rider on foot', ranOnFoot);
check('a crash always resolves back to riding', recovered);
}
// Overheating must stall you, and cool zones must be the answer.
{
const hot = buildTrackModel({
id: 'hot', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 60000, hurdles: [],
});
const state = createRace({ model: hot, mode: MODE.SOLO, seed: 2 });
const full = { ...neutralInput(), a: true, b: true };
let overheated = false;
for (let f = 0; f < 60 * 30; f += 1) {
step(state, full);
if (state.player.state === STATE.OVERHEATED) { overheated = true; break; }
}
check('unbroken turbo overheats the engine', overheated);
const cool = buildTrackModel({
id: 'cool', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 60000,
hurdles: [{ t: 'M', x: 200 }, { t: 'N', x: 200 }],
});
const c = createRace({ model: cool, mode: MODE.SOLO, seed: 2 });
c.player.temp = 0.9;
c.phase = STATE.RIDING;
for (const b of c.bikes) b.state = STATE.RIDING;
c.player.x = 210;
const before = c.player.temp;
for (let f = 0; f < 20; f += 1) step(c, { ...neutralInput(), a: true });
check('a cool zone drops engine temperature', c.player.temp < before,
`${before.toFixed(2)} -> ${c.player.temp.toFixed(2)}`);
}
// The landing-angle rule is the heart of the game, so pin it directly.
{
const ramp = buildTrackModel({
id: 'ramp', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 60000,
hurdles: [{ t: 'H', x: 400 }],
});
const fly = (holdLeft, holdRight) => {
const state = createRace({ model: ramp, mode: MODE.SOLO, seed: 8 });
state.phase = STATE.RIDING;
for (const b of state.bikes) b.state = STATE.RIDING;
state.player.x = 300;
let sawAir = false;
let outcome = null;
for (let f = 0; f < 60 * 30 && !outcome; f += 1) {
const inp = { ...neutralInput(), a: true, b: true, left: holdLeft, right: holdRight };
for (const ev of step(state, inp)) {
if (ev.type === 'launch') sawAir = true;
if (sawAir && ev.type === 'land') outcome = ev.quality;
if (sawAir && ev.type === 'crash') outcome = 'crash';
}
}
return { sawAir, outcome };
};
const neutral = fly(false, false);
check('a jumping ramp launches the bike', neutral.sawAir);
const pinnedUp = fly(true, false);
check('holding the nose up all the way down ends badly',
pinnedUp.outcome === 'crash' || pinnedUp.outcome === 'hard',
`got ${pinnedUp.outcome}`);
check('an uncontrolled landing is not a clean one',
neutral.outcome !== null, `got ${neutral.outcome}`);
}
// Contact: catching a leader from behind at speed puts YOU down.
{
const flat = buildTrackModel({
id: 'flat', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 60000, hurdles: [],
});
const state = createRace({ model: flat, mode: MODE.RACE, rivalCount: 1, seed: 6 });
state.phase = STATE.RIDING;
for (const b of state.bikes) b.state = STATE.RIDING;
const [me, rival] = state.bikes;
// I am ahead and slow; the rival is behind me and closing hard.
me.x = 480; me.lane = 1; me.vx = 40;
rival.x = 470; rival.lane = 1; rival.vx = 210;
let victim = null;
for (const ev of step(state, { ...neutralInput(), a: true })) {
if (ev.type === 'knockdown') victim = ev.bike;
}
check('the bike closing from behind is the one that goes down',
victim === rival.index, `victim ${victim}`);
const state2 = createRace({ model: flat, mode: MODE.RACE, rivalCount: 1, seed: 6 });
state2.phase = STATE.RIDING;
for (const b of state2.bikes) b.state = STATE.RIDING;
const [a2, b2] = state2.bikes;
a2.x = 480; a2.lane = 1; a2.vx = 150;
b2.x = 490; b2.lane = 1; b2.vx = 150;
let anyDown = false;
for (const ev of step(state2, { ...neutralInput(), a: true })) {
if (ev.type === 'knockdown') anyDown = true;
}
check('running nose-to-tail at the same pace does not wipe you out', !anyDown);
}
// ---------------------------------------------------------------------------
// 5. Determinism
// ---------------------------------------------------------------------------
section('5. Determinism');
const raceFingerprint = (run) => run.order.map((b) => `${b.index}:${b.finishMs ?? Math.round(b.x)}`).join('|');
for (const { entry, model } of bank.slice(0, 3)) {
const a = runAuto(model, { mode: MODE.RACE, seed: 21 });
const b = runAuto(model, { mode: MODE.RACE, seed: 21 });
check(`${entry.id} replays identically from the same seed`,
raceFingerprint(a) === raceFingerprint(b), `${a.ms} vs ${b.ms}`);
// A different seed has to change the race. It need not change the player's
// own time — a clean run through untouched traffic is legitimately identical
// — so compare the whole field, which is what the seed actually drives.
const c = runAuto(model, { mode: MODE.RACE, seed: 22 });
check(`${entry.id} seeds a different field`, raceFingerprint(c) !== raceFingerprint(a));
const soloRun = (seed) => {
const run = runAuto(model, { mode: MODE.SOLO, skill: AUTO_SKILL.human, seed });
return `${run.ms}|${JSON.stringify(run.events)}`;
};
check(`${entry.id} a jittered rider replays identically from the same seed`,
soloRun(21) === soloRun(21));
// Finish times are quantised to a frame, so two seeds can legitimately tie.
// What must not happen is every seed producing the same ride.
const rides = new Set([21, 22, 23, 24].map(soloRun));
check(`${entry.id} a jittered rider rides differently across seeds`, rides.size > 1,
`${rides.size} distinct rides from 4 seeds`);
}
// ---------------------------------------------------------------------------
// 6. The difficulty gate
// ---------------------------------------------------------------------------
section('6. Difficulty gate');
const gate = [];
for (const { entry, model } of bank) {
const expert = runAuto(model, { skill: AUTO_SKILL.expert, seed: 9 });
const naive = runAuto(model, { skill: AUTO_SKILL.naive, seed: 9 });
const probe = probeTrack(model);
check(`${entry.id}: the reference rider finishes`, expert.finished && !expert.timedOut);
check(`${entry.id}: the reference rider qualifies`, expert.qualified,
`${formatTime(expert.ms)} vs target ${formatTime(model.qualifyMs)}`);
check(`${entry.id}: the target leaves the expert a real margin`,
expert.ms < model.qualifyMs * 0.95, `${formatTime(expert.ms)}`);
// The target is priced off a fallible rider, so that rider has to be able to
// make it — on most seeds, not just a lucky one.
check(`${entry.id}: a fallible rider finishes every attempt`,
probe.finishedCount === probe.runs.length, `${probe.finishedCount}/${probe.runs.length}`);
const madeIt = probe.runs.filter((r) => r.finished && r.ms <= model.qualifyMs).length;
check(`${entry.id}: a fallible rider qualifies most attempts`,
madeIt >= Math.ceil(probe.runs.length / 2), `${madeIt}/${probe.runs.length}`);
// Tracks 4 and up must ask something of the player. Holding the throttle down
// and steering at nothing has to fail there, or the mechanics are decoration.
if (entry.n >= 4) {
check(`${entry.id}: a naive rider fails to qualify`, !naive.qualified,
naive.finished ? formatTime(naive.ms) : 'DNF');
}
gate.push({ n: entry.n, id: entry.id, expert, naive, probe, model });
}
// Difficulty has to rise across the bank. The expert is a poor yardstick for
// this — with perfect information it is barely slowed by hazards at all — so
// difficulty is measured two ways that do respond to it: how much raw terrain
// punishes a rider who does not manage it, and how thickly hurdles are laid.
{
const half = (pred, f) => {
const rows = gate.filter(pred);
return rows.reduce((s, g) => s + f(g), 0) / rows.length;
};
const early = (g) => g.n <= 5;
const late = (g) => g.n >= 6;
const naiveEarly = half(early, (g) => g.naive.ms / g.model.length);
const naiveLate = half(late, (g) => g.naive.ms / g.model.length);
check('the back half of the bank punishes an unmanaged rider harder',
naiveLate > naiveEarly,
`${(naiveEarly * 1000).toFixed(2)} -> ${(naiveLate * 1000).toFixed(2)} ms/px`);
const densEarly = half(early, (g) => (g.model.hurdles.length / g.model.length) * 1000);
const densLate = half(late, (g) => (g.model.hurdles.length / g.model.length) * 1000);
check('the back half of the bank is laid out more densely', densLate > densEarly,
`${densEarly.toFixed(2)} -> ${densLate.toFixed(2)} hurdles/kpx`);
check('the hardest track asks more than the first',
gate[gate.length - 1].naive.ms / gate[gate.length - 1].model.length
> gate[0].naive.ms / gate[0].model.length);
}
// ---------------------------------------------------------------------------
// 7. Rival soak
// ---------------------------------------------------------------------------
section('7. Rival soak');
// SELECTION B advances you on third or better, so what matters is not one
// win rate but the gradient across ability. Riding well has to be rewarded and
// riding badly has to be punished, with the boundary somewhere a player can
// move across by getting better. These bands are the regression guard: a change
// that makes the pack trivial or impossible breaks one end of them.
const SOAK_BANDS = {
expert: { wins: [0.50, 1.00], podium: [0.85, 1.00] },
human: { wins: [0.00, 0.35], podium: [0.20, 0.70] },
steady: { wins: [0.00, 0.20], podium: [0.00, 0.35] },
naive: { wins: [0.00, 0.02], podium: [0.00, 0.10] },
};
{
let stalled = 0;
let offTrack = 0;
let neverFinished = 0;
let races = 0;
for (const name of Object.keys(SOAK_BANDS)) {
let wins = 0;
let podiums = 0;
let n = 0;
for (const { model } of bank) {
for (let s = 0; s < SEEDS; s += 1) {
const run = runAuto(model, {
mode: MODE.RACE, seed: 1000 + s * 7, rivalCount: 5, skill: AUTO_SKILL[name],
});
n += 1;
races += 1;
if (!run.finished) neverFinished += 1;
if (run.place <= 1) wins += 1;
if (run.place <= 3) podiums += 1;
for (const b of run.state.bikes) {
if (b.lane < -0.01 || b.lane > LANE_COUNT - 1 + 0.01) offTrack += 1;
// A rival that covered almost no ground is stuck, not merely slow.
if (!b.isPlayer && b.x < model.length * 0.25) stalled += 1;
}
}
}
const band = SOAK_BANDS[name];
const winRate = wins / n;
const podRate = podiums / n;
check(`${name}: win rate inside its band`,
winRate >= band.wins[0] && winRate <= band.wins[1],
`${(winRate * 100).toFixed(0)}% outside ${band.wins.map((v) => `${v * 100}%`).join('-')}`);
check(`${name}: podium rate inside its band`,
podRate >= band.podium[0] && podRate <= band.podium[1],
`${(podRate * 100).toFixed(0)}% outside ${band.podium.map((v) => `${v * 100}%`).join('-')}`);
console.log(` ${name.padEnd(7)} ${n} races: won ${String(wins).padStart(3)}`
+ ` (${(winRate * 100).toFixed(0)}%), podium ${String(podiums).padStart(3)} (${(podRate * 100).toFixed(0)}%)`);
}
check('every race reaches a finish', neverFinished === 0, `${neverFinished}/${races} did not`);
check('no rival ever stalls', stalled === 0, `${stalled} stuck`);
check('no bike ever leaves the four lanes', offTrack === 0, `${offTrack} samples`);
}
// Standings must be a total order that agrees with the finish times.
{
const run = runAuto(bank[0].model, { mode: MODE.RACE, seed: 55 });
const order = standings(run.state);
check('standings list every bike once', new Set(order).size === run.state.bikes.length);
let ok = true;
for (let i = 1; i < order.length; i += 1) {
const a = order[i - 1];
const b = order[i];
if (a.finishMs != null && b.finishMs != null && a.finishMs > b.finishMs) ok = false;
if (a.finishMs == null && b.finishMs != null) ok = false;
}
check('standings are ordered by finish time, then by distance', ok);
}
// ---------------------------------------------------------------------------
// 8. Raster output
// ---------------------------------------------------------------------------
section('8. Raster output');
{
const { model } = bank[0];
const canvas = rasterizeTrack(model, model.theme);
check('the track canvas is the length of the track', canvas.width === model.length,
`${canvas.width}`);
check('the track canvas is the height of the playfield', canvas.height === PLAYFIELD_H);
const px = canvasPixels(canvas);
let offPalette = 0;
let transparent = 0;
const used = new Set();
// Sample rather than sweep: 1.2M pixels is a lot to walk twice.
for (let i = 0; i < px.length; i += 4 * 37) {
const rgb = (px[i] << 16) | (px[i + 1] << 8) | px[i + 2];
if (px[i + 3] !== 255) transparent += 1;
if (!PALETTE_SET.has(rgb)) offPalette += 1;
used.add(rgb);
}
check('every track pixel is a NES master-palette colour', offPalette === 0,
`${offPalette} off-palette samples`);
check('the track is fully opaque', transparent === 0, `${transparent} samples`);
check('the track uses a real spread of colours', used.size >= 8, `${used.size}`);
// The lanes must actually differ from the infield, or the art is a flat field.
const rowRgb = (y, x) => {
const i = (y * canvas.width + x) * 4;
return (px[i] << 16) | (px[i + 1] << 8) | px[i + 2];
};
check('the racing surface differs from the crowd band', rowRgb(LANES_Y + 8, 300) !== rowRgb(4, 300));
const hud = buildHudCanvas();
check('the HUD canvas is one NES frame wide', hud.width === SCREEN_W);
const hudPx = canvasPixels(hud);
let hudOff = 0;
for (let i = 0; i < hudPx.length; i += 4 * 13) {
const rgb = (hudPx[i] << 16) | (hudPx[i + 1] << 8) | hudPx[i + 2];
if (hudPx[i + 3] === 255 && !PALETTE_SET.has(rgb)) hudOff += 1;
}
check('every HUD pixel is a NES master-palette colour', hudOff === 0, `${hudOff} samples`);
const fontCanvas = buildFontCanvas();
check('the font atlas is 16 glyphs wide', fontCanvas.canvas.width === 16 * fontCanvas.cell);
const fpx = canvasPixels(fontCanvas.canvas);
let opaqueGlyphPixels = 0;
for (let i = 3; i < fpx.length; i += 4) if (fpx[i] === 255) opaqueGlyphPixels += 1;
check('the font atlas has glyphs drawn in it', opaqueGlyphPixels > 500,
`${opaqueGlyphPixels} lit pixels`);
// Sprites must keep their transparency, or every bike gets a black box.
const sprite = gridToCanvas(buildBikeFrame(0, 0), THEMES.day.sprites[0]);
const spx = canvasPixels(sprite);
let clear = 0;
for (let i = 3; i < spx.length; i += 4) if (spx[i] === 0) clear += 1;
check('a bike sprite is mostly transparent', clear > spx.length / 4 / 2, `${clear} clear px`);
}
// Every theme must rasterise, not just the default one.
for (const id of THEME_IDS) {
const small = buildTrackModel({
id: `theme-${id}`, theme: id, length: 1200, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'C', x: 300 }, { t: 'M', x: 600 }, { t: 'K', x: 800 }],
});
const c = rasterizeTrack(small, id);
const p = canvasPixels(c);
let off = 0;
for (let i = 0; i < p.length; i += 4 * 17) {
const rgb = (p[i] << 16) | (p[i + 1] << 8) | p[i + 2];
if (!PALETTE_SET.has(rgb)) off += 1;
}
check(`theme ${id} rasterises inside the palette`, off === 0, `${off} samples`);
}
// ---------------------------------------------------------------------------
// 9. DESIGN mode
// ---------------------------------------------------------------------------
section('9. DESIGN mode');
{
check('the palette strip is the 19 hurdles plus CL, END and LP',
TOOLS.length === 22 && TOOLS.slice(0, 19).join('') === EXPECTED_IDS.join('')
&& TOOLS.slice(19).join(',') === 'CL,END,LP', TOOLS.join(''));
const blank = blankTrack();
check('a blank design is a legal track', validateTrack(buildTrackModel(blank), blank).length === 0);
check('a blank design is the design-mode length', blank.length === DESIGN_LENGTH);
// A designed track has to survive the same round-trip a save/load does.
const designed = blankTrack();
designed.hurdles = [
{ t: 'A', x: 300 }, { t: 'M', x: 600 }, { t: 'C', x: 900 },
{ t: 'K', x: 1300 }, { t: 'H', x: 1700 }, { t: 'N', x: 2200 },
];
const roundTripped = JSON.parse(JSON.stringify(designed));
const m1 = buildTrackModel(designed);
const m2 = buildTrackModel(roundTripped);
check('a designed track survives a save/load round-trip',
validateTrack(m2, roundTripped).length === 0
&& m1.height[0].every((v, i) => v === m2.height[0][i]));
const run = runAuto(m2, { skill: AUTO_SKILL.expert, seed: 3, maxSeconds: 400 });
check('a designed track can be ridden to the finish', run.finished && !run.timedOut);
// The caps the manual states.
const over = blankTrack();
for (let i = 0; i < MAX_HURDLES + 5; i += 1) over.hurdles.push({ t: 'A', x: 200 + i * 40 });
check('the fifty-hurdle cap is enforced',
validateTrack(buildTrackModel(over), over).some((e) => e.includes('cap')));
check('the lap ceiling is nine', MAX_LAPS === 9);
}
// ---------------------------------------------------------------------------
section('Summary');
console.log(' track len hurdles expert human target naive crash/kpx');
for (const g of gate) {
const s = (ms) => (ms == null ? ' DNF' : `${(ms / 1000).toFixed(1)}s`.padStart(7));
console.log(
` ${g.id} ${String(g.model.length).padStart(5)} ${String(g.model.hurdles.length).padStart(7)}`
+ ` ${s(g.expert.ms)} ${s(g.probe.medianMs)} ${s(g.model.qualifyMs)}`
+ ` ${g.naive.finished ? s(g.naive.ms) : ' DNF'}`
+ ` ${g.probe.crashesPerKpx.toFixed(2).padStart(9)}`,
);
}
if (failures) {
console.error(`\n${failures} FAILED of ${checks} checks`);
process.exit(1);
}
console.log(`\nall ${checks} checks passed`);