// 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)');