orbit/dev/class-combat.test.mjs

98 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import fs from 'node:fs';
import { dirname, join } from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
// The real data/*.json into the config singleton (the classes.json numbers
// under test are the shipped ones — no private copy).
const { config } = await import(file('config/Config.js'));
const { barrels, angleDiff, intercept, rawAngle } = await import(file('combat/Aiming.js'));
const { perkDef } = await import(file('player/ClassModel.js'));
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const ok = (m) => console.log(' \u2714 ' + m);
const TAU = Math.PI * 2;
// ────────────────────────────────────────────────────────── the intercept
{
// 1 · a stationary target needs no lead at all.
const hit = intercept(0, 0, 300, 0, 0, 0, 600, 0.9);
assert.ok(Math.abs(hit.lead) < 1e-12, 'a parked target gets no lead');
assert.equal(hit.t, Math.hypot(300, 0) / 600, 'the flight time is still the straight distance');
assert.ok(Math.abs(hit.angle - rawAngle(0, 0, 300, 0)) < 1e-12, 'raw aim is the answer');
ok('stationary target: no lead, raw aim');
// 2 · a target crossing right in front of the muzzle, dead ahead at 90° away.
// Muzzle (0,0), bolt 600 px/s; target at (0,300) moving +x at 200 px/s.
// t solves (200t)² + 300² = (600t)² → t = 300/√(600²−200²) = 0.5303 s;
// it will be at x = 106.07 → angle atan2(300, 106.07) = 1.2310 rad (70.5°).
const t = intercept(0, 0, 0, 300, 200, 0, 600, 0);
assert.equal(t.solved, true, 'a crossing target IS interceptable');
assert.ok(Math.abs(t.t - 300 / Math.sqrt(600 ** 2 - 200 ** 2)) < 1e-9, `flight time ${t.t}`);
assert.ok(Math.abs(t.x - 200 * t.t) < 1e-9 && Math.abs(t.y - 300) < 1e-9, 'the meeting point rides the target’s velocity');
assert.ok(Math.abs(t.angle - Math.atan2(300, 200 * t.t)) < 1e-12, `lead angle ${t.angle}`);
// the bolt, fired on that heading, really does arrive where the target gets to
const bx = Math.cos(t.angle) * 600 * t.t;
const by = Math.sin(t.angle) * 600 * t.t;
assert.ok(Math.hypot(bx - t.x, by - t.y) < 1e-9, 'bolt and target meet at the solved point');
// …and the lead is a HEAD-WARD correction, not a random swing
const raw = rawAngle(0, 0, 0, 300);
assert.ok(Math.abs(t.lead) > 0.3 && Math.sign(t.lead) === Math.sign(angleDiff(Math.atan2(300, 106), raw)), 'the nose moves AHEAD of the target');
ok('crossing target: leads ahead by the bullet-speed-correct angle (bolt meets target)');
// 3 · a target running straight at the muzzle needs no lead either.
const head = intercept(0, 0, 400, 0, -150, 0, 600, 0.9);
assert.ok(Math.abs(head.angle) < 1e-9 || Math.abs(Math.abs(head.angle) - Math.PI) < 1e-9, 'still on the line');
assert.ok(Math.abs(head.lead) < 1e-9, 'no lead down the barrel line');
ok('head-on target: no lead (the solve is degenerate, and honest about it)');
// 4 · a target faster than the bolt can never be caught → raw aim, solved:false.
const run = intercept(0, 0, 100, 0, 1400, 200, 600, 0);
assert.equal(run.solved, false, 'unreachable');
assert.equal(run.angle, rawAngle(0, 0, 100, 0), 'falls back to raw aim');
ok('target outruns the bolt: falls back to raw aim');
// 5 · a perfect broadside pass at extreme range: an intercept exists but the
// computer's cap (data/classes.json → maxLead) refuses the swing.
const far = intercept(0, 0, 6000, 400, 0, 900, 600, Number(perkDef('targetingComputer').maxLead));
assert.ok(far.t === 0 && far.solved === false, `the ${perkDef('targetingComputer').maxLead}s lead cap holds (raw solve would need ~1.5s)`);
assert.equal(far.angle, rawAngle(0, 0, 6000, 400), 'and the nose stays on the ship');
ok(`out of the computer's reach (cap ${perkDef('targetingComputer').maxLead}s): no wild nose, raw aim`);
// 6 · numbers come from the data, not from code.
assert.ok(Number(perkDef('targetingComputer').maxLead) > 0, 'maxLead is configured');
assert.equal(Number(perkDef('dualCannon').barrels), 2, 'the Vanguard fires two');
ok('the perk numbers ride data/classes.json');
}
// ─────────────────────────────────────────────────────────────── barrels
{
const one = barrels(0, 1, 9, 20);
assert.equal(one.length, 1);
assert.deepEqual({ x: one[0].x, y: one[0].y }, { x: 20, y: 0 }, 'a single barrel sits on the centreline');
const two = barrels(0, 2, 9, 20);
assert.equal(two.length, 2);
assert.ok(Math.abs(two[0].x - 20) < 1e-9 && Math.abs(two[1].x - 20) < 1e-9, 'both barrels are at the same station');
assert.ok(Math.abs(two[0].y + 9) < 1e-9 && Math.abs(two[1].y - 9) < 1e-9, `offset ±9 off the centreline (${two[0].y}, ${two[1].y})`);
// rotate the firing line: the pair squares away with it (perpendicular offsets)
const up = barrels(-Math.PI / 2, 2, 9, 20); // firing "up" the screen
assert.ok(Math.abs(up[0].x) === 9 || Math.abs(Math.abs(up[0].x) - 9) < 1e-9, 'the pair swings out along x');
assert.ok(Math.abs(up[0].y + 20) < 1e-9 && Math.abs(up[1].y + 20) < 1e-9, 'both still at the nose station');
assert.ok(Math.sign(up[0].x) !== Math.sign(up[1].x), 'one barrel each side of the centreline');
const sep = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
assert.ok(Math.abs(sep - 18) < 1e-9, `the muzzles are ${sep}px apart — never inside each other`);
ok('dual barrels: 2 muzzles, symmetric ±barrelOffset, turning with the nose');
}