orbit/dev/escort-flow.test.mjs

349 lines
17 KiB
JavaScript
Raw 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.

// dev/escort-flow.test.mjs — the ESCORT contract's SCENE flow, verified
// headless (node dev/escort-flow.test.mjs — no browser, no Phaser render).
//
// The model (gate / destination / wave / ledger) is pinned in
// dev/escort.test.mjs. This pins the SCENE's frame law — the
// Object.create(GameScene.prototype) seam (the delivery-launch / bounty
// precedent): the escort's methods are driven with a mocked combat
// system, ship, and lane director, so the CONTRACT's sequence stands:
//
// THE RUN — accept → a RENDEZVOUS waypoint stands in the current
// system; the player reaches it → the convoy FORMS (the transport +
// 1–2 escort fighters, persistent) and the lane goes hot; a wave
// fires (mixed fighters, the transport's own guns live); the convoy
// RE-FORMS across a jump's scene restart; arrival at the destination
// object with the sky clear DELIVERS (complete + the claim's payout).
//
// THE FAILURE — the transport is LOST → the contract FAILS (the convoy
// breaks off cleanly); the player LANDS → the contract RESETS (a fresh
// rendezvous in the current system, a fresh transport at FULL armor,
// the SAME destination — the retry starts where they are).
//
// THE BOARD — all TWENTY escort dossiers stand: unique titles/bodies,
// and every one of them reachable on a neutral board's postings
// (the row's title + issuer + description all render).
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
const __dirname = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const configData = {};
for (const f of fs.readdirSync(join(__dirname, '../data'))) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(__dirname, '../data', f), 'utf8'));
}
config.init(configData);
const DATA = configData;
const {
sideQuestPostingsFor,
escortDestinationFor,
escortEncounterXp,
SideQuestState,
} = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href);
const SEED = 77031;
const JOB = 'sq-sys-home-p1-escort-0';
// A gate graph: sys-home (neutral, the posting's system) — sys-a (neutral)
// — sys-b (neutral); sys-zenith (faction) is excluded. sys-a holds a planet,
// sys-b a free-space station.
const RECS = {
'sys-home': { id: 'sys-home', name: 'Hearth', faction: null, d: 5, zone: 'near', type: 'main' },
'sys-a': { id: 'sys-a', name: 'Aldrin', faction: null, d: 35, zone: 'mid', type: 'void' },
'sys-b': { id: 'sys-b', name: 'Basalt', faction: null, d: 65, zone: 'far', type: 'void' },
};
const ADJ = {
'sys-home': ['sys-a', 'sys-zenith-excluded'],
'sys-a': ['sys-home', 'sys-b'],
'sys-b': ['sys-a'],
};
const CONTENT = {
'sys-home': { planets: [{ id: 'sys-home-p1', name: 'Hearth One', ordinal: 1, x: 0, y: 0 }], settlements: [] },
'sys-a': { planets: [{ id: 'sys-a-p1', name: 'Aldrin One', ordinal: 1, x: 100, y: 200 }], settlements: [] },
'sys-b': { planets: [], settlements: [{ id: 'sys-b-s1', name: 'Basalt Relay', anchor: { type: 'space' }, x: 300, y: 400 }] },
};
const galaxy = {
seed: SEED,
byId: new Map(Object.entries(RECS).map(([k, v]) => [k, v])),
records: Object.values(RECS),
jumpGatesFor: (id) => (ADJ[id] ?? []).map((t) => RECS[t]).filter(Boolean),
ensureContent: (id) => {
if (!CONTENT[id]) throw new Error(`no content for ${id}`);
return CONTENT[id];
},
contentOf: (id) => galaxy.ensureContent(id),
};
// ------------------------------------------------------------------ mocks
/** A transport/ally craft the fake combat spawns (the scene's contract). */
function makeCraft(kind) {
const c = {
kind,
x: 0, y: 0,
maxHull: kind === 'transport' ? 1500 : 60,
hull: kind === 'transport' ? 1500 : 60,
maxShields: kind === 'transport' ? 650 : 0,
shields: kind === 'transport' ? 650 : 0,
dead: false,
destroyed: false,
isTransport: kind === 'transport',
_escortCrew: kind === 'ally',
persistent: true,
destroy() { this.dead = true; this.destroyed = true; },
};
return c;
}
/** The fake COMBAT SYSTEM the escort's scene law reads (the seam's edge). */
function makeCombat() {
const c = {
allies: [],
enemies: [],
xp: { level: 3, grant() {} },
playerDown: false,
waveRun: null,
active: false,
fx: { releaseCraft() {} },
_setActive() {},
spawnTransport(x, y, index) { const t = makeCraft('transport'); t.x = x; t.y = y; c.allies.push(t); return t; },
spawnAlly(x, y, index, type, o = {}) { const a = makeCraft('ally'); a.x = x; a.y = y; a.escortMs = o.escortMs; c.allies.push(a); return a; },
startWaves(script, { onComplete } = {}) {
c.waveRun = { state: 'live' };
const total = script?.waves?.[0]?.total ?? 3;
for (let i = 0; i < total; i++) c.enemies.push({ dead: false });
c.active = true;
if (typeof onComplete === 'function') c._onWaveComplete = onComplete;
},
// Test driver: the wave's last pirate goes down (the run is done).
_clearWave() {
for (const e of c.enemies) e.dead = true;
c.waveRun = { state: 'done' };
c.active = false;
if (typeof c._onWaveComplete === 'function') { c._onWaveComplete(); c._onWaveComplete = null; }
},
// Test driver: a JUMP's scene restart tears the scene-local sprites.
_teardownConvoy() {
for (const a of c.allies) a.destroy();
c.allies.length = 0;
},
};
return c;
}
/** The fake ESCORT LANE DIRECTOR — returns exactly the comp the test sets. */
function makeDirector() {
return {
state: 'idle', nextRollAt: null, quietUntil: 0,
nextComp: null,
update(time, ctx) { const out = this.nextComp; this.nextComp = null; return out; },
};
}
/** A GAME SCENE with the escort's dependencies mocked (the seam's host). */
function makeScene(systemId) {
const rec = RECS[systemId];
const scene = Object.create(GameScene.prototype);
scene.playSfx = () => {};
scene.consoleToast = () => {};
scene.time = { currentTime: 0 };
scene.galaxy = galaxy;
scene.systemRecord = { ...rec };
scene.systemContent = galaxy.ensureContent(systemId);
scene.systemPlanets = [];
scene.systemStations = [];
scene.systemGates = [];
scene.planet = null;
scene.tetherField = null;
scene.isHomeSystem = false;
scene.patrolG = { clear() {}, lineStyle() {}, circle() {}, strokeCircle() {}, fillStyle() {}, fillCircle() {} };
scene.ship = { x: 0, y: 0, addCredits() {} };
scene.refreshCreditsHud = () => {};
scene._routeFlightEnvelope = () => ({ region: [], anchors: [] });
scene.sideQuestState = new SideQuestState();
scene.combat = makeCombat();
scene.escortDirector = makeDirector();
scene._escortTransport = null;
return scene;
}
const DEST = escortDestinationFor(SEED, JOB, 'sys-home', galaxy);
assert.ok(DEST, 'a destination stands for the test origin');
const DEST_REC = RECS[DEST.destSystemId];
const DEST_OBJ = galaxy.ensureContent(DEST.destSystemId)
.planets.concat(galaxy.ensureContent(DEST.destSystemId).settlements)
.find((o) => o.id === DEST.destObjectId);
// ===========================================================================
console.log('== THE RUN — accept → rendezvous → link → wave → jump → delivery ==');
{
const scene = makeScene('sys-home');
const st = scene.sideQuestState;
const posting = {
id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort',
title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 },
};
// ACCEPT — the scene takes the contract and stands the rendezvous.
st.accept(JOB);
st.setActive(JOB); // the ACTIVE slot (the contract in flight — the board's ACTIVATE door)
scene._establishEscort(JOB, posting);
const route0 = st.routeFor(JOB);
assert.equal(route0.kind, 'escort', 'the route is typed escort');
assert.equal(route0.escort.phase, 'rendezvous', 'the contract stands at its RENDEZVOUS');
assert.equal(route0.escort.originSystemId, 'sys-home', '…in the CURRENT (posting) system');
assert.equal(route0.escort.destSystemId, DEST.destSystemId, '…aimed at the destination');
assert.ok(route0.waypoints.length >= 1, 'a rendezvous waypoint stands');
assert.ok(Number.isFinite(route0.credits) && route0.credits > 0, 'the credits payout is fixed');
assert.ok(route0.xp > 0, 'the XP payout is fixed');
assert.ok(route0.escort.escortCount >= 1 && route0.escort.escortCount <= 2, '1–2 escort fighters');
assert.equal(route0.escort.transportHull, route0.escort.transportMaxHull, 'a fresh transport stands at FULL armor');
// RENDEZVOUS LINK — the ship reaches the convoy's standing point.
const wp = route0.waypoints[0];
scene.ship.x = wp[0]; scene.ship.y = wp[1];
scene.escortDirector.nextComp = null; // no contact while standing by
scene.updateEscort();
const route1 = st.routeFor(JOB);
assert.equal(route1.escort.phase, 'transit', 'reaching the rendezvous LINKS the convoy (transit)');
assert.ok(scene._escortTransport && !scene._escortTransport.dead, 'the TRANSPORT forms on the wing');
assert.equal(scene.combat.allies.filter((a) => a._escortCrew).length, route1.escort.escortCount, 'the escort FIGHTERS form (the record count)');
assert.ok(scene._escortTransport.persistent, 'the convoy is persistent (it never holds the jump gate)');
assert.ok(scene._escortTransport.hull >= scene._escortTransport.maxHull * 0.999, '…at its full standing hull');
// THE LANE — a contact stands; the escort's wave fires (mixed, one at a time).
scene.escortDirector.nextComp = { kind: 'pirates', pirates: 3, neutrals: 0 }; // the director's hit
scene.updateEscort();
assert.equal(scene.combat.waveRun?.state, 'live', 'a LANE CONTACT fires the escort wave');
assert.ok(scene.combat.enemies.filter((e) => !e.dead).length >= 1, '…with hostile fighters up');
// The convoy is STILL flying (persistent — the transport's guns are live).
assert.ok(scene._escortTransport && !scene._escortTransport.dead, 'the transport holds its wing through the fight');
// The wave's last pirate goes down.
scene.combat._clearWave();
assert.equal(scene.combat.waveRun.state, 'done', 'the wave is spent');
assert.equal(scene.combat.active, false, '…the sky is clear (the convoy is persistent — it does not hold active)');
// THE JUMP — the scene restart tears the sprites; a contact is STILL up
// in the lane (the sky is not clear) — so the convoy RE-FORMS but the
// delivery HOLDS (its witness is a clear sky).
scene.combat._teardownConvoy(); // the scene restart tears the sprites
scene._escortTransport = null;
scene.systemRecord = { ...DEST_REC }; // the jump lands in the destination
scene.systemContent = galaxy.ensureContent(DEST.destSystemId);
scene.ship.x = DEST_OBJ.x; scene.ship.y = DEST_OBJ.y;
scene.combat.enemies.push({ dead: false }); // a pirate still rides the lane
scene.combat.active = true;
scene.escortDirector.nextComp = null; // no fresh wave while we settle
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'the contract is still in flight (not yet delivered)');
assert.ok(scene._escortTransport && !scene._escortTransport.dead, 'after the jump the convoy RE-FORMS (self-healing)');
assert.equal(scene.combat.allies.filter((a) => a._escortCrew).length, route1.escort.escortCount, '…the escort fighters re-form too');
assert.equal(st.isCompleted(JOB), false, '…and it is NOT complete (the sky is not clear)');
// DELIVERY — the last pirate falls; at the destination object the sky is clear.
for (const e of scene.combat.enemies) e.dead = true;
scene.combat.active = false;
scene.combat.waveRun = { state: 'done' };
scene.escortDirector.nextComp = null;
scene.updateEscort();
const routeF = st.routeFor(JOB);
assert.equal(routeF.escort.phase, 'delivered', 'arrival + sky clear DELIVERS the contract');
assert.equal(st.isCompleted(JOB), true, '…and it is COMPLETE (ready for the claim)');
assert.ok(!scene._escortTransport || scene._escortTransport.destroyed, 'the convoy stands down (a clean break-off)');
// THE CLAIM — the payout stands (the existing quest reward pattern).
const claim = scene.claimSideQuest(JOB);
assert.equal(claim.ok, true, 'the claim pays');
assert.ok(claim.credits > 0, '…the credits (the band)');
assert.ok(claim.xp > 0, '…and the XP (the lane worth)');
}
// ===========================================================================
console.log('== THE FAILURE — transport lost → land → reset (the retry) ==');
{
const scene = makeScene('sys-home');
const st = scene.sideQuestState;
const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } };
st.accept(JOB);
st.setActive(JOB); // the ACTIVE slot (the contract in flight)
scene._establishEscort(JOB, posting);
const wp = st.routeFor(JOB).waypoints[0];
scene.ship.x = wp[0]; scene.ship.y = wp[1];
scene.escortDirector.nextComp = null;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'linked');
const transport = scene._escortTransport;
assert.ok(transport, 'the transport is flying');
// THE TRANSPORT IS LOST (its hull goes to zero — CombatSystem's kill).
transport.hull = 0;
transport.dead = true;
scene.escortDirector.nextComp = null;
scene.updateEscort();
const routeFail = st.routeFor(JOB);
assert.equal(routeFail.escort.phase, 'failed', 'the transport loss FAILS the contract');
assert.ok(!scene._escortTransport, '…the convoy breaks off (the reference is cleared)');
assert.ok(scene.combat.allies.every((a) => a.destroyed), '…cleanly (every convoy craft stands down)');
// THE REMEDY — the player LANDS (startLanding's hook → _escortResetForLanding).
scene._escortResetForLanding();
const routeReset = st.routeFor(JOB);
assert.equal(routeReset.escort.phase, 'rendezvous', 'the landing RESETS the contract (a fresh rendezvous)');
assert.equal(routeReset.escort.originSystemId, scene.systemRecord.id, '…in the CURRENT system (the retry starts where they are)');
assert.equal(routeReset.escort.destSystemId, DEST.destSystemId, '…the SAME destination (a signed contract is a signed contract)');
assert.equal(routeReset.escort.transportHull, routeReset.escort.transportMaxHull, '…a fresh transport at FULL armor');
assert.ok(routeReset.waypoints.length >= 1 && (routeReset.waypoints[0][0] !== wp[0] || routeReset.waypoints[0][1] !== wp[1]), '…a fresh standing point (the retry beacon is new)');
assert.equal(scene.escortDirector.state, 'idle', '…the lane director is re-armed');
// And it can be flown again: the fresh rendezvous links a fresh convoy.
const wp2 = routeReset.waypoints[0];
scene.ship.x = wp2[0]; scene.ship.y = wp2[1];
scene.escortDirector.nextComp = null;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'the retry LINKS (the convoy forms again)');
assert.ok(scene._escortTransport && !scene._escortTransport.dead, '…a fresh transport, flying');
assert.equal(scene._escortTransport.hull, scene._escortTransport.maxHull, '…at full armor');
}
// ===========================================================================
console.log('== THE BOARD — all TWENTY escort dossiers stand and render ==');
{
const pool = DATA.sidequests.descriptions.neutral.escort;
assert.equal(pool.length, 20, 'twenty escort dossiers in the data');
assert.equal(new Set(pool.map((e) => e.title)).size, 20, '…with unique titles');
assert.ok(pool.every((e) => typeof e.body === 'string' && e.body.trim().length > 40), '…each with a real body (not a placeholder)');
// Every dossier is REACHABLE on a neutral board (the posting's title /
// issuer / description all render for the row the UI paints).
const neutral = RECS['sys-home'];
const seen = new Map(); // title → posting
for (let i = 1; i <= 60; i++) {
const posts = sideQuestPostingsFor(SEED, neutral, `p${i}`, galaxy, 99);
for (const p of posts) {
if (p.category !== 'escort') continue;
assert.ok(p.title && p.title.trim().length > 0, `the row's title renders (${p.title})`);
assert.ok(p.issuer && p.issuer.trim().length > 0, '…the issuer line renders');
assert.ok(p.description && p.description.trim().length > 40, '…the body renders (a full sentence, not a stub)');
assert.ok(p.label && p.accent, '…the category label + accent stand');
assert.ok(p.reward.credits >= 5, '…the reward meets the floor');
seen.set(p.title, p);
}
}
const dataTitles = new Set(pool.map((e) => e.title));
const missing = [...dataTitles].filter((t) => !seen.has(t));
assert.equal(missing.length, 0, `every one of the 20 stands on a board (missing: ${missing.join(', ') || 'none'})`);
assert.equal(seen.size, 20, '…all twenty distinct (no two collapse to one row)');
}
console.log('\nescort-flow: OK — the run (rendezvous→link→wave→jump→delivery), the failure (loss→land→reset→retry), the board (20 dossiers)');