fertig-classic-games/tools/verifyStarControl.js

731 lines
28 KiB
JavaScript

// Headless verification for Star Control Super Melee.
// node tools/verifyStarControl.js
// Exits non-zero on any failure.
//
// 1. Ship JSON schema: every entry compiles, every type resolves in the
// behavior registries, stats in sane bounds.
// 2. Toroidal math (wrap/tdelta/tmid) spot checks.
// 3. Determinism: same seed + same input script => identical state hash.
// 4. Fuzz invariants: energy/crew always in [0, max] under random inputs.
// 5. Physics fixtures: thrust cap, gravity pull, planet bounce + damage.
// 6. Weapon fixtures per pilot ship (hit registration + exact damage),
// special fixtures (point defense, glory falloff, fighters launch/dock,
// cloak vs homing, rear B.U.T.T.).
// 7. Smooth-zoom mapping: monotonic, anchored at the three UQM-style levels.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
STEP_MS, ARENA_W, ARENA_H, TUNE, ZOOM,
mulberry32, wrap, tdelta, tdist, tmid, quantizeFacing, angleDelta,
compileShips, createBattle, setInput, stepMatch,
targetZoom, smoothTowards, interceptAngle,
} from '../src/games/starcontrol/StarControlLogic.js';
import { PRIMARIES, SPECIALS, PASSIVES } from '../src/games/starcontrol/StarControlShips.js';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const shipsJson = JSON.parse(readFileSync(join(root, 'data/star-control-ships.json'), 'utf8'));
let failures = 0;
let passes = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; return; }
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
function section(name) { console.log(`\n== ${name}`); }
// Steps whole 24fps frames deterministically (no accumulator drift in tests).
function stepFrames(state, n, collect = false) {
const events = [];
for (let i = 0; i < n; i += 1) {
const evs = stepMatch(state, STEP_MS);
if (collect) events.push(...evs);
if (state.over) break;
}
return events;
}
const SHIPS = compileShips(shipsJson);
const keys = Object.keys(SHIPS);
function battle(aKey, bKey, seed = 7) {
const st = createBattle({ a: { def: SHIPS[aKey] }, b: { def: SHIPS[bKey] }, seed });
st.asteroids = []; // fixtures don't want random rocks in the way
return st;
}
function place(ship, x, y, facing = 0, vx = 0, vy = 0) {
ship.x = wrap(x, ARENA_W); ship.y = wrap(y, ARENA_H);
ship.px = ship.x; ship.py = ship.y;
ship.facing = facing; ship.vx = vx; ship.vy = vy;
}
// ---------------------------------------------------------------------------
section('1. ship JSON schema');
check('ships JSON has entries', keys.length > 0);
check('credits line present (CC BY-NC-SA attribution)', /CC BY-NC-SA/.test(shipsJson.credits ?? ''));
for (const key of keys) {
const def = SHIPS[key];
check(`${key}: primary type registered`, !!PRIMARIES[def.primary.type], def.primary.type);
check(`${key}: special type registered`, !!SPECIALS[def.special.type], def.special.type);
for (const p of def.passives) check(`${key}: passive registered`, !!PASSIVES[p], p);
check(`${key}: crew/energy sane`, def.stats.maxCrew >= 1 && def.stats.maxCrew <= 42
&& def.stats.maxEnergy >= 1 && def.stats.maxEnergy <= 42);
check(`${key}: point value sane`, def.pointValue >= 1 && def.pointValue <= 30);
check(`${key}: has ai hints`, !!def.ai && typeof def.ai.preferredRange === 'number');
check(`${key}: radius derived`, def.radius > 0);
check(`${key}: turn rate derived`, def.turnPerFrame > 0);
}
check('bad ship def throws (named reason)', (() => {
try { compileShips({ ships: { junk: { name: 'X', team: 'alliance', pointValue: 1, stats: {}, primary: { type: 'nope' }, special: { type: 'nope' } } } }); return false; }
catch (e) { return /junk/.test(e.message); }
})());
// ---------------------------------------------------------------------------
section('2. toroidal math');
check('wrap into range', wrap(-10, ARENA_W) === ARENA_W - 10 && wrap(ARENA_W + 5, ARENA_W) === 5);
check('tdelta short way', tdelta(100, ARENA_W - 100, ARENA_W) === -200);
check('tdelta antisymmetry', tdelta(500, 4000, ARENA_W) === -tdelta(4000, 500, ARENA_W));
{
const m = tmid(ARENA_W - 100, 100, 100, 100);
check('tmid crosses seam', m.x === 0 || m.x === ARENA_W, `got ${m.x}`);
}
check('angleDelta wraps', Math.abs(angleDelta(0.1, Math.PI * 2 - 0.1) + 0.2) < 1e-9);
check('quantizeFacing wraps negatives', quantizeFacing(-0.01) === 0 && quantizeFacing(Math.PI) === 8);
// ---------------------------------------------------------------------------
section('3. determinism');
{
const hashOf = (st) => st.ships.map((s) => [
s.x.toFixed(4), s.y.toFixed(4), s.vx.toFixed(4), s.vy.toFixed(4),
s.facing.toFixed(4), s.crew, s.energy,
].join(',')).join('|') + `#${st.projectiles.length}`;
const run = () => {
const st = createBattle({ a: { def: SHIPS.earthling }, b: { def: SHIPS.spathi }, seed: 42 });
const rng = mulberry32(99);
let acc = '';
for (let f = 0; f < 2000 && !st.over; f += 1) {
for (const side of [0, 1]) {
setInput(st, side, {
left: rng() < 0.2, right: rng() < 0.2, thrust: rng() < 0.5,
fire: rng() < 0.3, special: rng() < 0.1,
});
}
stepMatch(st, STEP_MS);
if (f % 100 === 0) acc += hashOf(st);
}
return acc;
};
check('same seed + inputs => identical trace', run() === run());
}
// ---------------------------------------------------------------------------
section('4. fuzz invariants (energy/crew bounds, positions in-arena)');
for (const key of keys) {
const st = createBattle({ a: { def: SHIPS[key] }, b: { def: SHIPS.urquan }, seed: 11 });
const rng = mulberry32(1234);
let ok = true;
let detail = '';
for (let f = 0; f < 5000 && !st.over; f += 1) {
for (const side of [0, 1]) {
setInput(st, side, {
left: rng() < 0.25, right: rng() < 0.25, thrust: rng() < 0.5,
fire: rng() < 0.35, special: rng() < 0.15,
});
}
stepMatch(st, STEP_MS);
for (const s of st.ships) {
if (s.energy < 0 || s.energy > s.def.stats.maxEnergy) { ok = false; detail = `${s.def.key} energy ${s.energy}`; }
if (s.crew < 0 || s.crew > s.def.stats.maxCrew) { ok = false; detail = `${s.def.key} crew ${s.crew}`; }
if (s.x < 0 || s.x >= ARENA_W || s.y < 0 || s.y >= ARENA_H) { ok = false; detail = `${s.def.key} pos ${s.x},${s.y}`; }
if (!Number.isFinite(s.x + s.y + s.vx + s.vy + s.facing)) { ok = false; detail = `${s.def.key} NaN`; }
}
if (!ok) break;
}
check(`fuzz ${key} vs urquan`, ok, detail);
}
// ---------------------------------------------------------------------------
section('5. physics fixtures');
{
// Thrust alone never exceeds maxThrust.
const st = battle('shofixti', 'urquan');
const savedG = TUNE.PLANET_GRAVITY;
TUNE.PLANET_GRAVITY = 0;
const A = st.ships[0];
place(A, 1000, 1000, 0);
setInput(st, 0, { thrust: true });
stepFrames(st, 200);
const speed = Math.hypot(A.vx, A.vy);
check('thrust reaches maxThrust', Math.abs(speed - A.def.stats.maxThrust) < 0.6, `speed ${speed}`);
stepFrames(st, 100);
check('thrust never exceeds maxThrust', Math.hypot(A.vx, A.vy) <= A.def.stats.maxThrust + 1e-6);
TUNE.PLANET_GRAVITY = savedG;
}
{
// Gravity pulls a coasting ship toward the planet.
const st = battle('earthling', 'urquan');
const A = st.ships[0];
place(A, ARENA_W / 2 - 1500, ARENA_H / 2, 0);
const d0 = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2);
stepFrames(st, 30);
const d1 = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2);
check('gravity pulls ship planet-ward', d1 < d0, `${d0} -> ${d1}`);
}
{
// Planet impact costs crew (once per cooldown window) and ejects the ship.
const st = battle('urquan', 'earthling');
const A = st.ships[0];
place(A, ARENA_W / 2 - 600, ARENA_H / 2, 0, 60, 0);
const crew0 = A.crew;
let bounced = false;
for (let f = 0; f < 30 && !bounced; f += 1) {
bounced = stepFrames(st, 1, true).some((e) => e.t === 'planetBounce');
}
stepFrames(st, 3);
check('planetBounce event emitted', bounced);
check('planet bounce damages crew once', A.crew === crew0 - TUNE.PLANET_CREW_DAMAGE, `crew ${A.crew}`);
const d = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2);
check('ship ejected from planet', d > st.planet.radius, `d ${d}`);
}
{
// Ship-ship collision: both take a knock at speed, momentum pushes apart.
const st = battle('urquan', 'earthling');
const [A, B] = st.ships;
const savedG = TUNE.PLANET_GRAVITY;
TUNE.PLANET_GRAVITY = 0;
place(A, 3000, 1000, 0, 80, 0);
place(B, 3000 + A.def.radius + B.def.radius + 90, 1000, Math.PI, -80, 0);
const crews = [A.crew, B.crew];
stepFrames(st, 20);
check('collision damages both ships', A.crew < crews[0] && B.crew < crews[1], `${A.crew},${B.crew}`);
check('collision separates ships', tdist(A.x, A.y, B.x, B.y) >= A.def.radius + B.def.radius - 1);
TUNE.PLANET_GRAVITY = savedG;
}
// ---------------------------------------------------------------------------
section('6. weapon + special fixtures');
const savedG2 = TUNE.PLANET_GRAVITY;
TUNE.PLANET_GRAVITY = 0; // keep fixtures geometric
{
// Every pilot primary lands on a stationary hulk dead ahead, placed inside
// that weapon's actual reach (flamethrowers are short, nukes are long).
for (const key of keys) {
const st = battle(key, key === 'urquan' ? 'earthling' : 'urquan');
const [A, B] = st.ships;
const pd = A.def.primary;
const reach = pd.type === 'beam'
? pd.range * 4
: (pd.muzzleDist ?? 12) * 4 + (pd.speed ?? 0) * (pd.lifeFrames ?? 0);
const dist = Math.min(600, Math.max(220, reach * 0.6));
place(A, 2000, 3000, 0);
place(B, 2000 + dist, 3000, Math.PI);
A.fx.warped = true; B.fx.warped = true; // suppress VUX-style warp-in repositioning
const crew0 = B.crew;
setInput(st, 0, { fire: true });
stepFrames(st, 90);
setInput(st, 0, { fire: false }); // charge-shot weapons launch on release
stepFrames(st, 40);
check(`${key} primary hits`, B.crew < crew0, `crew still ${B.crew} at dist ${dist}`);
}
}
{
// Exact damage: one Ur-Quan fusion bolt = 6 crew.
const st = battle('urquan', 'earthling');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2900, 3000, Math.PI);
setInput(st, 0, { fire: true });
stepFrames(st, 1);
setInput(st, 0, { fire: false });
stepFrames(st, 40);
check('fusion bolt exact damage', B.crew === B.def.stats.maxCrew - 6, `crew ${B.crew}`);
}
{
// Homing: nuke curves onto a target off boresight; cloak defeats tracking.
const run = (cloaked) => {
const st = battle('earthling', 'ilwrath');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000 + 1200, 3000 - 900, 0);
if (cloaked) B.fx.cloaked = true;
setInput(st, 0, { fire: true });
stepFrames(st, 2);
setInput(st, 0, { fire: false });
stepFrames(st, 70);
return B.crew < B.def.stats.maxCrew;
};
check('nuke tracks an off-axis target', run(false));
check('cloak defeats nuke tracking', !run(true));
}
{
// Point defense: zaps an incoming missile before impact, costs energy.
const st = battle('earthling', 'spathi');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000, 3000 + 260, -Math.PI / 2);
// Spathi B.U.T.T. fired away from us won't reach; instead have Spathi shoot at us.
place(B, 2600, 3000, Math.PI);
setInput(st, 1, { fire: true });
stepFrames(st, 3);
setInput(st, 1, { fire: false });
const e0 = A.energy;
let zapped = false;
for (let f = 0; f < 30 && !zapped; f += 1) {
setInput(st, 0, { special: true });
zapped = stepMatch(st, STEP_MS).some((e) => e.t === 'pdZap');
}
check('point defense zaps incoming fire', zapped);
check('point defense costs energy', A.energy < e0, `${e0} -> ${A.energy}`);
check('shot never landed', A.crew === A.def.stats.maxCrew);
}
{
// Glory device: falloff damage, kills the scout, ends the battle.
const dmgAt = (dist) => {
const st = battle('shofixti', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000 + dist, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 3);
return { dmg: B.def.stats.maxCrew - B.crew, st, A };
};
const near = dmgAt(200);
const far = dmgAt(650);
const out = dmgAt(1000);
check('glory kills the scout', !near.A.alive);
check('glory near > far damage', near.dmg > far.dmg, `${near.dmg} vs ${far.dmg}`);
check('glory out of range = 0', out.dmg === 0, `${out.dmg}`);
const expect = Math.floor((18 * (720 - 200)) / 720) + 1;
check('glory matches UQM falloff formula', near.dmg === expect, `${near.dmg} vs ${expect}`);
}
{
// Fighters: launch costs a crew member, they harass, then dock and return it.
const st = battle('urquan', 'earthling');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000 + 1500, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
check('fighter launched', st.projectiles.some((p) => p.type === 'fighter'));
check('launch costs 1 crew', A.crew === A.def.stats.maxCrew - 1);
const crewB0 = B.crew;
const evs = stepFrames(st, 500, true);
check('fighter shot at the enemy', B.crew < crewB0 || evs.some((e) => e.t === 'pdZap'));
check('fighter docked home (crew restored)', A.crew === A.def.stats.maxCrew
|| evs.some((e) => e.t === 'fighterDock'), `crew ${A.crew}`);
}
{
// B.U.T.T.: rear-launched missile homes onto a pursuer behind the Eluder.
const st = battle('spathi', 'urquan');
const [A, B] = st.ships;
place(A, 3000, 3000, 0, 20, 0);
place(B, 3000 - 700, 3000, 0, 20, 0); // chasing from behind
const crew0 = B.crew;
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
stepFrames(st, 40);
check('B.U.T.T. hits the pursuer', B.crew === crew0 - 2, `crew ${B.crew}`);
}
{
// Asteroids block shots and shatter.
const st = battle('spathi', 'urquan');
const [A] = st.ships;
place(A, 2000, 3000, 0);
place(st.ships[1], 6000, 6000, 0);
st.asteroids.push({
id: 9999, kind: 'asteroid', x: 2400, y: 3000, px: 2400, py: 3000,
vx: 0, vy: 0, spin: 0, rot: 0, radius: TUNE.ASTEROID_RADIUS, alive: true,
});
setInput(st, 0, { fire: true });
const evs = stepFrames(st, 10, true);
check('asteroid shattered by shot', evs.some((e) => e.t === 'asteroidBreak'));
}
{
// Battle-over bookkeeping: winner declared once, loser reported.
const st = battle('shofixti', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2150, 3000, Math.PI);
setInput(st, 0, { special: true }); // glory at point blank vs 42-crew hulk: scout dies
const evs = stepFrames(st, 5, true);
check('battleOver emitted', evs.some((e) => e.t === 'battleOver'));
check('winner is the dreadnought', st.winner === 1, `winner ${st.winner}`);
check('death event for scout', evs.some((e) => e.t === 'death' && e.side === 0));
}
// ---------------------------------------------------------------------------
section('6b. special-ability fixtures (full roster)');
{
// Teleport relocates the Skiff.
const st = battle('arilou', 'urquan');
const A = st.ships[0];
place(A, 2000, 3000, 0);
const x0 = A.x;
setInput(st, 0, { special: true });
stepFrames(st, 2);
check('teleport relocates', tdist(A.x, A.y, x0, 3000) > 400 || A.x !== x0);
}
{
// Yehat force shield blocks a fusion bolt outright.
const st = battle('yehat', 'urquan');
const [A, B] = st.ships;
place(A, 2900, 3000, 0);
place(B, 2000, 3000, 0);
A.fx.shieldFrames = 200;
setInput(st, 1, { fire: true });
stepFrames(st, 1);
setInput(st, 1, { fire: false });
stepFrames(st, 40);
check('force shield blocks damage', A.crew === A.def.stats.maxCrew, `crew ${A.crew}`);
}
{
// Utwig absorption shield converts damage into battery charge.
const st = battle('utwig', 'urquan');
const [A, B] = st.ships;
place(A, 2900, 3000, 0);
place(B, 2000, 3000, 0);
A.energy = 2;
A.fx.absorbFrames = 200;
setInput(st, 1, { fire: true });
stepFrames(st, 1);
setInput(st, 1, { fire: false });
stepFrames(st, 40);
check('absorption shield converts damage', A.crew === A.def.stats.maxCrew && A.energy === 8,
`crew ${A.crew} energy ${A.energy}`);
}
{
// Druuge furnace trades a crewman for a full battery.
const st = battle('druuge', 'urquan');
const A = st.ships[0];
A.energy = 3;
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('furnace refills battery for 1 crew',
A.crew === A.def.stats.maxCrew - 1 && A.energy === A.def.stats.maxEnergy,
`crew ${A.crew} energy ${A.energy}`);
}
{
// Mycon regeneration: full battery -> +4 crew.
const st = battle('mycon', 'urquan');
const A = st.ships[0];
A.crew = 10;
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('mycon regenerates 4 crew', A.crew === 14 && A.energy <= 1, `crew ${A.crew} energy ${A.energy}`);
}
{
// Pkunk taunt restores energy; resurrection lands in a plausible band.
const st = battle('pkunk', 'urquan');
const A = st.ships[0];
A.energy = 0;
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('pkunk taunt restores energy', A.energy === 2, `energy ${A.energy}`);
let rebirths = 0;
let deaths = 0;
const N = 400;
for (let i = 0; i < N; i += 1) {
const st2 = battle('pkunk', 'urquan', 500 + i);
const P = st2.ships[0];
place(P, 2600, 3000, 0);
place(st2.ships[1], 2000, 3000, 0);
setInput(st2, 1, { fire: true });
for (let f = 0; f < 80; f += 1) {
const evs = stepFrames(st2, 1, true);
if (evs.some((e) => e.t === 'death' && e.side === 0)) { deaths += 1; break; }
if (evs.some((e) => e.t === 'resurrect' && e.side === 0)) { deaths += 1; rebirths += 1; break; }
}
}
check('pkunk resurrection ~50% (0.40-0.60 band)',
deaths >= 100 && rebirths / deaths > 0.4 && rebirths / deaths < 0.6,
`${rebirths}/${deaths}`);
}
{
// VUX limpets degrade the enemy's turn rate.
const st = battle('yehat', 'vux');
const A = st.ships[0];
A.fx.limpets = 4;
place(A, 2000, 3000, 0);
setInput(st, 0, { right: true });
stepFrames(st, 24);
const slowed = A.facing;
const st2 = battle('yehat', 'vux');
const A2 = st2.ships[0];
place(A2, 2000, 3000, 0);
setInput(st2, 0, { right: true });
stepFrames(st2, 24);
check('limpets slow turning', slowed < A2.facing * 0.6, `${slowed} vs ${A2.facing}`);
}
{
// Orz marines board and chew crew.
const st = battle('orz', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2600, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
check('marine launched costs 1 crew', A.crew === A.def.stats.maxCrew - 1);
stepFrames(st, 300);
check('marines chew enemy crew', B.crew < B.def.stats.maxCrew, `crew ${B.crew}`);
}
{
// Chenjesu DOGI drains enemy energy; shard fragments on release.
const st = battle('chenjesu', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2900, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
check('DOGI spawned', st.projectiles.some((p) => p.type === 'dogi'));
stepFrames(st, 200);
check('DOGI drained enemy energy', B.energy < B.def.stats.maxEnergy, `energy ${B.energy}`);
const st2 = battle('chenjesu', 'urquan');
place(st2.ships[0], 2000, 3000, 0);
place(st2.ships[1], 6000, 5000, Math.PI);
setInput(st2, 0, { fire: true });
stepFrames(st2, 1);
setInput(st2, 0, { fire: false });
stepFrames(st2, 2);
check('shard shatters into fragments on release',
st2.projectiles.filter((p) => p.type === 'fragment').length === 8
&& !st2.projectiles.some((p) => p.type === 'shard'));
}
{
// Chmmr zapsats orbit and shoot down incoming missiles.
const st = battle('chmmr', 'earthling');
const [A, B] = st.ships;
check('zapsats spawned at battle start', st.projectiles.filter((p) => p.type === 'zapsat').length === 3);
place(A, 2000, 3000, 0);
place(B, 3200, 3000, Math.PI);
setInput(st, 1, { fire: true });
stepFrames(st, 2);
setInput(st, 1, { fire: false });
const evs = stepFrames(st, 60, true);
check('zapsats defend the Avatar', A.crew === A.def.stats.maxCrew || evs.some((e) => e.t === 'pdZap'));
}
{
// Androsynth blazer: constant-speed comet, contact does 3 crew.
const st = battle('androsynth', 'earthling');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000 + 700, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
check('blazer engaged', A.fx.blazer === true);
const crew0 = B.crew;
stepFrames(st, 40);
check('blazer contact damage', B.crew <= crew0 - 3, `crew ${B.crew}`);
}
{
// Kohr-Ah F.R.I.E.D. clears incoming ordnance.
const st = battle('kohrah', 'earthling');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 3000, 3000, Math.PI);
setInput(st, 1, { fire: true });
stepFrames(st, 2);
setInput(st, 1, { fire: false });
stepFrames(st, 8);
setInput(st, 0, { special: true });
stepFrames(st, 2);
check('FRIED burns off incoming missiles',
!st.projectiles.some((p) => p.alive && p.owner === 1 && p.type === 'missile'));
}
{
// Syreen song pulls crew out as floaters; the Penetrator can rescue them.
const st = battle('syreen', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2600, 3000, Math.PI);
const crewB0 = B.crew;
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
const floaters = st.projectiles.filter((p) => p.type === 'crew').length;
check('song pulls crew overboard', floaters > 0 && B.crew < crewB0, `${floaters} floaters`);
}
{
// Mmrnmhrm transform swaps stats and weapon.
const st = battle('mmrnmhrm', 'urquan');
const A = st.ships[0];
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('transform costs the battery', A.energy <= 2, `energy ${A.energy}`);
check('Y-form stats active', A.fx.formIndex === 1 && A.fx.formStats.maxThrust === 50);
check('Y-form weapon active', A.fx.formPrimary.type === 'homing');
}
{
// Umgah cone destroys incoming shots; retro hop jumps backward.
const st = battle('umgah', 'earthling');
const [A, B] = st.ships;
const savedG3 = TUNE.PLANET_GRAVITY;
TUNE.PLANET_GRAVITY = 0;
place(A, 2000, 3000, 0);
place(B, 2900, 3000, Math.PI);
setInput(st, 1, { fire: true });
stepFrames(st, 2);
setInput(st, 1, { fire: false });
setInput(st, 0, { fire: true });
stepFrames(st, 24);
check('cone destroys incoming missile',
!st.projectiles.some((p) => p.alive && p.owner === 1 && p.type === 'missile'));
setInput(st, 0, { fire: false });
const x0 = A.x;
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('retro hop jumps backward', tdelta(x0, A.x, ARENA_W) < -100, `dx ${tdelta(x0, A.x, ARENA_W)}`);
TUNE.PLANET_GRAVITY = savedG3;
}
{
// Slylandro probe drive: always at full speed; harvest refuels from rocks.
const st = battle('slylandro', 'urquan');
const A = st.ships[0];
stepFrames(st, 5);
check('probe always moves at max thrust',
Math.abs(Math.hypot(A.vx, A.vy) - A.def.stats.maxThrust) < 0.001);
A.energy = 0;
st.asteroids = [{
id: 777, kind: 'asteroid', x: wrap(A.x + 200, ARENA_W), y: A.y, px: A.x, py: A.y,
vx: 0, vy: 0, spin: 0, rot: 0, radius: 60, alive: true,
}];
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('harvest converts asteroid to energy', A.energy === 8, `energy ${A.energy}`);
}
{
// Melnorme confusion scrambles steering flag.
const st = battle('melnorme', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2500, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
stepFrames(st, 20);
check('confusion pulse scrambles the enemy', (B.fx.confusedFrames ?? 0) > 0);
}
{
// Thraddash afterburner lays damaging napalm.
const st = battle('thraddash', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
setInput(st, 0, { special: true });
stepFrames(st, 10);
const trail = st.projectiles.filter((p) => p.type === 'napalm');
check('afterburner lays napalm trail', trail.length > 0);
if (trail.length > 0) {
place(B, trail[0].x, trail[0].y, 0);
const crew0 = B.crew;
stepFrames(st, 3);
check('napalm burns the enemy', B.crew < crew0, `crew ${B.crew}`);
}
}
TUNE.PLANET_GRAVITY = savedG2;
// ---------------------------------------------------------------------------
section('6c. fleets');
{
const { PRESET_FLEETS, materializeFleet, fleetPoints, validateFleet, randomFleet, aiCounterPick, FLEET_MAX } =
await import('../src/games/starcontrol/StarControlFleets.js');
for (const preset of PRESET_FLEETS) {
const m = materializeFleet(SHIPS, preset.ships);
check(`fleet "${preset.id}" fully materializes`, m.missing.length === 0, `missing ${m.missing.join(',')}`);
check(`fleet "${preset.id}" within ${FLEET_MAX}`, m.entries.length <= FLEET_MAX);
check(`fleet "${preset.id}" validates`, validateFleet(SHIPS, m.entries).ok);
}
const alliance = materializeFleet(SHIPS, PRESET_FLEETS[0].ships);
const hierarchy = materializeFleet(SHIPS, PRESET_FLEETS[1].ships);
check('default matchup point-balanced (within 10)',
Math.abs(alliance.points - hierarchy.points) <= 10,
`${alliance.points} vs ${hierarchy.points}`);
const rf = randomFleet(SHIPS, mulberry32(5), 200);
check('grab bag within budget', fleetPoints(SHIPS, rf) <= 200 && rf.length >= 1);
const pick = aiCounterPick(SHIPS, ['shofixti', 'chmmr'], 'urquan', mulberry32(9));
check('counter-pick avoids scout vs dreadnought', pick === 'chmmr', pick);
}
// ---------------------------------------------------------------------------
section('7. smooth zoom mapping');
{
check('close anchor at short range', Math.abs(targetZoom(0) - ZOOM.CLOSE) < 1e-9);
check('med anchor between bands', Math.abs(targetZoom((ZOOM.BAND_1[1] + ZOOM.BAND_2[0]) / 2) - ZOOM.MED) < 1e-9);
check('far anchor at long range', Math.abs(targetZoom(99999) - ZOOM.FAR) < 1e-9);
let mono = true;
let prev = Infinity;
for (let d = 0; d <= 6000; d += 25) {
const z = targetZoom(d);
if (z > prev + 1e-12) mono = false;
prev = z;
}
check('zoom monotonically non-increasing with distance', mono);
check('far viewport fits arena', 1920 / ZOOM.FAR <= ARENA_W && 1080 / ZOOM.FAR <= ARENA_H);
const z1 = smoothTowards(1, 0.25, 180);
const z2 = smoothTowards(z1, 0.25, 180);
check('smoothing approaches without overshoot', z1 > 0.25 && z2 > 0.25 && z2 < z1);
}
// ---------------------------------------------------------------------------
section('8. AI pilots (soak + skill gradient)');
{
const { createAI, updateAI, knobsFor } = await import('../src/games/starcontrol/StarControlAI.js');
check('knobs interpolate between anchors', (() => {
const k = knobsFor(6);
return k.reactMs < knobsFor(5).reactMs && k.reactMs > knobsFor(7).reactMs;
})());
const playBattle = (aKey, bKey, skillA, skillB, seed) => {
const st = createBattle({ a: { def: SHIPS[aKey] }, b: { def: SHIPS[bKey] }, seed });
const aiA = createAI({ skill: skillA, side: 0, seed: seed + 1 });
const aiB = createAI({ skill: skillB, side: 1, seed: seed + 2 });
let hits = 0;
while (!st.over && st.frame < 2160) { // 90 sim-seconds cap
setInput(st, 0, updateAI(aiA, st, STEP_MS));
setInput(st, 1, updateAI(aiB, st, STEP_MS));
for (const e of stepMatch(st, STEP_MS)) if (e.t === 'shipHit') hits += 1;
}
return { winner: st.over ? st.winner : 'timeout', hits, frames: st.frame };
};
let decisive = 0;
let total = 0;
let battlesWithHits = 0;
let threw = null;
try {
for (const aKey of keys) {
for (const bKey of keys) {
const r = playBattle(aKey, bKey, 5, 5, 101 + total);
total += 1;
if (r.winner === 0 || r.winner === 1) decisive += 1;
if (r.hits > 0) battlesWithHits += 1;
}
}
} catch (e) { threw = e; }
check('AI soak throws no exceptions', !threw, threw?.stack?.split('\n')[0]);
check('nearly every AI battle produced hits (>=97%)', battlesWithHits / total >= 0.97, `${battlesWithHits}/${total}`);
check('most AI battles decisive', decisive / total >= 0.6, `${decisive}/${total}`);
let hi = 0;
let games = 0;
for (let g = 0; g < 20; g += 1) {
const r = playBattle('earthling', 'earthling', 8, 2, 900 + g);
if (r.winner === 0) hi += 1;
if (r.winner === 0 || r.winner === 1) games += 1;
}
check('skill 8 beats skill 2 (mirror match)', games >= 10 && hi / games >= 0.65, `${hi}/${games}`);
}
// ---------------------------------------------------------------------------
console.log(`\n${passes} checks passed, ${failures} failed`);
if (failures > 0) process.exit(1);