Back to Poly Dogfight
SOURCE / PINNED RELEASE

Made of little things.

Poly Dogfight

Release
b8511ee374e3…
Author-recorded commit
5579434b9d8b…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b

Archive hash verified: bcadd2430d8ea2bc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/rockets.test.mjs
// Heat-seeking rockets: ammunition, locking, dodging and coasting round the planet.
import assert from 'node:assert/strict';
import test from 'node:test';
import { Vector3 } from 'three';
import { PLANET_RADIUS, ROCKETS } from '../src/sim/constants.ts';
import { beginnerControls } from '../src/sim/beginner.ts';
import { createFlightState, neutralInput, spawnAt, stepFlight } from '../src/sim/flight.ts';
import { createRockets, seekerTarget, stepRockets } from '../src/sim/rockets.ts';

const DT = 1 / 120;
const sky = { surfaceHeight: () => 0, terrainHeight: () => -60, hitsObstacle: () => false };

function plane(altitude = 200) {
  const state = createFlightState();
  spawnAt(state, sky, new Vector3(0, 1, 0), new Vector3(0, 0, -1));
  state.position.setLength(PLANET_RADIUS + altitude);
  state.rocketHeld = false;
  return state;
}

/** A shooter `gap` metres behind the target, same heading and speed. */
function chase(gap, offset = new Vector3()) {
  const target = plane();
  const shooter = plane();
  shooter.position.copy(target.position).add(new Vector3(0, 0, gap)).add(offset);
  return { target, shooter };
}

/** Fly both planes and the rockets together; the target steers with `steer`. */
function duel(shooter, target, seconds, steer = () => neutralInput(), launchAt = 0) {
  const rockets = createRockets();
  const hits = [];
  const locks = [];
  for (let i = 0; i < seconds * 120; i++) {
    stepFlight(shooter, neutralInput(), sky, DT);
    stepFlight(target, steer(target), sky, DT);
    const events = stepRockets(rockets, [shooter, target], [i === Math.round(launchAt * 120), false], sky, DT);
    hits.push(...events.hits);
    for (const rocket of rockets.rockets) if (rocket.target) locks.push(rocket.target);
  }
  return { hits, locks, rockets };
}

test('one rocket per press, two on the wings, a 2 s gap and an 8 s reload each', () => {
  const pilot = plane();
  const rockets = createRockets();
  let launched = 0;
  const step = (trigger) => (launched += stepRockets(rockets, [pilot], [trigger], sky, DT).launches.length);
  step(true);
  assert.equal(launched, 1);
  for (let i = 0; i < 360; i++) step(true);
  assert.equal(launched, 1, 'holding the trigger fires once');
  step(false);
  step(true);
  assert.equal(launched, 2, 'a fresh press after the cooldown fires the other wing');
  assert.equal(pilot.rockets, 0);

  // Pressed again inside the 2 s cooldown: refused.
  const fresh = plane();
  const rack = createRockets();
  stepRockets(rack, [fresh], [true], sky, DT);
  stepRockets(rack, [fresh], [false], sky, DT);
  assert.equal(stepRockets(rack, [fresh], [true], sky, DT).launches.length, 0);
  assert.equal(fresh.rockets, 1);

  // Empty: presses do nothing. Reloading runs one rocket at a time from the
  // first launch: one is back 8 s after it, the second 8 s after that.
  let t = (362 + 2) / 120;
  const until = (seconds, trigger = false) => {
    while (t < seconds) { step(trigger); step(false); t += 2 / 120; }
  };
  until(7.8, true);
  assert.equal(launched, 2, 'empty rack refuses');
  assert.equal(pilot.rockets, 0);
  until(8.1);
  assert.equal(pilot.rockets, 1);
  until(15.8);
  assert.equal(pilot.rockets, 1);
  until(16.1);
  assert.equal(pilot.rockets, ROCKETS.capacity);
});

test('a rocket locks onto a plane flying straight ahead and halves the part it hits', () => {
  const { target, shooter } = chase(150);
  const { hits } = duel(shooter, target, 6);
  assert.equal(hits.length, 1);
  assert.equal(hits[0].owner, shooter);
  const total = target.damage.left + target.damage.right + target.damage.tail;
  assert.ok(Math.abs(total - ROCKETS.damage) < 1e-9, `one hit deals ${ROCKETS.damage}, got ${total}`);
  assert.equal(target.damage[hits[0].part], 0.5);
  assert.equal(hits[0].destroyed, false);
  assert.equal(target.crippled, false);
});

