Add escort denial gates and transport captain comms

- Hold jumps while the escort convoy is linked or setting down: the
  player must have the transport, its surviving escorts, and the gate
  all on screen before leaving the system.
- Close the destination world's landing door until the transport has
  reached it (phase delivered) or been destroyed (phase failed), so a
  lost convoy never blocks the reset remedy.
- Arm OAC's escortDeny visual beat plus flat toasts on both denials.
- Register the transport captain character and add neutral-transport-link
  (convoy link greeting) and neutral-transport-delivery (delivery thanks)
  comms with audio pools, all once:false so they re-arm per contract.
- Require the convoy to be on screen before the delivery begins, not just
  the destination world.
- Update escort-flow and comms tests for the new witness gates, landing
  door, and transport captain beats.
This commit is contained in:
Brian Fertig 2026-09-26 09:42:56 -06:00
parent 21e9e68033
commit 64da303c23
13 changed files with 382 additions and 11 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -737,7 +737,7 @@
"color": "#67e863",
"text": "PATROL ROUTE COMPLETE — ITS REWARD IS READY TO CLAIM"
},
"_commentEscort": "The ESCORT contract's face (js/ui/JobBoardWindow.js + GameScene.updateEscort). escortActive = the row's live badge once the convoy links (the patrol's 'active' slot, re-labelled). escortRouteProgress = the row's progress line: the rendezvous leg, then the lane. escortLinkToast = the convoy forming on the player's wing. escortArrivedToast = the destination reached with the convoy intact. escortFailToast = the transport lost — the reset's call. escortResetToast = the landing's reset (a fresh convoy stands). escortRendezvousToast = the pre-link pointing.",
"_commentEscort": "The ESCORT contract's face (js/ui/JobBoardWindow.js + GameScene.updateEscort). escortActive = the row's live badge once the convoy links (the patrol's 'active' slot, re-labelled). escortRouteProgress = the row's progress line: the rendezvous leg, then the lane. escortLinkToast = the convoy forming on the player's wing. escortArrivingToast = the destination reached with the convoy intact (the delivery begins). escortArrivedToast = the touchdown closed (the contract completes). escortFailToast = the transport lost — the reset's call. escortResetToast = the landing's reset (a fresh convoy stands). escortRendezvousToast = the pre-link pointing. escortJumpHoldToast = the jump's flat denial while the convoy is linked but OUT OF VIEW (transport + its surviving escorts + the gate must all be on screen before the player jumps — GameScene.jumpThroughGate's escort guard). escortDeliveringToast = the jump's flat denial while the convoy is on its touchdown (hold until it lands). escortLandingDenyToast = the destination world's door (GameScene.startLanding's escort guard) — the planet/station the convoy is being delivered to holds its landing until the transport has REACHED it (phase 'delivered') or been DESTROYED (phase 'failed' — the landing's reset remedy). Every escort denial (jump hold + landing door) also arms OAC's 'escortDeny' visual comm (data/comms.json → oac-escort-wait — assets/speech/oac/escort-wait.mp3, the denial's voice).",
"escortActive": "CONVOY LINKED",
"escortRouteProgress": "LANE — {visited}/{total} SECTORS CROSSED",
"escortRendezvousToast": {
@ -770,6 +770,21 @@
"color": "#7ce8a4",
"text": "CONTRACT RESET — A FRESH CONVOY STANDS AT THE RENDEZVOUS"
},
"escortJumpHoldToast": {
"glyph": "⚓",
"color": "#ff5c77",
"text": "CONVOY OUT OF VIEW — BRING THE TRANSPORT, ITS ESCORTS AND THE GATE INTO VIEW TO JUMP"
},
"escortDeliveringToast": {
"glyph": "✈",
"color": "#7ce8a4",
"text": "CONVOY SETTING DOWN — HOLD POSITION UNTIL TOUCHDOWN"
},
"escortLandingDenyToast": {
"glyph": "✗",
"color": "#ff5c77",
"text": "ACCESS DENIED — THE CONVOY'S DESTINATION HOLDS UNTIL THE TRANSPORT LANDS"
},
"_commentBounty": "The BOUNTY HUNT's face (js/ui/JobBoardWindow.js + GameScene.updateBountyHunt). NOTE THE SILENCE: nothing here names the leg that holds the mark or a picket wave — the chain is hidden until it fires, so these lines only ever speak of what the player has already flown into. bountyLockBadge = the greyed row's plate (and the detail's lock line) under categories.bounty.minLevel; bountyAcceptDenyToast = the scene's refusal when a locked contract is pressed anyway.",
"bountyLockBadge": "LOCKED — REQUIRES LEVEL {level}",
"bountyLockSub": "BRING YOUR RECORD UP TO LEVEL {level} AND THE BOARD WILL LIST YOU",

