853 lines
38 KiB
JavaScript
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`);
|