orbit/dev/escort-flow.test.mjs

698 lines
38 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; the destination world ON
// SCREEN with the sky clear → the convoy breaks off, lands beside the
// world and fades out (the touchdown), and the contract 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,
// Render face (the delivery’s landing glide reads these — the real
// craft is a Phaser sprite; the fake carries the same surface).
scale: 1, alpha: 1, rotation: 0, artOffset: 0,
body: { setVelocity(x, y) { this.vx = x; this.vy = y; } },
setScale(s) { this.scale = s; return this; },
maxHull: kind === 'transport' ? 1500 : 60,
hull: kind === 'transport' ? 1500 : 60,
maxShields: kind === 'transport' ? 650 : 0,
shields: kind === 'transport' ? 650 : 0,
// The craft’s OWN top speed (the real TransportShip/NeutralShip read their
// data into this; the speed-match law reads it off the craft). The fighters
// carry their roster speed (180) — distinct from the transport’s 245, so
// the match and the restore are both observable.
speed: kind === 'transport' ? 245 : 180,
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' };
c._lastScript = script; // the test reads the wave’s roster (the lane’s law)
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._fired = [];
scene.comms = { trigger: (n) => scene._fired.push(n) };
scene.time = { now: 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() {} };
// The camera’s world rect (the delivery’s witness — the destination world
// being on screen). Defaults center on the origin; the delivery test
// re-centers it on the ship before the delivery frame.
scene.scale = { width: 1280, height: 720 };
scene.cameras = { main: { scrollX: 0, scrollY: 0 } };
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; the destination world is ON SCREEN
// and the sky is clear → the convoy BREAKS OFF to land (phase
// 'delivering'). It is not complete yet — the touchdown is still flying.
for (const e of scene.combat.enemies) e.dead = true;
scene.combat.active = false;
scene.combat.waveRun = { state: 'done' };
scene.escortDirector.nextComp = null;
// Bring the destination world into the camera’s world rect (the ship is
// on it — centre the view on the ship).
scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2;
scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2;
scene.time.now = 0;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', 'world on screen + sky clear → the DELIVERY begins (the convoy breaks off to land)');
assert.equal(st.isCompleted(JOB), false, '…but not complete yet (the touchdown is still flying)');
assert.ok(scene._escortTransport && !scene._escortTransport.destroyed, '…the convoy is still there, flying in beside the world');
// THE TOUCHDOWN — the convoy glides in beside the world and fades out
// (a fixed approach + fade). The first landing frame stamps the start;
// a later frame is mid-fade (alpha dropping); on the fade’s close the
// contract seals (complete + the claim’s payout).
scene.time.now = 1000; // the delivery’s first landing frame
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', '…mid-touchdown, still landing');
scene.time.now = 2600; // approach settled + the fade is running
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', '…the touchdown is still in flight');
assert.ok(scene._escortTransport && scene._escortTransport.alpha < 1, '…the convoy is fading out beside the world');
scene.time.now = 3350; // approach + fade complete
scene.updateEscort();
const routeF = st.routeFor(JOB);
assert.equal(routeF.escort.phase, 'delivered', 'the touchdown closes → DELIVERED');
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');
assert.ok(scene._fired.includes('escortLink'), "the TRANSPORT CAPTAIN’s link greeting arms (the convoy stands at the first waypoint)");
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)');
assert.ok(!scene._fired.includes('escortDelivered'), '…and the lost convoy never arms the delivery thanks (no thanks for a lost run)');
// 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._fired.filter((n) => n === 'escortLink').length >= 2, '…the greeting RE-arms on the retry (once:false — a fresh link is a fresh word)');
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 WITNESS GATES — the convoy + gate on screen before the jump; the convoy on screen before the 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 } };
st.accept(JOB);
st.setActive(JOB);
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 (the convoy stands)');
// Centre the camera on the ship (the convoy stands on its wing — near).
scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2;
scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2;
const GATE = { x: scene.ship.x + 200, y: scene.ship.y, bound: 90, radius: 40 };
assert.equal(scene._escortJumpHold(GATE), null, 'convoy + gate on screen → the jump is FREE');
GATE.x += 5000; // the gate flies off-screen
assert.equal(scene._escortJumpHold(GATE), 'hidden', 'gate OFF SCREEN → the jump HOLDS');
GATE.x = scene.ship.x + 200; // the gate back in view…
for (const a of scene._escortConvoyCrafts()) { a.x += 5000; a.y -= 5000; } // …the convoy off
assert.equal(scene._escortJumpHold(GATE), 'hidden', 'convoy OFF SCREEN → the jump HOLDS');
for (const a of scene._escortConvoyCrafts()) { a.x -= 5000; a.y += 5000; } // …the convoy back in view
assert.equal(scene._escortJumpHold(GATE), null, 'convoy + gate in view again → the jump is free');
// A LOST escort still holds: the SURVIVING convoy must be witnessed.
const crew = scene.combat.allies.filter((a) => a._escortCrew);
assert.ok(crew.length >= 1, 'an escort fighter stands');
crew[0].x += 5000; // one escort out of view
assert.equal(scene._escortJumpHold(GATE), 'hidden', 'a convoy craft out of view → the jump holds');
crew[0].x -= 5000;
crew[0].dead = true; // …or lost (the surviving convoy is the witness)
assert.equal(scene._escortJumpHold(GATE), null, 'the LOST escort is out of the witness (only the survivors stand)');
crew[0].dead = false;
// THE DELIVERY GATE — a jump’s scene restart (tear the sprites, the
// convoy re-forms on the wing at the ship’s new position), then the
// destination world in view + the sky clear, but the convoy OFF SCREEN:
// the delivery HOLDS (it witnesses its own landing).
scene.combat._teardownConvoy();
scene._escortTransport = null;
scene.systemRecord = { ...DEST_REC };
scene.systemContent = galaxy.ensureContent(DEST.destSystemId);
scene.ship.x = DEST_OBJ.x; scene.ship.y = DEST_OBJ.y;
scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2;
scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2;
scene.combat.enemies.push({ dead: false }); // a pirate still rides the lane (the sky is not clear)
scene.combat.active = true;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'the convoy re-forms on the wing after the jump (the sky is not clear, so the delivery holds)');
for (const e of scene.combat.enemies) e.dead = true; // the sky clears
scene.combat.active = false;
scene.combat.waveRun = { state: 'done' };
for (const a of scene._escortConvoyCrafts()) { a.x += 5000; a.y -= 5000; } // the convoy out of view
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'world on screen + sky clear, convoy OFF SCREEN → the delivery HOLDS');
for (const a of scene._escortConvoyCrafts()) { a.x -= 5000; a.y += 5000; } // the convoy back in view
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', 'world + convoy on screen + sky clear → the DELIVERY begins');
assert.ok(scene._fired.includes('escortDelivered'), "the TRANSPORT CAPTAIN’s delivery thanks arms (the convoy breaks off to the surface)");
// …and the jump holds for the whole touchdown (no skipping the landing).
assert.equal(scene._escortJumpHold(GATE), 'delivering', 'delivering → the jump HOLDS until the touchdown closes');
scene.time.now = 5000; // the delivery’s first landing frame (stamps the start)
scene.updateEscort();
scene.time.now = 9000; // approach + fade complete
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivered', '…the contract delivers');
assert.equal(scene._escortJumpHold(GATE), null, 'delivered → the jump is free again');
}
// ===========================================================================
console.log('== THE DESTINATION’S DOOR — the landing holds until the transport has reached it (or is lost) ==');
{
const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } };
// RENDEZVOUS (the convoy hasn’t even linked — the transport is still at
// the origin) — the player ahead of the job in the destination system.
{
const scene = makeScene('sys-home');
const st = scene.sideQuestState;
st.accept(JOB);
st.setActive(JOB);
scene._establishEscort(JOB, posting);
assert.equal(st.routeFor(JOB).escort.phase, 'rendezvous', 'pre-link');
scene.systemRecord = { ...DEST_REC }; // the player is in the destination system
scene.systemContent = galaxy.ensureContent(DEST.destSystemId);
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'rendezvous: the destination door HOLDS (the transport has not reached it)');
assert.equal(scene._escortLandingBlocked({ name: 'Some Other World' }), false, '…any OTHER world is free');
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name.toUpperCase() }), true, '…casing-independent (the panel’s display casing)');
}
// TRANSIT → DELIVERING (the touchdown in flight) → DELIVERED / FAILED.
{
const scene = makeScene('sys-home');
const st = scene.sideQuestState;
st.accept(JOB);
st.setActive(JOB);
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');
scene.systemRecord = { ...DEST_REC };
scene.systemContent = galaxy.ensureContent(DEST.destSystemId);
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'transit: the destination door HOLDS');
assert.equal(scene._escortLandingBlocked({ key: DEST.destObjectId }), true, '…the world’s key (its settlement id) is the same door');
const route = st.routeFor(JOB);
route.escort.phase = 'delivering';
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'delivering: the door HOLDS until the touchdown closes');
route.escort.phase = 'delivered';
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), false, 'delivered: the transport has REACHED it — the door OPENS');
route.escort.phase = 'failed';
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), false, 'failed: the transport is LOST — the door opens (the landing IS the reset’s remedy)');
}
}
// ===========================================================================
console.log('== THE SPEED MATCH — escort visible + out of combat: hull + fighters match the transport ==');
{
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);
scene._establishEscort(JOB, posting);
const normal = Number(config.get('ship.maxSpeed', 480)) || 480;
scene.ship.maxSpeed = normal;
// LINK the convoy (the ship reaches the rendezvous; the transport forms).
const wp = st.routeFor(JOB).waypoints[0];
scene.ship.x = wp[0]; scene.ship.y = wp[1];
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'the convoy is linked');
const tr = scene._escortTransport;
assert.ok(tr && !tr.dead, 'the transport is standing');
// TRANSPORT ON SCREEN + OUT OF COMBAT — the convoy flies as one: the hull
// takes the transport’s cap, and the escort fighters match its speed.
tr.x = 0; tr.y = 0; // the camera’s home view (scroll 0,0 — the craft is in view)
scene.combat.active = false;
const crew = scene.combat.allies.filter((a) => a._escortCrew);
assert.ok(crew.length >= 1, 'the escort fighters stand on the wing');
scene._syncEscortSpeed();
assert.equal(scene.ship.maxSpeed, tr.speed, "the player’s top speed MATCHES the transport’s");
for (const f of crew) assert.equal(f.speed, tr.speed, 'the escort fighters match the transport\u2019s speed');
// COMBAT OPENS — everyone falls back to their normal top speed.
scene.combat.active = true;
scene._syncEscortSpeed();
assert.equal(scene.ship.maxSpeed, normal, 'in combat the hull returns to NORMAL speed');
for (const f of crew) assert.equal(f.speed, f._baseSpeed, 'in combat the fighters return to their OWN speed');
// COMBAT CLOSES, but the transport is OUT OF VIEW — still normal.
scene.combat.active = false;
tr.x = 99999; tr.y = 99999;
scene._syncEscortSpeed();
assert.equal(scene.ship.maxSpeed, normal, 'transport off-screen: the hull stays NORMAL');
for (const f of crew) assert.equal(f.speed, f._baseSpeed, '…and the fighters hold their own speed');
// BACK IN VIEW — the match re-arms for the whole convoy; the close of the
// escort releases it.
tr.x = 0; tr.y = 0;
scene._syncEscortSpeed();
assert.equal(scene.ship.maxSpeed, tr.speed, 'back in view: the match re-arms');
for (const f of crew) assert.equal(f.speed, tr.speed, '…for the fighters too');
st.routeFor(JOB).escort.phase = 'delivered'; // the contract closes
scene._syncEscortSpeed();
assert.equal(scene.ship.maxSpeed, normal, 'escort complete: the hull returns to NORMAL speed');
for (const f of crew) assert.equal(f.speed, f._baseSpeed, '…and the fighters return to their OWN speed');
}
// ===========================================================================
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('== THE WAR MODEL — a faction rescue: link in hostile space, deliver back home ==');
{
// A FACTION galaxy: sys-zenith (the board’s own friendly system, zenith)
// holds a gate to sys-apex (the hostile neighbor, apex — the transport’s
// captors' ground). Both have a delivery/rendezvous world.
const FRECS = {
'sys-zenith': { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 55, zone: 'mid', type: 'void' },
'sys-apex': { id: 'sys-apex', name: 'Apex Verge', faction: 'apex', d: 60, zone: 'mid', type: 'void' },
};
const FADJ = { 'sys-zenith': ['sys-apex'], 'sys-apex': ['sys-zenith'] };
const FCONTENT = {
'sys-zenith': { planets: [{ id: 'sys-zenith-p1', name: 'Verge One', ordinal: 1, x: 10, y: 20 }], settlements: [] },
'sys-apex': { planets: [{ id: 'sys-apex-p1', name: 'Apex One', ordinal: 1, x: 300, y: 400 }], settlements: [] },
};
const fgalaxy = {
seed: SEED,
byId: new Map(Object.entries(FRECS).map(([k, v]) => [k, v])),
jumpGatesFor: (id) => (FADJ[id] ?? []).map((t) => FRECS[t]).filter(Boolean),
ensureContent: (id) => { if (!FCONTENT[id]) throw new Error(`no content for ${id}`); return FCONTENT[id]; },
contentOf: (id) => fgalaxy.ensureContent(id),
};
const FRIENDLY = FRECS['sys-zenith'];
const HOSTILE = FRECS['sys-apex'];
const FJOB = 'sq-sys-zenith-p1-escort-0';
// A scene standing in the FRIENDLY system (the board is here — the player
// accepts the rescue, then must fly OUT to the hostile ground to link).
const fscene = Object.create(GameScene.prototype);
fscene.playSfx = () => {};
fscene.consoleToast = () => {};
fscene.comms = { trigger: () => {} };
fscene.time = { now: 0 };
fscene.galaxy = fgalaxy;
fscene.systemRecord = { ...FRIENDLY };
fscene.systemContent = fgalaxy.ensureContent('sys-zenith');
fscene.systemPlanets = []; fscene.systemStations = []; fscene.systemGates = []; fscene.planet = null;
fscene.tetherField = null;
fscene.isHomeSystem = false;
fscene.patrolG = { clear() {}, lineStyle() {}, circle() {}, strokeCircle() {}, fillStyle() {}, fillCircle() {} };
fscene.ship = { x: 0, y: 0, addCredits() {} };
fscene.refreshCreditsHud = () => {};
fscene._routeFlightEnvelope = (id) => ({ region: [], anchors: [] });
fscene.sideQuestState = new SideQuestState();
fscene.combat = makeCombat();
fscene.escortDirector = makeDirector();
fscene._escortTransport = null;
fscene.scale = { width: 1280, height: 720 };
fscene.cameras = { main: { scrollX: 0, scrollY: 0 } };
const fpost = {
id: FJOB, systemId: 'sys-zenith', systemName: 'Zenith Verge', category: 'escort',
enemySystemId: 'sys-apex', enemyFaction: 'apex',
title: 'Stranded Freight', label: 'ESCORT', reward: { credits: 220 },
};
const st = fscene.sideQuestState;
st.accept(FJOB); st.setActive(FJOB);
fscene._establishEscort(FJOB, fpost);
const r0 = st.routeFor(FJOB);
assert.equal(r0.kind, 'escort', 'the route is typed escort');
assert.equal(r0.escort.phase, 'rendezvous', '…standing at its rendezvous');
assert.equal(r0.escort.originSystemId, 'sys-apex', 'the RENDEZVOUS is in the HOSTILE neighbor (the transport stands there)');
assert.equal(r0.escort.destSystemId, 'sys-zenith', '…and the DELIVERY is the board’s own friendly system');
assert.equal(r0.escort.enemyFaction, 'apex', '…hunted by its captors (the faction’s roster)');
assert.ok(Number.isFinite(r0.credits) && r0.credits > 0, 'the credits payout is fixed');
assert.ok(r0.xp > 0, 'the XP payout is fixed');
// The player flies OUT to the hostile ground and reaches the rendezvous —
// the convoy LINKS (the transport + its escort fighters form on the wing).
fscene.systemRecord = { ...HOSTILE };
fscene.systemContent = fgalaxy.ensureContent('sys-apex');
const wp = r0.waypoints[0];
fscene.ship.x = wp[0]; fscene.ship.y = wp[1];
fscene.escortDirector.nextComp = null;
fscene.updateEscort();
const r1 = st.routeFor(FJOB);
assert.equal(r1.escort.phase, 'transit', 'reaching the rendezvous in hostile space LINKS the convoy');
assert.ok(fscene._escortTransport && !fscene._escortTransport.dead, 'the TRANSPORT forms on the wing');
// THE LANE — the escort’s contact fires a wave of the CAPTORS' OWN fighters
// (the faction roster, not the pirate pool), even though this is faction space.
fscene.escortDirector.nextComp = { kind: 'pirates', pirates: 3, neutrals: 0 };
fscene.updateEscort();
const script = fscene.combat._lastScript;
assert.ok(fscene.combat.waveRun?.state === 'live', 'a LANE contact fires the escort wave in HOSTILE faction space');
const roster = script?.waves?.[0]?.roster ?? {};
const rkeys = Object.keys(roster);
assert.ok(rkeys.length >= 1, 'the lane’s roster stands');
assert.ok(rkeys.every((t) => t.startsWith('apex_')), `the lane is the CAPTORS' own fighters (got ${rkeys.join(',')})`);
assert.ok(!rkeys.includes('pirate_fighter'), '…and no pirate type (the war-model lane)');
// THE GATE FLY-THROUGH — the player carries the convoy back into the friendly
// system (the scene restart tears the sprites); the convoy RE-FORMS and the
// delivery is the friendly world (the sky-clear witness seals it).
fscene.combat._clearWave();
fscene.combat._teardownConvoy();
fscene._escortTransport = null;
fscene.systemRecord = { ...FRIENDLY };
fscene.systemContent = fgalaxy.ensureContent('sys-zenith');
const destObj = fgalaxy.ensureContent('sys-zenith').planets[0];
fscene.ship.x = destObj.x; fscene.ship.y = destObj.y;
fscene.combat.enemies.length = 0; fscene.combat.active = false; fscene.combat.waveRun = { state: 'done' };
fscene.escortDirector.nextComp = null;
fscene.cameras.main.scrollX = fscene.ship.x - fscene.scale.width / 2;
fscene.cameras.main.scrollY = fscene.ship.y - fscene.scale.height / 2;
fscene.time.now = 0;
fscene.updateEscort();
assert.equal(st.routeFor(FJOB).escort.phase, 'delivering', 'back in friendly space, world on screen + sky clear → the DELIVERY begins');
assert.ok(fscene._escortTransport && !fscene._escortTransport.dead, '…the convoy re-forms after the gate’s scene restart');
// THE TOUCHDOWN — a fixed approach + fade (deliverStart is stamped on the
// first delivering frame, so the seal lands ~2.3s after that frame).
fscene.time.now = 1000;
fscene.updateEscort();
assert.equal(st.routeFor(FJOB).escort.phase, 'delivering', '…mid-touchdown, still landing');
fscene.time.now = 3600;
fscene.updateEscort();
assert.equal(st.routeFor(FJOB).escort.phase, 'delivered', 'the touchdown closes → DELIVERED');
assert.equal(st.isCompleted(FJOB), true, '…and the contract is COMPLETE (the convoy made it home through the gate)');
const claim = fscene.claimSideQuest(FJOB);
assert.equal(claim.ok, true, 'the claim pays');
assert.ok(claim.credits > 0 && claim.xp > 0, '…the credits + the lane’s worth');
}
console.log('\nescort-flow: OK — the run (rendezvous→link→wave→jump→delivery), the witness gates (convoy + gate on screen before the jump; the convoy on screen before the landing), the destination’s door (no landing until the transport reaches it or is lost), the speed match (transport in view + out of combat: the hull and the escort fighters fly at the transport’s top speed; combat / off-screen / contract-end restore everyone), the failure (loss→land→reset→retry), the board (20 dossiers)');