512 lines
28 KiB
JavaScript
512 lines
28 KiB
JavaScript
// 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)');
|