698 lines
38 KiB
JavaScript
698 lines
38 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,
|
||
// 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)');
|