163 lines
6.1 KiB
JavaScript
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');
|