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