204 lines
7.8 KiB
JavaScript
204 lines
7.8 KiB
JavaScript
// 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 };
|