// A reference rider, good enough to be the yardstick for every track. // // The generator uses it to set qualifying times, and the verifier uses it to // prove those times are beatable. Both must judge a track the same way, which // is why this lives in src/ and not inside either tool — the same rider, and // the same seed, from both directions. import { SURFACE, surfaceAt, groundAngleAt, isLaunchEdge, curvatureAt, LANE_COUNT, } from './ExcitebikeTrack.js'; import { createRace, step, neutralInput, finalizeRace, mulberry32, STATE, STEP_MS, MODE, } from './ExcitebikeLogic.js'; /** * The reference riders, from the ceiling down to the floor. * * expert perfect information, no lag. What the track can theoretically give. * human the yardstick the qualifying times are actually set from: it looks * ahead less far, reacts on a delay, and cannot place the nose * exactly. A track is fair when this rider can qualify on it. * steady a cautious human, low on the throttle. * naive holds the throttle down and steers at nothing. The difficulty * floor: a track this rider can qualify on is asking nothing. */ // `heatOff` is where a rider lifts off turbo and `heatOn` where they get back // on it. The gap between them matters more than either number: the throttle // bleeds the moment you release turbo, so feathering B around a single // threshold spends the whole race accelerating and never reaches turbo pace. // Riding it in long bursts is what the heat model rewards, and it is how a // person actually plays. export const AUTO_SKILL = { expert: { heatOff: 0.94, heatOn: 0.52, lookahead: 190, pitchDeadzone: 0.04, reactionFrames: 1, jitter: 0, }, human: { heatOff: 0.82, heatOn: 0.54, lookahead: 140, pitchDeadzone: 0.11, reactionFrames: 7, jitter: 0.09, }, steady: { heatOff: 0.66, heatOn: 0.58, lookahead: 120, pitchDeadzone: 0.16, reactionFrames: 11, jitter: 0.14, }, naive: null, }; // How high to carry the nose into a landing. The tolerance is lopsided, so the // safe place to sit is a little above the ground angle, not exactly on it. const LAND_BIAS = 0.14; function laneScore(model, lane, x, lookahead) { let cost = 0; for (let d = 0; d < lookahead; d += 8) { const s = surfaceAt(model, lane, x + d); const w = 1 - d / (lookahead * 1.35); if (s === SURFACE.GAP) cost += 900 * w; else if (s === SURFACE.OBSTACLE) cost += 900 * w; else if (s === SURFACE.MUD) cost += 130 * w; else if (s === SURFACE.ROUGH) cost += 85 * w; else if (s === SURFACE.COOL) cost -= 28 * w; } return cost; } /** * Cost of running up behind a slower bike in a lane. * * Without this the reference rider rams the back of the pack all race: the * contact rule puts the bike that closes from behind on the floor, so a rider * who ignores traffic spends SELECTION B crashing. Weighted by how fast it is * closing, because a rival pulling away is not in the way. */ function trafficAhead(state, self, lane) { if (state.bikes.length < 2) return 0; let cost = 0; for (const other of state.bikes) { if (other === self || other.state === STATE.FINISHED) continue; if (Math.abs(other.lane - lane) > 0.7) continue; const gap = other.x - self.x; if (gap < -24 || gap > 150) continue; const closing = Math.max(0, self.vx - other.vx); cost += (90 + closing * 3) * (1 - gap / 150); } return cost; } function memo(state) { if (!state._auto) { state._auto = { frame: 0, lane: null, onTurbo: true, rng: mulberry32(state.seed ^ 0x9e3779b9), }; } return state._auto; } /** One frame of input from the reference rider. */ export function autoInput(state, skill = AUTO_SKILL.expert) { const inp = neutralInput(); const b = state.player; const m = state.model; inp.a = true; if (!skill) return inp; // the naive rider: throttle only const mem = memo(state); mem.frame += 1; const lane = Math.max(0, Math.min(LANE_COUNT - 1, Math.round(b.lane))); if (b.state === STATE.AIRBORNE) { // Point the bike at the ground it is about to meet, with the nose held a // little high — landing rear wheel first is safe and going over the bars is // not, so the whole margin sits on the nose-up side. A less able rider // misjudges that target, and is slower to act on it. const want = groundAngleAt(m, lane, b.x + b.vx * 0.25) + LAND_BIAS + (skill.jitter ? (mem.rng() - 0.5) * skill.jitter : 0); const err = want - b.pitch; // Reaction lag: hold the previous decision between glances at the ground. if (mem.frame % skill.reactionFrames === 0 || mem.air == null) { mem.air = err > skill.pitchDeadzone ? 'up' : (err < -skill.pitchDeadzone ? 'down' : 'hold'); } if (mem.air === 'up') inp.left = true; else if (mem.air === 'down') inp.right = true; return inp; } mem.air = null; if (b.state === STATE.RUNNING) { inp.a = Math.floor(state.elapsedMs / STEP_MS) % 2 === 0; return inp; } const here = surfaceAt(m, lane, b.x); if (b.temp >= skill.heatOff) mem.onTurbo = false; else if (b.temp <= skill.heatOn) mem.onTurbo = true; // A cool zone is free heat: hold turbo across it regardless. inp.b = mem.onTurbo || here === SURFACE.COOL; if (mem.frame % skill.reactionFrames === 0 || mem.lane == null) { let best = lane; let bestCost = Infinity; for (let l = 0; l < LANE_COUNT; l += 1) { const cost = laneScore(m, l, b.x, skill.lookahead) + trafficAhead(state, b, l) + Math.abs(l - b.lane) * 9; if (cost < bestCost) { bestCost = cost; best = l; } } mem.lane = best; } if (mem.lane < Math.round(b.lane)) inp.up = true; else if (mem.lane > Math.round(b.lane)) inp.down = true; // Loft the front wheel just before anything that will throw the bike up, so // it leaves the ground level rather than nose-down. if (isLaunchEdge(m, lane, b.x + 10) || curvatureAt(m, lane, b.x + 10) < -0.02) inp.left = true; return inp; } /** * Drive a whole race and report the outcome. `maxSeconds` is a safety net: a * track that cannot be finished must fail loudly rather than hang the tool. */ export function runAuto(model, { mode = MODE.SOLO, skill = AUTO_SKILL.expert, seed = 1, rivalCount = 5, maxSeconds = 600, } = {}) { const state = createRace({ model, mode, rivalCount, seed }); const limit = Math.ceil((maxSeconds * 1000) / STEP_MS); const counts = {}; let frames = 0; while (state.phase !== STATE.FINISHED && frames < limit) { for (const ev of step(state, autoInput(state, skill))) { counts[ev.type] = (counts[ev.type] ?? 0) + 1; } frames += 1; } const result = finalizeRace(state); return { ...result, events: counts, frames, timedOut: frames >= limit, state }; } const PROBE_SEEDS = [3, 9, 17, 23, 31]; /** * Measure a track the way both the generator and the verifier need it measured. * * The `human` rider is jittered, so a single run says very little; this takes * the median finish time across several seeds and the mean crash rate. Those * two numbers are what a qualifying time is set from and what the difficulty * curve is asserted against — keeping them in one place is what stops the tool * that writes the tracks and the tool that checks them from disagreeing. */ export function probeTrack(model, { skill = AUTO_SKILL.human, seeds = PROBE_SEEDS } = {}) { const runs = seeds.map((seed) => runAuto(model, { skill, seed })); const finished = runs.filter((r) => r.finished && !r.timedOut); const times = finished.map((r) => r.ms).sort((a, b) => a - b); const crashes = runs.reduce((s, r) => s + (r.events.crash ?? 0), 0) / runs.length; return { runs, finishedCount: finished.length, medianMs: times.length ? times[Math.floor(times.length / 2)] : null, worstMs: times.length ? times[times.length - 1] : null, bestMs: times.length ? times[0] : null, crashesPerKpx: (crashes / model.length) * 1000, }; } export { PROBE_SEEDS };