From f49051be6960e1c42ac64e21d0ff05f18456ad1c Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 27 Sep 2026 11:21:56 -0600 Subject: [PATCH] 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. --- dev/escort-flow.test.mjs | 64 +++++++++++++++++++++++++++++++++++++++- js/scenes/GameScene.js | 44 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/dev/escort-flow.test.mjs b/dev/escort-flow.test.mjs index db6ed3d..ed01f21 100644 --- a/dev/escort-flow.test.mjs +++ b/dev/escort-flow.test.mjs @@ -102,6 +102,11 @@ function makeCraft(kind) { 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', @@ -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 =='); { @@ -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)'); } -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)'); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 800a184..b040f43 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -6024,6 +6024,49 @@ export class GameScene extends Phaser.Scene { 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 * 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). */ updateSideQuestRoute() { + this._syncEscortSpeed(); // the escort's speed match (its own seam — a no-op off the escort lane) const kind = this._sideRouteKind(); if (kind === 'bounty') this.updateBountyHunt(); else if (kind === 'escort') this.updateEscort();