fertig-classic-games/src/games/excitebike/ExcitebikeAuto.js

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 };