View File

@ -111,9 +111,46 @@ const roster = listComms(cfg);
// 2/3, data/comms.json) + the payoff pair (trestle-q4-4/5, the
// quest-complete sign-off) + the combat pair (oac-enemies-detected /
// oac-enemies-defeated) + the pirate trio (pirate-attack / pirate-defeat /
// pirate-bounty-attack — the comming type's own voice, the mark's too) = 35).
check('the roster holds exactly the shipped comms', roster.length === 37);
// pirate-bounty-attack — the comming type's own voice, the mark's too)
// + the neutral pilot's pair (neutral-scan / neutral-scan-complete)
// + the escort denial (oac-escort-wait) + the transport captain's pair
// (neutral-transport-link / neutral-transport-delivery) = 40).
check('the roster holds exactly the shipped comms', roster.length === 40);
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
const transportCaptain = charEntry(cfg, 'transport_captain');
check('the transport captain is registered (label + role + the transport clip)', !!transportCaptain && transportCaptain.label === 'TRANSPORT CAPTAIN' && !!transportCaptain.role && transportCaptain.video === 'assets/videos/allies/neutral-transport.mp4');
check('the transport clip ships on disk', existsSync(join(root, 'assets/videos/allies/neutral-transport.mp4')));
const tLink = byId['neutral-transport-link'];
check(
'neutral-transport-link: the captain link greeting (visual, escortLink, transport_captain, re-arms)',
!!tLink && tLink.from === 'transport_captain' && tLink.type === 'visual' && tLink.trigger === 'escortLink' && tLink.once === false,
);
check('neutral-transport-link: the greeting', /good to see you/i.test(tLink?.text ?? '') && /appreciate you escorting us/i.test(tLink?.text ?? ''));
check('neutral-transport-link: the pool is the three transport clips', (tLink?.audioPool ?? []).length === 3 && tLink?.audioPool.every((f) => /^assets\/speech\/neutral\/transport-0[123]\.mp3$/.test(f)));
check('neutral-transport-link: the pool ships on disk', (tLink?.audioPool ?? []).every((f) => existsSync(join(root, f))));
check('neutral-transport-link: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortLink', new Set(['neutral-transport-link'])).length === 1);
const tDeliv = byId['neutral-transport-delivery'];
check(
'neutral-transport-delivery: the captain delivery thanks (visual, escortDelivered, transport_captain, re-arms)',
!!tDeliv && tDeliv.from === 'transport_captain' && tDeliv.type === 'visual' && tDeliv.trigger === 'escortDelivered' && tDeliv.once === false,
);
check('neutral-transport-delivery: the thanks', tDeliv?.text.includes('Thank you for the escort'));
check('neutral-transport-delivery: the pool is the three transport-success clips', (tDeliv?.audioPool ?? []).length === 3 && tDeliv?.audioPool.every((f) => /^assets\/speech\/neutral\/transport-success-0[123]\.mp3$/.test(f)));
check('neutral-transport-delivery: the pool ships on disk', (tDeliv?.audioPool ?? []).every((f) => existsSync(join(root, f))));
check('neutral-transport-delivery: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortDelivered', new Set(['neutral-transport-delivery'])).length === 1);
const escortWait = byId['oac-escort-wait'];
check(
'oac-escort-wait: the escort denial (visual, escortDeny, OAC, re-arms)',
!!escortWait &&
escortWait.from === 'oac' &&
escortWait.type === 'visual' &&
escortWait.trigger === 'escortDeny' &&
escortWait.once === false,
);
check('oac-escort-wait: the words, verbatim', escortWait?.text === 'Our access is being denied. We need to wait for the transport ship.');
check('oac-escort-wait: the escort-wait voice', escortWait?.audio === 'assets/speech/oac/escort-wait.mp3');
check('oac-escort-wait: the voice ships on disk', existsSync(join(root, 'assets/speech/oac/escort-wait.mp3')));
check('oac-escort-wait: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortDeny', new Set(['oac-escort-wait'])).length === 1);
const intro = byId['oac-intro'];
check(
'oac-intro: the fresh-run welcome (ack, newGame, OAC, once)',

View File

@ -163,6 +163,8 @@ function makeScene(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 };
@ -318,6 +320,7 @@ console.log('== THE FAILURE — transport lost → land → reset (the retry) ==
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');
@ -330,6 +333,7 @@ console.log('== THE FAILURE — transport lost → land → reset (the retry) ==
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();
@ -347,10 +351,133 @@ console.log('== THE FAILURE — transport lost → land → reset (the retry) ==
scene.escortDirector.nextComp = null;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'the retry LINKS (the convoy forms again)');
assert.ok(scene._fired.filter((n) => n === 'escortLink').length >= 2, '…the greeting RE-arms on the retry (once:false — a fresh link is a fresh word)');
assert.ok(scene._escortTransport && !scene._escortTransport.dead, '…a fresh transport, flying');
assert.equal(scene._escortTransport.hull, scene._escortTransport.maxHull, '…at full armor');
}
// ===========================================================================
console.log('== THE WITNESS GATES — the convoy + gate on screen before the jump; the convoy on screen before the delivery ==');
{
const scene = makeScene('sys-home');
const st = scene.sideQuestState;
const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } };
st.accept(JOB);
st.setActive(JOB);
scene._establishEscort(JOB, posting);
const wp = st.routeFor(JOB).waypoints[0];
scene.ship.x = wp[0]; scene.ship.y = wp[1];
scene.escortDirector.nextComp = null;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'linked (the convoy stands)');
// Centre the camera on the ship (the convoy stands on its wing — near).
scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2;
scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2;
const GATE = { x: scene.ship.x + 200, y: scene.ship.y, bound: 90, radius: 40 };
assert.equal(scene._escortJumpHold(GATE), null, 'convoy + gate on screen → the jump is FREE');
GATE.x += 5000; // the gate flies off-screen
assert.equal(scene._escortJumpHold(GATE), 'hidden', 'gate OFF SCREEN → the jump HOLDS');
GATE.x = scene.ship.x + 200; // the gate back in view…
for (const a of scene._escortConvoyCrafts()) { a.x += 5000; a.y -= 5000; } // …the convoy off
assert.equal(scene._escortJumpHold(GATE), 'hidden', 'convoy OFF SCREEN → the jump HOLDS');
for (const a of scene._escortConvoyCrafts()) { a.x -= 5000; a.y += 5000; } // …the convoy back in view
assert.equal(scene._escortJumpHold(GATE), null, 'convoy + gate in view again → the jump is free');
// A LOST escort still holds: the SURVIVING convoy must be witnessed.
const crew = scene.combat.allies.filter((a) => a._escortCrew);
assert.ok(crew.length >= 1, 'an escort fighter stands');
crew[0].x += 5000; // one escort out of view
assert.equal(scene._escortJumpHold(GATE), 'hidden', 'a convoy craft out of view → the jump holds');
crew[0].x -= 5000;
crew[0].dead = true; // …or lost (the surviving convoy is the witness)
assert.equal(scene._escortJumpHold(GATE), null, 'the LOST escort is out of the witness (only the survivors stand)');
crew[0].dead = false;
// THE DELIVERY GATE — a jump's scene restart (tear the sprites, the
// convoy re-forms on the wing at the ship's new position), then the
// destination world in view + the sky clear, but the convoy OFF SCREEN:
// the delivery HOLDS (it witnesses its own landing).
scene.combat._teardownConvoy();
scene._escortTransport = null;
scene.systemRecord = { ...DEST_REC };
scene.systemContent = galaxy.ensureContent(DEST.destSystemId);
scene.ship.x = DEST_OBJ.x; scene.ship.y = DEST_OBJ.y;
scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2;
scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2;
scene.combat.enemies.push({ dead: false }); // a pirate still rides the lane (the sky is not clear)
scene.combat.active = true;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'the convoy re-forms on the wing after the jump (the sky is not clear, so the delivery holds)');
for (const e of scene.combat.enemies) e.dead = true; // the sky clears
scene.combat.active = false;
scene.combat.waveRun = { state: 'done' };
for (const a of scene._escortConvoyCrafts()) { a.x += 5000; a.y -= 5000; } // the convoy out of view
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'world on screen + sky clear, convoy OFF SCREEN → the delivery HOLDS');
for (const a of scene._escortConvoyCrafts()) { a.x -= 5000; a.y += 5000; } // the convoy back in view
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', 'world + convoy on screen + sky clear → the DELIVERY begins');
assert.ok(scene._fired.includes('escortDelivered'), "the TRANSPORT CAPTAIN's delivery thanks arms (the convoy breaks off to the surface)");
// …and the jump holds for the whole touchdown (no skipping the landing).
assert.equal(scene._escortJumpHold(GATE), 'delivering', 'delivering → the jump HOLDS until the touchdown closes');
scene.time.now = 5000; // the delivery's first landing frame (stamps the start)
scene.updateEscort();
scene.time.now = 9000; // approach + fade complete
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivered', '…the contract delivers');
assert.equal(scene._escortJumpHold(GATE), null, 'delivered → the jump is free again');
}
// ===========================================================================
console.log('== THE DESTINATION’S DOOR — the landing holds until the transport has reached it (or is lost) ==');
{
const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } };
// RENDEZVOUS (the convoy hasn't even linked — the transport is still at
// the origin) — the player ahead of the job in the destination system.
{
const scene = makeScene('sys-home');
const st = scene.sideQuestState;
st.accept(JOB);
st.setActive(JOB);
scene._establishEscort(JOB, posting);
assert.equal(st.routeFor(JOB).escort.phase, 'rendezvous', 'pre-link');
scene.systemRecord = { ...DEST_REC }; // the player is in the destination system
scene.systemContent = galaxy.ensureContent(DEST.destSystemId);
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'rendezvous: the destination door HOLDS (the transport has not reached it)');
assert.equal(scene._escortLandingBlocked({ name: 'Some Other World' }), false, '…any OTHER world is free');
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name.toUpperCase() }), true, '…casing-independent (the panel’s display casing)');
}
// TRANSIT → DELIVERING (the touchdown in flight) → DELIVERED / FAILED.
{
const scene = makeScene('sys-home');
const st = scene.sideQuestState;
st.accept(JOB);
st.setActive(JOB);
scene._establishEscort(JOB, posting);
const wp = st.routeFor(JOB).waypoints[0];
scene.ship.x = wp[0]; scene.ship.y = wp[1];
scene.escortDirector.nextComp = null;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'linked');
scene.systemRecord = { ...DEST_REC };
scene.systemContent = galaxy.ensureContent(DEST.destSystemId);
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'transit: the destination door HOLDS');
assert.equal(scene._escortLandingBlocked({ key: DEST.destObjectId }), true, '…the world’s key (its settlement id) is the same door');
const route = st.routeFor(JOB);
route.escort.phase = 'delivering';
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), true, 'delivering: the door HOLDS until the touchdown closes');
route.escort.phase = 'delivered';
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), false, 'delivered: the transport has REACHED it — the door OPENS');
route.escort.phase = 'failed';
assert.equal(scene._escortLandingBlocked({ name: DEST_OBJ.name }), false, 'failed: the transport is LOST — the door opens (the landing IS the reset’s remedy)');
}
}
// ===========================================================================
console.log('== THE BOARD — all TWENTY escort dossiers stand and render ==');
{
@ -381,4 +508,4 @@ console.log('== THE BOARD — all TWENTY escort dossiers stand and render ==');
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)');
console.log('\nescort-flow: OK — the run (rendezvous→link→wave→jump→delivery), the witness gates (convoy + gate on screen before the jump; the convoy on screen before the landing), the destination’s door (no landing until the transport reaches it or is lost), the failure (loss→land→reset→retry), the board (20 dossiers)');

