// Builds the Excitebike track bank into assets/gamedata/excitebike/. // // node tools/genExcitebikeTracks.js # only writes missing files // node tools/genExcitebikeTracks.js --force # rewrites everything // node tools/genExcitebikeTracks.js --only=6,7 # rewrite specific tracks // // Tracks 1-5 are the original NES courses: their lengths and hurdle sequences // are transcribed from the NES track maps by tools/readExcitebikeMaps.js, not // invented. Tracks 6-10 are new, built from the same vocabulary and tuned to // pick up where Track 5 leaves off. // // Qualifying times are MEASURED, never guessed: the reference rider in // src/games/excitebike/ExcitebikeAuto.js drives each track, and the target is // set from what it actually does. Hand-tuned files are never clobbered without // --force, matching tools/genSuperKartTracks.js. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { buildTrackModel, validateTrack, HURDLES, START_PAD, FINISH_PAD } from '../src/games/excitebike/ExcitebikeTrack.js'; import { runAuto, probeTrack, AUTO_SKILL } from '../src/games/excitebike/ExcitebikeAuto.js'; import { mulberry32, MODE } from '../src/games/excitebike/ExcitebikeLogic.js'; import { TRACKS as AUTHORED } from './data/excitebikeCourses.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const OUT = join(ROOT, 'assets', 'gamedata', 'excitebike'); const args = process.argv.slice(2); const FORCE = args.includes('--force'); const ONLY = (args.find((a) => a.startsWith('--only=')) ?? '').slice(7) .split(',').filter(Boolean).map(Number); // --------------------------------------------------------------------------- // Generated courses (tracks 6-10) // --------------------------------------------------------------------------- // Weighted hurdle pools per difficulty tier. Ramps stay common throughout — // they are the game — while hazards thicken as the tier rises. const POOLS = [ { ramps: 'AABBDD', hazards: 'KLMN', gaps: '' }, { ramps: 'ABBCDE', hazards: 'IJKL', gaps: 'OP' }, { ramps: 'BCCEFGH', hazards: 'IJKL', gaps: 'OPOP' }, { ramps: 'CCEFGHHS', hazards: 'IIJJKL', gaps: 'OPOPQ' }, { ramps: 'CEFGHHHSR', hazards: 'IIJJKKLL', gaps: 'OPQQ' }, ]; // Spacing tightens track by track. It is the main difficulty dial: hurdles // close together leave no room to pick a line, dry the heat meter out, or set // up for the next landing. // Tracks 6-10 pick up where the originals leave off, so they start around // where Track 5 sits and climb from there. const NEW_TRACKS = [ { n: 6, length: 6600, theme: 'dusk', tier: 1, spacing: 145 }, { n: 7, length: 6900, theme: 'desert', tier: 2, spacing: 130 }, { n: 8, length: 7200, theme: 'night', tier: 3, spacing: 120 }, { n: 9, length: 7400, theme: 'snow', tier: 3, spacing: 110 }, { n: 10, length: 7800, theme: 'night', tier: 4, spacing: 105 }, ]; function pick(rng, str) { return str[Math.floor(rng() * str.length)]; } /** * Lay hurdles down the track at a rising cadence. Two rules keep a course * rideable: nothing overlaps its neighbour's footprint, and a cool zone is * never more than `coolEvery` away, so heat is always manageable if you look * for it. */ function generateHurdles(spec, seed) { const rng = mulberry32(seed); const pool = POOLS[spec.tier]; const hurdles = []; const first = START_PAD + 160; const last = spec.length - FINISH_PAD - 220; const coolEvery = 950; let x = first; let sinceCool = 0; while (x < last) { let t; if (sinceCool > coolEvery) { t = rng() < 0.5 ? 'M' : 'N'; sinceCool = 0; } else { const roll = rng(); if (roll < 0.5) t = pick(rng, pool.ramps); else if (roll < 0.82) t = pick(rng, pool.hazards); else if (pool.gaps) t = pick(rng, pool.gaps); else t = pick(rng, pool.ramps); if (t === 'M' || t === 'N') sinceCool = 0; } let footprint = HURDLES[t].profile.length; if (t === 'Q') { // A hole across all four lanes is only a hurdle if you can get airborne // over it. Always build the launcher first, edge flush with the near lip. const ramp = HURDLES.C.profile.length; hurdles.push({ t: 'C', x: Math.round(x) }); hurdles.push({ t: 'Q', x: Math.round(x) + ramp }); footprint += ramp; } else { hurdles.push({ t, x: Math.round(x) }); } const gap = spec.spacing * (0.72 + rng() * 0.6); x += footprint + gap; sinceCool += footprint + gap; } // Whoops sections, a couple per course, always on one half so there is a // clean line for a rider who spots them. const rough = []; const roughCount = 1 + spec.tier / 2; for (let i = 0; i < roughCount; i += 1) { const rx = first + ((i + 0.5) / roughCount) * (last - first) + rng() * 120; rough.push({ x: Math.round(rx), lanes: rng() < 0.5 ? [0, 1] : [2, 3] }); } return { hurdles, rough }; } // --------------------------------------------------------------------------- // Timing // --------------------------------------------------------------------------- /** * Set the qualifying target from what a fallible rider actually manages. * * Not the expert: the expert has perfect information and no reaction time, and * timing a course off it would set a target nobody holding a controller could * reach. The `human` probe misjudges landings and reacts on a delay, which is * the behaviour a target should be priced against. The margin on top is small * because that rider is already making mistakes. * * The other end is asserted by the verifier: a rider who just holds the * throttle down has to miss the target from track 4 on. */ const QUALIFY_MARGIN = 1.06; // ...but never tighter than this over the theoretical best. The human probe is // jittered, so on any given course it can happen to beat the expert, and a // target priced purely off it would then leave a perfect ride no room at all. const EXPERT_MARGIN = 1.12; function measure(json) { const model = buildTrackModel(json); const probe = probeTrack(model); const expert = runAuto(model, { skill: AUTO_SKILL.expert, seed: 9 }); const naive = runAuto(model, { skill: AUTO_SKILL.naive, seed: 9 }); return { model, probe, expert, naive }; } // --------------------------------------------------------------------------- // Build // --------------------------------------------------------------------------- function buildTrack(spec) { const authored = AUTHORED[spec.n]; const base = { version: 1, id: `track-${String(spec.n).padStart(2, '0')}`, name: `TRACK ${spec.n}`, theme: spec.theme, length: spec.length, laps: 2, mainLaps: 2, qualifyMs: 90000, }; const body = authored ? { hurdles: authored.hurdles, rough: authored.rough ?? [] } : generateHurdles(spec, 0x5b1ce7 + spec.n * 7919); const json = { ...base, ...body }; const { model, probe, expert, naive } = measure(json); if (!expert.finished) throw new Error(`${json.id}: the reference rider could not finish it`); if (probe.medianMs == null) throw new Error(`${json.id}: the human probe could not finish it`); json.qualifyMs = Math.round(Math.max( probe.medianMs * QUALIFY_MARGIN, expert.ms * EXPERT_MARGIN, ) / 100) * 100; const errors = validateTrack(buildTrackModel(json), json); return { json, model, probe, expert, naive, errors }; } function main() { mkdirSync(OUT, { recursive: true }); const specs = [ ...Object.values(AUTHORED).map((a) => ({ n: a.n, length: a.length, theme: a.theme, tier: a.tier, spacing: 0, })), ...NEW_TRACKS, ].sort((a, b) => a.n - b.n); const index = { version: 1, tracks: [] }; let wrote = 0; for (const spec of specs) { if (ONLY.length && !ONLY.includes(spec.n)) { const file = join(OUT, `track-${String(spec.n).padStart(2, '0')}.json`); if (existsSync(file)) { const existing = JSON.parse(readFileSync(file, 'utf8')); index.tracks.push({ n: spec.n, id: existing.id, name: existing.name, theme: existing.theme, file: `${existing.id}.json` }); continue; } } const { json, probe, expert, naive, errors } = buildTrack(spec); const file = join(OUT, `${json.id}.json`); if (errors.length) { console.error(`FAIL ${json.id}:`); for (const e of errors) console.error(` ${e}`); process.exitCode = 1; } if (existsSync(file) && !FORCE && !ONLY.includes(spec.n)) { const existing = JSON.parse(readFileSync(file, 'utf8')); index.tracks.push({ n: spec.n, id: existing.id, name: existing.name, theme: existing.theme, file: `${existing.id}.json` }); console.log(`skip ${json.id} (exists; --force to rewrite)`); continue; } writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`); wrote += 1; index.tracks.push({ n: spec.n, id: json.id, name: json.name, theme: json.theme, file: `${json.id}.json` }); const s = (ms) => (ms == null ? ' DNF' : `${(ms / 1000).toFixed(1)}`.padStart(6)); console.log( `${json.id} len ${String(json.length).padStart(5)} hurdles ${String(json.hurdles.length).padStart(2)}` + ` expert ${s(expert.ms)} human ${s(probe.medianMs)} target ${s(json.qualifyMs)}` + ` naive ${s(naive.finished ? naive.ms : null)}` + ` crash/kpx ${probe.crashesPerKpx.toFixed(2)}` + `${naive.finished && naive.ms <= json.qualifyMs ? ' <-- TOO EASY' : ''}`, ); } writeFileSync(join(OUT, 'tracks.json'), `${JSON.stringify(index, null, 2)}\n`); console.log(`\n${wrote} track file(s) written, index has ${index.tracks.length}`); } main();