// 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, OVERLAY_TILES, OVERLAY_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'); } // Overlay tiles are the mirror image of the rule above: they are drawn over an // already-filled band, so they MUST carry transparency or the scenery paints a // solid block across the infield. for (const name of OVERLAY_TILE_NAMES) { const t = OVERLAY_TILES[name]; check(`overlay ${name} is ${TILE}x${TILE}`, t.w === TILE && t.h === TILE); check(`overlay ${name} stays inside one subpalette`, fitsSubpalette(t)); check(`overlay ${name} carries transparency`, gridSlots(t).has(S.CLEAR), 'an overlay with no clear pixels is a solid block'); check(`overlay ${name} draws something`, gridSlots(t).size > 1); } // The scenery is painted in the grass subpalette's slot 1, which the grass fill // tiles never use — that is what lets a shrub be a different colour from the // field it stands in without breaking the four-colour rule. for (const name of ['grassA', 'grassB']) { check(`${name} leaves slot 1 free for the scenery`, !gridSlots(TILES[name]).has(S.DARK)); } 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); } // Bad ground must be escapable. The bike only slides 12-60px when it goes down // and a hole is 80-440px wide, so a rider remounts still inside it — if arriving // slowly also crashed, that would loop forever and the race would be unfinishable. { const holed = buildTrackModel({ id: 'holed', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 90000, hurdles: [{ t: 'E', x: 700 }, { t: 'Q', x: 800 }], }); const state = createRace({ model: holed, mode: MODE.SOLO, seed: 12 }); state.phase = STATE.RIDING; for (const b of state.bikes) b.state = STATE.RIDING; // Drop the rider straight into the hole, slowly, as if they had just remounted. state.player.x = 820; state.player.vx = TUNE.remountSpeed; let crashes = 0; let escaped = false; let maxInHole = 0; const inp = { ...neutralInput(), a: true }; for (let f = 0; f < 60 * 60; f += 1) { for (const ev of step(state, inp)) if (ev.type === 'crash') crashes += 1; // Speed has to be sampled while still in the hole — once out, the bike is // free to accelerate and says nothing about how it got there. if (surfaceAt(holed, state.player.lane, state.player.x) === SURFACE.GAP) { maxInHole = Math.max(maxInHole, state.player.vx); } if (state.player.x > 800 + HURDLES.Q.profile.length + 40) { escaped = true; break; } } check('a bike sitting in a hole is not thrown off again', crashes === 0, `${crashes} crashes`); check('a bike can crawl out of a hole under its own power', escaped, `stuck at x=${state.player.x.toFixed(0)}`); check('crawling out of a hole is slow', maxInHole <= TUNE.bogSpeed + 1, `reached ${maxInHole.toFixed(0)}`); // ...but arriving at pace must still put you down, or a hole is not a hazard. const fast = createRace({ model: holed, mode: MODE.SOLO, seed: 12 }); fast.phase = STATE.RIDING; for (const b of fast.bikes) b.state = STATE.RIDING; fast.player.x = 790; fast.player.vx = TUNE.speedTurbo; let fastCrash = false; for (let f = 0; f < 60 && !fastCrash; f += 1) { for (const ev of step(fast, inp)) if (ev.type === 'crash') fastCrash = true; } check('riding into a hole at pace still crashes you', fastCrash); check('the crawl speed is safely under the crash speed', TUNE.bogSpeed < TUNE.surfaceCrashSpeed && TUNE.remountSpeed < TUNE.surfaceCrashSpeed); } // 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); check('a jump always resolves into a landing', neutral.outcome !== null, `got ${neutral.outcome}`); // The lopsided landing rule, pinned directly. Rear wheel first is how you are // meant to land and must not put you down; going over the bars must. const pinnedUp = fly(true, false); check('landing rear-wheel-first does not crash you', pinnedUp.outcome !== 'crash', `got ${pinnedUp.outcome}`); const pinnedDown = fly(false, true); check('landing front-wheel-first crashes you', pinnedDown.outcome === 'crash', `got ${pinnedDown.outcome}`); // The asymmetry is the design; how lopsided it is, is a taste dial. Assert // the direction, not a ratio, so the numbers can be tuned without a failure. check('the nose-up tolerance is wider than the nose-down one', TUNE.landHardBack > TUNE.landHardNose && TUNE.landCleanBack > TUNE.landCleanNose, `back ${TUNE.landCleanBack}/${TUNE.landHardBack} vs nose ${TUNE.landCleanNose}/${TUNE.landHardNose}`); } // Heat: holding A alone must never stall you, and turbo must take a real // stretch of track to do it. { const flat = buildTrackModel({ id: 'heat', length: 8000, laps: 1, mainLaps: 1, qualifyMs: 90000, hurdles: [], }); const holdFor = (input, seconds) => { const state = createRace({ model: flat, mode: MODE.SOLO, seed: 3 }); state.phase = STATE.RIDING; for (const b of state.bikes) b.state = STATE.RIDING; let stalledAt = null; const frames = Math.round((seconds * 1000) / STEP_MS); for (let f = 0; f < frames; f += 1) { step(state, input); if (stalledAt == null && state.player.state === STATE.OVERHEATED) { stalledAt = (f * STEP_MS) / 1000; } } return { temp: state.player.temp, stalledAt }; }; const cruise = holdFor({ ...neutralInput(), a: true }, 60); check('holding the throttle alone never overheats', cruise.stalledAt == null, `stalled at ${cruise.stalledAt}s`); check('holding the throttle alone settles the meter around halfway', Math.abs(cruise.temp - TUNE.heatAccelTarget) < 0.02, `${cruise.temp.toFixed(2)}`); const boost = holdFor({ ...neutralInput(), a: true, b: true }, 20); check('unbroken turbo does overheat', boost.stalledAt != null); check('unbroken turbo takes six to eight seconds from cold', boost.stalledAt >= 6 && boost.stalledAt <= 8, `${boost.stalledAt?.toFixed(1)}s`); // Enough of a boost to be worth the heat. The exact size is a taste dial. check('turbo is a real boost over the throttle', TUNE.speedTurbo >= TUNE.speedAccel * 1.25, `${TUNE.speedAccel} -> ${TUNE.speedTurbo}`); } // A ramp must not put you down by itself. The steep ramp is only 15px wide, so // the windows that measure ground slope and curvature straddle its cliff and // report the face the bike is climbing as a 73-degree wall — which used to // launch the bike early, bury it inside the ramp it had just left, and then // judge the resulting phantom landing against that imaginary wall. Every ramp // in the catalogue is checked here, at every speed a bike can reach. { for (const id of ['A', 'B', 'C', 'D', 'E', 'H', 'R', 'S']) { const ramp = buildTrackModel({ id: `ramp-${id}`, length: 4000, laps: 1, mainLaps: 1, qualifyMs: 90000, hurdles: [{ t: id, x: 600 }], }); let worst = null; for (const v of [60, 90, 120, 150, 200, 240, TUNE.speedTurbo]) { const state = createRace({ model: ramp, mode: MODE.SOLO, seed: 5 }); state.phase = STATE.RIDING; for (const b of state.bikes) b.state = STATE.RIDING; state.player.x = 560; state.player.vx = v; const inp = { ...neutralInput(), a: true }; let outcome = null; for (let f = 0; f < 900 && !outcome; f += 1) { for (const ev of step(state, inp)) { if (ev.type === 'land') outcome = ev.quality; if (ev.type === 'crash') outcome = `crash-${ev.reason}`; } } if (outcome && outcome.startsWith('crash')) worst = `${v}px/s -> ${outcome}`; } check(`hurdle ${id} does not crash a rider who just holds the throttle`, worst === null, worst ?? ''); } // And the clamp that makes it work has to stay sane: a cliff is not a surface. check('the landing angle reference is clamped to a rideable slope', TUNE.landGroundAngleMax > 0.2 && TUNE.landGroundAngleMax < 1.0, `${TUNE.landGroundAngleMax}`); } // 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. // Re-based after landings were made deliberately forgiving: the pack crashes // far less now, so the field is tighter and nobody runs away with it. What must // still hold is that riding well is rewarded and riding carelessly is not. const SOAK_BANDS = { expert: { wins: [0.30, 1.00], podium: [0.70, 1.00] }, human: { wins: [0.00, 0.45], podium: [0.15, 0.80] }, steady: { wins: [0.00, 0.35], podium: [0.00, 0.60] }, naive: { wins: [0.00, 0.02], podium: [0.00, 0.10] }, }; // Absolute bands drift; the ordering must not. Skill has to pay, monotonically. const SOAK_ORDER = ['expert', 'human', 'steady', 'naive']; { let stalled = 0; let offTrack = 0; let neverFinished = 0; let races = 0; const rates = {}; 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; rates[name] = { winRate, podRate }; 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)}%)`); } for (let i = 1; i < SOAK_ORDER.length; i += 1) { const better = SOAK_ORDER[i - 1]; const worse = SOAK_ORDER[i]; check(`${better} out-podiums ${worse}`, rates[better].podRate > rates[worse].podRate, `${(rates[better].podRate * 100).toFixed(0)}% vs ${(rates[worse].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`);