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