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');