Files
2026-08-03 06:43:21 -05:00

163 lines
6.1 KiB
JavaScript

import { createBrain, spawnLineup, steer } from '../shared/ai.js';
import { SKATE, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
import { RINK, insideRink } from '../shared/rink.js';
import { done, near, ok, section } from './harness.mjs';
const DT = 1 / 120;
/** Deterministic PRNG so a failure here is reproducible. */
function rng(seed) {
let a = seed | 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* A whole match's worth of skaters and brains, stepped headlessly.
* Board contact is the sim's clamp here rather than Box3D's, which is the
* point: the AI must not need the physics world to behave.
*/
function simulate(perTeam, seconds, seed = 7, teams = 2) {
const rand = rng(seed);
const spawns = spawnLineup(perTeam, teams);
const count = spawns.length;
const states = spawns.map((sp, i) => createSkaterState(i, sp, { team: sp.team }));
const brains = spawns.map(() => createBrain(rand));
const trace = states.map(() => ({ minSpeed: Infinity, maxSpeed: 0, offIce: 0, touches: 0, distance: 0 }));
const steps = Math.round(seconds / DT);
for (let n = 0; n < steps; n++) {
for (let i = 0; i < count; i++) {
steer(brains[i], states[i], states, DT);
const x0 = states[i].x;
const z0 = states[i].z;
stepSkater(states[i], DT);
const t = trace[i];
t.distance += Math.hypot(states[i].x - x0, states[i].z - z0);
const v = speedOf(states[i]);
if (v < t.minSpeed) t.minSpeed = v;
if (v > t.maxSpeed) t.maxSpeed = v;
if (!insideRink(states[i].x, states[i].z, SKATE.radius)) t.offIce++;
}
// Count how often two bodies are actually overlapping. The proxies resolve
// this in the browser; here it measures whether the *steering* alone keeps
// them roughly apart.
for (let i = 0; i < count; i++) {
for (let j = i + 1; j < count; j++) {
const d = Math.hypot(states[i].x - states[j].x, states[i].z - states[j].z);
if (d < SKATE.radius * 2) {
trace[i].touches++;
trace[j].touches++;
}
}
}
}
return { states, brains, trace, steps, count };
}
section('the 3-on-3 lineup is legal, split by half, and faces centre ice');
{
const spawns = spawnLineup(3, 2);
ok(spawns.length === 6, `six skaters on the ice (${spawns.length})`);
ok(spawns.filter((s) => s.team === 0).length === 3, 'three a side, home');
ok(spawns.filter((s) => s.team === 1).length === 3, 'three a side, away');
for (const sp of spawns) {
ok(insideRink(sp.x, sp.z, SKATE.radius + 1), `spawn (${sp.x.toFixed(1)}, ${sp.z.toFixed(1)}) is on the ice`);
// Facing should point back toward the middle of the rink.
near(sp.yaw, Math.atan2(-sp.x, -sp.z), 1e-9, 'spawn faces centre ice');
// Each team starts in its own half, the way a lineup does.
const ownHalf = sp.team === 0 ? sp.x < 0 : sp.x > 0;
ok(ownHalf, `team ${sp.team} lines up in its own half (x=${sp.x.toFixed(1)})`);
}
// Index order has to agree with the team field, because the match builds
// skaters and materials off the index.
for (let i = 0; i < spawns.length; i++) {
ok(spawns[i].team === Math.floor(i / 3), `index ${i} belongs to team ${Math.floor(i / 3)}`);
}
for (let i = 0; i < spawns.length; i++) {
for (let j = i + 1; j < spawns.length; j++) {
const d = Math.hypot(spawns[i].x - spawns[j].x, spawns[i].z - spawns[j].z);
ok(d > 2, `spawns ${i} and ${j} are not on top of each other (${d.toFixed(1)}m)`);
}
}
// Nobody starts inside the far team, and nobody starts in a corner.
for (const sp of spawns) {
ok(Math.abs(sp.x) < RINK.halfX - 4, `spawn is clear of the end boards (x=${sp.x.toFixed(1)})`);
}
}
section('a 3-on-3 skates a full minute without leaving the ice');
{
const { trace, states, count } = simulate(3, 60);
ok(count === 6, 'six skaters simulated');
for (let i = 0; i < count; i++) {
ok(trace[i].offIce === 0, `skater ${i} never went through the boards`);
ok(Number.isFinite(states[i].x) && Number.isFinite(states[i].z), `skater ${i} stayed finite`);
ok(trace[i].distance > 120, `skater ${i} actually covered ground (${trace[i].distance.toFixed(0)}m in 60s)`);
ok(trace[i].maxSpeed > 4, `skater ${i} got up to a real speed (${trace[i].maxSpeed.toFixed(1)} m/s)`);
ok(trace[i].maxSpeed <= SKATE.speedCeiling, `skater ${i} never exceeded the ceiling`);
}
}
section('bots keep out of each other\'s way on their own');
{
const { trace, steps, count } = simulate(3, 60);
for (let i = 0; i < count; i++) {
const overlapFraction = trace[i].touches / steps;
ok(
overlapFraction < 0.06,
`skater ${i} spends almost no time inside another body (${(overlapFraction * 100).toFixed(1)}%)`,
);
}
}
section('bots reach their waypoints rather than circling forever');
{
const rand = rng(19);
const s = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
const brain = createBrain(rand);
let arrivals = 0;
let last = null;
for (let n = 0; n < 60 * 120; n++) {
steer(brain, s, [s], DT);
if (brain.target !== last) {
if (last !== null) arrivals++;
last = brain.target;
}
stepSkater(s, DT);
}
ok(arrivals >= 5, `a lone bot got through several waypoints in a minute (${arrivals})`);
}
section('a full 5-on-5 still behaves');
{
// Not a spike-1 requirement, but the cheapest possible check that the
// steering does not fall over the moment there is a full side on the ice.
const { trace, states, count } = simulate(5, 30, 3);
ok(count === 10, 'ten skaters simulated');
for (let i = 0; i < count; i++) {
ok(trace[i].offIce === 0, `skater ${i} of ten stayed on the ice`);
ok(Number.isFinite(states[i].x), `skater ${i} of ten stayed finite`);
}
}
section('the whole match is deterministic');
{
const a = simulate(3, 20, 42);
const b = simulate(3, 20, 42);
for (let i = 0; i < a.count; i++) {
near(a.states[i].x, b.states[i].x, 0, `skater ${i} replays to the same x`);
near(a.states[i].z, b.states[i].z, 0, `skater ${i} replays to the same z`);
}
}
done('ai');