View File

@ -3370,6 +3370,31 @@ export class GameScene extends Phaser.Scene {
);
return;
}
// ESCORT CONVOY (data/sidequests.json → escort) — while the convoy is
// linked ('transit') or setting down ('delivering'), the jump HOLDS:
// the player must have the convoy — the transport + its surviving
// escorts — AND this gate ON SCREEN (the convoy is witnessed, not
// merely left behind: a jump re-stages the scene and would break the
// convoy off mid-contract). 'delivering' holds until the touchdown
// closes. Same flat-denial shape as the guards above.
{
const hold = this._escortJumpHold(gt);
if (hold) {
const t = config.get(
hold === 'delivering' ? 'sidequests.ui.escortDeliveringToast' : 'sidequests.ui.escortJumpHoldToast',
{},
);
const fallback = hold === 'delivering'
? 'CONVOY SETTING DOWN — HOLD POSITION UNTIL TOUCHDOWN'
: 'CONVOY OUT OF VIEW — BRING THE TRANSPORT, ITS ESCORTS AND THE GATE INTO VIEW TO JUMP';
this.comms?.trigger('escortDeny'); // OAC's denial beat (data/comms.json — the escort-wait voice)
this.consoleToast(String(t.text ?? fallback), {
glyph: t.glyph ?? '⚓',
glyphColor: t.color ? toCss(t.color) : undefined,
});
return;
}
}
// FACTION GATE LOCK (data/gates.json → jump.factionLock) — the gate
// comm window's grayed REQUEST JUMP + the "under Faction Control"
// message are the visible half; this is the scene's own flat denial
@ -3894,6 +3919,29 @@ export class GameScene extends Phaser.Scene {
);
return;
}
// ESCORT CONTRACT — the contract's DESTINATION WORLD holds its door
// while the transport is still out on the job: the player can't land
// on the planet/station the convoy is being delivered to until the
// transport has REACHED it (the delivery seals — phase 'delivered') or
// has been DESTROYED (phase 'failed' — that landing is the failure's
// reset's remedy, so a lost convoy never blocks the door). The denial
// rides OAC's 'escortDeny' beat (data/comms.json — the escort-wait
// voice) over the scene's own flat toast (the comms panel is already
// closed — the box is the ack).
if (this._escortLandingBlocked(target)) {
const t = config.get('sidequests.ui.escortLandingDenyToast', {});
this.playSfx('ui_close');
this.comms?.trigger('escortDeny');
this.consoleToast(
String(t.text ?? "ACCESS DENIED — THE CONVOY'S DESTINATION HOLDS UNTIL THE TRANSPORT LANDS"),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
durationMs: 3200,
},
);
return;
}
// Name-keyed world state (build records, tether labels) is keyed by
// the world's CANONICAL spelling — resolve whatever casing the
// target arrived in (UI display paths uppercase) to that spelling
@ -5728,6 +5776,100 @@ export class GameScene extends Phaser.Scene {
return circleInView(obj.x, obj.y, Number(obj.radius) || 24, view);
}
/**
* The live convoy crafts — the transport + its surviving escort fighters
* (deduped; the transport also rides the allies roster).
*/
_escortConvoyCrafts() {
const seen = new Set();
const out = [];
if (this._escortTransport && !this._escortTransport.dead && !this._escortTransport._removed) {
seen.add(this._escortTransport);
out.push(this._escortTransport);
}
for (const a of this.combat?.allies ?? []) {
if (a && !a.dead && !a._removed && (a._escortCrew || a.isTransport || a === this._escortTransport) && !seen.has(a)) {
seen.add(a);
out.push(a);
}
}
return out;
}
/** Is one convoy craft on the player's screen right now? */
_escortCraftOnScreen(a) {
const cam = this.cameras?.main;
if (!cam || !a) return false;
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
const r = Math.max(8, (Number(a.size) || Number(a.hitRadius) || 48) / 2);
return circleInView(a.x, a.y, r, view);
}
/**
* THE CONVOY ON SCREEN — every live convoy craft (the transport + its
* surviving escorts) flown into view, not merely near it. The escort's
* visibility law: the delivery (the convoy witnesses its own landing)
* AND the jump (the convoy is witnessed before the player leaves the
* system) both gate on it.
*/
_escortConvoyOnScreen() {
const convoy = this._escortConvoyCrafts();
return convoy.length > 0 && convoy.every((a) => this._escortCraftOnScreen(a));
}
/**
* THE ESCORT CONVOY'S JUMP HOLD — while the escort contract is in transit
* (or the convoy is setting down), a jump out of the system waits on the
* player witnessing the convoy: 'delivering' = the convoy is on its
* landing (hold until the touchdown closes); 'hidden' = the transport +
* its surviving escorts and this gate are not ALL on screen (fly them
* into view before jumping). `null` = the escort holds no claim on the
* jump (nothing linked yet — 'rendezvous' has no convoy in the scene to
* witness, nor does a finished/failed contract).
*/
_escortJumpHold(gt) {
const sel = this._escortRoute?.();
const esc = sel?.route?.escort;
if (!esc || (esc.phase !== 'transit' && esc.phase !== 'delivering')) return null;
if (esc.phase === 'delivering') return 'delivering';
const cam = this.cameras?.main;
if (!cam) return null; // no camera to witness on (a degenerate) — don't hold
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
const gateOk =
!!gt && Number.isFinite(gt.x) && Number.isFinite(gt.y) &&
circleInView(gt.x, gt.y, Number(gt.bound) || Number(gt.radius) || 60, view);
if (!gateOk) return 'hidden';
const convoy = this._escortConvoyCrafts();
if (convoy.length === 0) return null; // nothing standing (a degenerate) — nothing to witness
return convoy.every((a) => this._escortCraftOnScreen(a)) ? null : 'hidden';
}
/**
* THE DESTINATION'S DOOR — the contract's destination world (the planet /
* free-space station the convoy is being delivered to) holds its landing
* while the transport is still OUT on the job (phase 'rendezvous' — the
* convoy hasn't even linked — 'transit' — the lane — or 'delivering' —
* the touchdown in flight): the player can't land there until the
* transport has REACHED it (the delivery seals, phase 'delivered') or has
* been DESTROYED (phase 'failed' — that landing is the failure's reset's
// remedy, so a lost convoy never blocks the door). Any other world, any
// other phase: free.
*/
_escortLandingBlocked(target) {
const sel = this._escortRoute?.();
const esc = sel?.route?.escort;
if (!esc) return false;
if (esc.phase !== 'rendezvous' && esc.phase !== 'transit' && esc.phase !== 'delivering') return false;
if (!esc.destSystemId || esc.destSystemId !== this.systemRecord?.id) return false;
const tName = String(target?.name ?? '').trim().toLowerCase();
const tKey = String(target?.key ?? '').trim();
const dName = String(esc.destObjectName ?? '').trim().toLowerCase();
const dId = String(esc.destObjectId ?? '').trim();
if (dName !== '' && tName !== '' && tName === dName) return true;
if (dId !== '' && tKey !== '' && tKey === dId) return true;
return false;
}
/**
* THE DELIVERY — the convoy lands beside the destination world. The
* transport + its escort fighters break off the wing, glide in to a slot
@ -5928,6 +6070,7 @@ export class GameScene extends Phaser.Scene {
this._ensureEscortConvoy(id, route); // form on the wing
const t = config.get('sidequests.ui.escortLinkToast', {});
this.playSfx('construct');
this.comms?.trigger('escortLink'); // the TRANSPORT CAPTAIN's greeting (data/comms.json — neutral-transport-link)
this.consoleToast(
String(t.text ?? 'CONVOY LINKED — {name} HOLDS YOUR WING THROUGH TO {dest}')
.replace('{name}', esc.name || 'the convoy').replace('{dest}', esc.destSystemName || 'the destination'),
@ -5956,17 +6099,19 @@ export class GameScene extends Phaser.Scene {
if (this._escortLaneAllowed()) this._fireEscortWave(id, route);
if (esc.destSystemId === this.systemRecord.id) {
const obj = this._escortDestinationObject(route);
// THE WITNESS — the destination world is VISIBLE ON SCREEN (the player
// has flown it into view, not merely near it) and the sky is clear: the
// delivery begins — the convoy breaks off to land beside the world and
// fades out (phase 'delivering'; the seal + payout land on the fade's
// close, _sealEscortDelivery).
if (obj && this._escortObjectOnScreen(obj) && this._escortFieldClear()) {
// THE WITNESS — the destination world AND the convoy itself (the
// transport + its surviving escorts) are VISIBLE ON SCREEN (the player
// has flown them into view, not merely near them) and the sky is clear:
// the delivery begins — the convoy breaks off to land beside the world
// and fades out (phase 'delivering'; the seal + payout land on the
// fade's close, _sealEscortDelivery).
if (obj && this._escortObjectOnScreen(obj) && this._escortConvoyOnScreen() && this._escortFieldClear()) {
esc.phase = 'delivering';
esc.deliverStart = null; // stamped on the first delivering frame
this.sideQuestState.setRoute(id, route);
const t = config.get('sidequests.ui.escortArrivingToast', {});
this.playSfx('construct');
this.comms?.trigger('escortDelivered'); // the TRANSPORT CAPTAIN's thanks (data/comms.json — neutral-transport-delivery)
this.consoleToast(
String(t.text ?? 'CONVOY INBOUND — {name} IS SETTING DOWN AT {dest}')
.replace('{name}', esc.name || 'the convoy').replace('{dest}', esc.destObjectName || esc.destSystemName || 'the destination'),