Initial commit
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
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');
|
||||
Reference in New Issue
Block a user