orbit/dev/escort-flow.test.mjs

512 lines
28 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,
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._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 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 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 failure (loss→land→reset→retry), the board (20 dossiers)');