test('a second rocket into the same part destroys it', () => {
  const { target, shooter } = chase(150);
  const rockets = createRockets();
  const hits = [];
  for (let i = 0; i < 120 * 8; i++) {
    stepFlight(shooter, neutralInput(), sky, DT);
    stepFlight(target, neutralInput(), sky, DT);
    const trigger = i === 0 || i === 120 * 2.5;
    hits.push(...stepRockets(rockets, [shooter, target], [trigger, false], sky, DT).hits);
  }
  assert.equal(hits.length, 2);
  assert.equal(hits[0].part, hits[1].part, 'a rocket from dead astern hits the same part twice');
  assert.equal(hits[1].destroyed, true);
  assert.equal(target.crippled, true);
});

test('the seeker ignores planes well off the nose or out of range', () => {
  const origin = new Vector3(0, PLANET_RADIUS + 200, 0);
  const heading = new Vector3(0, 0, -1);
  const off = plane();
  off.position.copy(origin).add(new Vector3(-Math.sin(Math.PI / 3), 0, -Math.cos(Math.PI / 3)).multiplyScalar(150));
  const far = plane();
  far.position.copy(origin).add(new Vector3(0, 0, -500));
  assert.equal(seekerTarget(origin, heading, [off, far], null, sky), null);
  const near = plane();
  near.position.copy(origin).add(new Vector3(20, 0, -150));
  assert.equal(seekerTarget(origin, heading, [off, far, near], null, sky), near);

  // Fired with both out of its cone: the rocket never locks either.
  const shooter = plane();
  shooter.position.copy(origin);
  const { locks } = duel(shooter, off, 1.5);
  assert.equal(locks.length, 0);
});

test('a hard banking turn shakes a rocket fired from behind', () => {
  const { target, shooter } = chase(150);
  // Full right in beginner steering: roll into a 90° bank and pull.
  const { hits, locks, rockets } = duel(shooter, target, 10, (state) => beginnerControls(state, { ...neutralInput(), roll: 1 }), 0);
  assert.ok(locks.includes(target), 'the rocket locked on before the dodge');
  assert.equal(hits.length, 0, 'the rocket overshoots the turn and misses');
  assert.ok(rockets.rockets.every((rocket) => rocket.target !== target), 'and loses its lock');
});

test('with nothing to chase a rocket coasts round the planet at its altitude, then finds its launcher', () => {
  const launcher = plane(120);
  const launch = launcher.position.clone();
  const rockets = createRockets();
  stepRockets(rockets, [launcher], [true], sky, DT);
  const rocket = rockets.rockets[0];
  const radius = rocket.position.length();
  // Keep the launcher parked where it fired (alive, unmoving) to be found again.
  launcher.velocity.set(0, 0, 0);
  let maxDrift = 0;
  let hit = null;
  let minAge = Infinity;
  for (let i = 0; i < 120 * ROCKETS.life && !hit; i++) {
    const events = stepRockets(rockets, [launcher], [false], sky, DT);
    if (rockets.rockets[0] && !rockets.rockets[0].target) maxDrift = Math.max(maxDrift, Math.abs(rockets.rockets[0].position.length() - radius));
    if (rockets.rockets[0]?.target === launcher) minAge = Math.min(minAge, rockets.rockets[0].age);
    hit = events.hits[0] ?? null;
  }
  assert.ok(maxDrift < 2, `holds altitude within 2 m while coasting (drifted ${maxDrift.toFixed(2)})`);
  assert.ok(hit, 'the rocket came all the way round and struck its launcher');
  assert.equal(hit.owner, launcher);
  const lap = (2 * Math.PI * radius) / ROCKETS.speed;
  assert.ok(minAge > lap * 0.8, `locked on only after a lap (${minAge.toFixed(1)} s, lap ${lap.toFixed(1)} s)`);
  assert.ok(launch.distanceTo(launcher.position) < 1e-9);
});

test('the launcher is safe from its own rocket at launch', () => {
  const pilot = plane();
  const rockets = createRockets();
  const hits = [];
  for (let i = 0; i < 120 * 2; i++) {
    stepFlight(pilot, neutralInput(), sky, DT);
    hits.push(...stepRockets(rockets, [pilot], [i === 0], sky, DT).hits);
  }
  assert.equal(hits.length, 0);
});

test('the armoured nose does not stop a rocket: the blast takes a wing', () => {
  const target = plane();
  const shooter = plane();
  // Head-on: shooter 200 m ahead of the target, facing it.
  shooter.position.copy(target.position).add(new Vector3(0, 0, -200));
  spawnAt(shooter, sky, shooter.position.clone().normalize(), new Vector3(0, 0, 1));
  shooter.position.setLength(target.position.length());
  shooter.rocketHeld = false;
  const { hits } = duel(shooter, target, 3);
  assert.equal(hits.length, 1);
  assert.ok(hits[0].part === 'left' || hits[0].part === 'right', `nose hit lands on a wing, got ${hits[0].part}`);
});