Sync escort convoy speed to transport while visible and out of combat

- Add `_syncEscortSpeed()` in GameScene: when an escort contract is open, the transport is on screen, and combat is inactive, cap the player hull and escort fighters at the transport's own top speed; restore each craft to its normal/roster speed otherwise.
- Fighters keep their base speed on `_baseSpeed` so the restore is exact even after a jump re-form.
- Per-frame writes only on change to avoid steady-state churn or steering disturbance.
- Hook the sync into `updateSideQuestRoute()` so it runs each frame off the escort lane (no-op elsewhere).
- Cover the new behavior in `escort-flow.test.mjs`: match applies in view/out-of-combat, restores on combat open, transport off-screen, and contract end; give test crafts distinct speeds so match and restore are observable.
This commit is contained in:
Brian Fertig 2026-09-27 11:21:56 -06:00
parent fdd0b4900c
commit f49051be69
2 changed files with 107 additions and 1 deletions

View File

@ -102,6 +102,11 @@ function makeCraft(kind) {
hull: kind === 'transport' ? 1500 : 60, hull: kind === 'transport' ? 1500 : 60,
maxShields: kind === 'transport' ? 650 : 0, maxShields: kind === 'transport' ? 650 : 0,
shields: 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, dead: false,
destroyed: false, destroyed: false,
isTransport: kind === 'transport', isTransport: kind === 'transport',
@ -478,6 +483,63 @@ console.log('== THE DESTINATION’S DOOR — the landing holds until the transpo
} }
} }
// ===========================================================================
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 =='); console.log('== THE BOARD — all TWENTY escort dossiers stand and render ==');
{ {
@ -508,4 +570,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)'); assert.equal(seen.size, 20, '…all twenty distinct (no two collapse to one row)');
} }
console.log('\nescort-flow: OK — the run (rendezvous→link→wave→jump→delivery), the witness gates (convoy + gate on screen before the jump; the convoy on screen before the landing), the destination’s door (no landing until the transport reaches it or is lost), the failure (loss→land→reset→retry), the board (20 dossiers)'); 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)');

View File

@ -6024,6 +6024,49 @@ export class GameScene extends Phaser.Scene {
return circleInView(a.x, a.y, r, view); return circleInView(a.x, a.y, r, view);
} }
/**
* THE ESCORT'S SPEED MATCH — while the escort contract is still open
* (rendezvous / transit / delivering — 'failed' and 'delivered' are over,
* as is a claimed or completed route) AND the transport is VISIBLE ON
* SCREEN (its own witness law, _escortCraftOnScreen) AND the player is OUT
* OF COMBAT (this.combat.active is the same predicate that raises the
* combat bar), the convoy's speed law goes to the TRANSPORT: the player's
* hull takes the transport's own top speed (data/combat.json → transport.speed,
* riding the craft's `speed`) as its cap, and the escort FIGHTERS match the
* transport's speed for the flight (their own base is kept on `_baseSpeed`
* so the restore is exact). The moment the transport drops out of view,
* combat opens, or the contract ends, every one of them falls back to its
* NORMAL speed (data/ship.json → ship.maxSpeed for the hull; the fighters'
* own roster speed). A per-frame write only on change (no steady-state
* churn, no steering disturbance).
*/
_syncEscortSpeed() {
const ship = this.ship;
const normal = Number(config.get('ship.maxSpeed', 480)) || 480;
const sel = this._escortRoute?.();
const esc = sel?.route?.escort;
const tr = this._escortTransport;
const open = esc && (esc.phase === 'rendezvous' || esc.phase === 'transit' || esc.phase === 'delivering');
const matching = !!(open && tr && !tr.dead && !tr._removed && !this.combat?.active && this._escortCraftOnScreen(tr));
if (ship) {
const target = matching ? (Number(tr.speed) || normal) : normal;
if (Number.isFinite(target) && ship.maxSpeed !== target) ship.maxSpeed = target;
}
// THE ESCORT FIGHTERS — match the transport's speed (the convoy flies as
// one), else restore each fighter's own base (kept on first sight — a
// jump's re-form stands a fresh sprite with its roster speed again).
if (ship || tr) {
const ref = matching ? (Number(tr.speed) || null) : null;
for (const a of this._escortConvoyCrafts()) {
if (a === tr || !a || a.dead || a._removed) continue;
if (typeof a.speed !== 'number' || !Number.isFinite(a.speed)) continue;
if (!Number.isFinite(a._baseSpeed)) a._baseSpeed = a.speed;
const want = ref !== null ? ref : a._baseSpeed;
if (a.speed !== want) a.speed = want;
}
}
}
/** /**
* THE CONVOY ON SCREEN — every live convoy craft (the transport + its * THE CONVOY ON SCREEN — every live convoy craft (the transport + its
* surviving escorts) flown into view, not merely near it. The escort's * surviving escorts) flown into view, not merely near it. The escort's
@ -6444,6 +6487,7 @@ export class GameScene extends Phaser.Scene {
/** Per-frame route update — the record's OWN kind picks the law (patrol untouched). */ /** Per-frame route update — the record's OWN kind picks the law (patrol untouched). */
updateSideQuestRoute() { updateSideQuestRoute() {
this._syncEscortSpeed(); // the escort's speed match (its own seam — a no-op off the escort lane)
const kind = this._sideRouteKind(); const kind = this._sideRouteKind();
if (kind === 'bounty') this.updateBountyHunt(); if (kind === 'bounty') this.updateBountyHunt();
else if (kind === 'escort') this.updateEscort(); else if (kind === 'escort') this.updateEscort();