Add Escort side quest: convoy rendezvous, elevated lane combat, and rese

- New "Escort" contract type on neutral job boards (minLevel 3): the player
  links a persistent transport + 1-2 escort fighters at a local waypoint,
  flies them through a seeded 1-2 hop gate route to a planet/station, and
  delivers with the sky clear.
- TransportShip extends NeutralShip with a lapsed break-off window and a
  `persistent` flag so CombatSystem's active/neutralOnly gates skip it; the
  convoy never holds the jump gate or landing door open.
- Dedicated escort EncounterDirector (9-18s rolls, 85-100% chance) fires
  mixed-fighter waves ~5x more frequently than normal encounters; one wave at
  a time via the patrol contact's law.
- Transport loss fails the contract; landing on a planet resets it with a
  fresh rendezvous in the current system and full-armor transport, same
  destination.
- 20 unique escort dossier descriptions, reward band raised to 120-175 CR.
- Three new test suites: escort.test.mjs (model), escort-flow.test.mjs
  (scene flow), escort-browser.test.mjs (real browser boot + board render).
This commit is contained in:
Brian Fertig 2026-09-23 05:45:18 -06:00
parent 6f77ada8ad
commit 0a53931f16
16 changed files with 2117 additions and 46 deletions

View File

@ -1 +1 @@
{"version":1,"dirMtimeMs":1790080392798.7607,"goals":[]}
{"version":1,"dirMtimeMs":1790137091865.154,"goals":[]}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,101 @@
{
"version": 3,
"id": "mudi9u8o-nektab",
"objective": "Add a new \"Escort\" side quest type to the job board where the player rendevouses with a neutral transport (and 1-2 escort fighters) at a local waypoint, leads them through a jump-gate route of 1-2 hops to a planet or space station in a reachable system amid heavily elevated hostile encounters, and completes the quest on arrival with no enemies on screen; transports are ~2.5x fighter size using new spritesheet frames, and if the transport is destroyed the player must land on a planet to reset and retry the quest.",
"status": "complete",
"autoContinue": true,
"usage": {
"tokensUsed": 34568477,
"activeSeconds": 4185
},
"sisyphus": false,
"revision": 488,
"createdAt": "2026-09-23T02:50:32.664Z",
"updatedAt": "2026-09-23T04:18:11.865Z",
"scheduler": {
"version": 1,
"owner": "01a0cc17-b86f-72ee-a9b2-e20965fed2b9",
"generation": "cecf5829-a4f5-4101-9442-d5d08eaa1365",
"used": 1,
"phase": "idle",
"repairUsed": false
},
"taskList": {
"tasks": [
{
"id": "escort-data",
"title": "Define Escort category in quest data",
"status": "complete",
"verificationContract": "sidequest data contains an Escort category with enabled:true, 20 distinct escort mission descriptions, neutral-only posting, minLevel gate; existing categories unaffected",
"completedAt": "2026-09-23T03:14:27.732Z",
"evidence": "data/sidequests.json: escort enabled:true, neutral-only, minLevel 3, 20 distinct descriptions, reward band, escort rules block + UI strings. dev/escort.test.mjs (gate/posting) + dev/sidequests.test.mj"
},
{
"id": "escort-transport-ship",
"title": "Add transport ship class and assets",
"status": "complete",
"verificationContract": "Ship definition uses assets/images/ships-transport.png frames 0-3 at 180x320, rendered ~2.5x fighter scale; transport has substantially higher HP and stronger weapons than fighters; visible and hittable in game",
"completedAt": "2026-09-23T03:42:53.299Z",
"evidence": "TransportShip.js (extends NeutralShip, persistent) + data/combat.json transport (135px=2.5x fighter 54px, hull 1500/shields 650 vs 60/0, volley dmg14x7 vs 8-9); CombatSystem.spawnTransport + persisten"
},
{
"id": "escort-flow",
"title": "Implement escort quest flow (rendezvous, route, follow, destination)",
"status": "complete",
"verificationContract": "Accepting from job board spawns a rendezvous waypoint in the current system; transport + 1-2 escort fighters form up and follow the player; destination is a planet or space station 1-2 hops away in a gate-reachable system, seeded consistently; destination waypoint set after rendezvous",
"completedAt": "2026-09-23T03:50:51.670Z",
"evidence": "GameScene: acceptSideQuest→_establishEscort (rendezvous waypoint via buildPatrolRoute fork 'escort'; destination via escortDestinationFor = 1-2 hop gate-reachable neutral, seeded, planet/station objec"
},
{
"id": "escort-combat",
"title": "Elevated mixed enemy waves during escort",
"status": "complete",
"verificationContract": "Encounter chance along the route is highly elevated vs. normal travel; enemies spawn in waves of mixed fighter types with larger concurrent counts; transport participates with its own weapons",
"completedAt": "2026-09-23T03:51:34.196Z",
"evidence": "Escort director (EncounterDirector, sidequests.escort.encounters: roll 9-18s, chance 85-100%, cooldown 5-10s) vs normal (roll 45-90s, chance 50-60%, cooldown 30-60s) = ~5x elevated. Waves: 8 total / 4"
},
{
"id": "escort-fail-success",
"title": "Failure/reset and quest completion rules",
"status": "complete",
"verificationContract": "Transport destroyed => player must land on a planet to reset the quest and re-accept/retry; arrival at destination planet/station with no enemy fighters on screen completes the quest and grants rewards per existing quest reward patterns",
"completedAt": "2026-09-23T03:52:18.710Z",
"evidence": "updateEscort: transport.dead → phase 'failed' + toast + _escortBreakConvoy (clean teardown). startLanding hook → _escortResetForLanding: phase 'failed' only; re-stands rendezvous in CURRENT system (bu"
},
{
"id": "escort-verify",
"title": "End-to-end verification and save/load check",
"status": "complete",
"verificationContract": "Manual playthrough: accept on board, rendezvous, multi-hop route, wave combats, success and transport-loss reset paths; mid-quest save/reload preserves escort state; 20 descriptions all render correctly in the job board UI",
"completedAt": "2026-09-23T04:15:42.055Z",
"evidence": "69/69 dev tests pass. Escort verified 3 ways: escort.test.mjs (model: gate/destination/wave/XP/ledger+save round-trip), escort-flow.test.mjs (scene flow: accept→rendezvous→link→wave→jump→delivery; fai"
}
],
"blockCompletion": true,
"proposedAt": "2026-09-23T02:50:30.355Z"
},
"stopReason": "agent",
"archivedPath": ".pi/goals/archived/goal_2026092222181186_mudi9u8o-nektab.md"
}
# Goal Prompt
Add a new "Escort" side quest type to the job board where the player rendevouses with a neutral transport (and 1-2 escort fighters) at a local waypoint, leads them through a jump-gate route of 1-2 hops to a planet or space station in a reachable system amid heavily elevated hostile encounters, and completes the quest on arrival with no enemies on screen; transports are ~2.5x fighter size using new spritesheet frames, and if the transport is destroyed the player must land on a planet to reset and retry the quest.
## Progress
- Status: complete
- Auto-continue: on
- Sisyphus mode: no
- Time spent: 1h09m45s
- Tokens used: 35M (34,568,477) tokens
## Tasks
<!-- blockCompletion: true -->
- [x] escort-data: Define Escort category in quest data — evidence: data/sidequests.json: escort enabled:true, neutral-only, minLevel 3, 20 distinct descriptions, reward band, escort rules block + UI strings. dev/escort.test.mjs (gate/posting) + dev/sidequests.test.mj
- [x] escort-transport-ship: Add transport ship class and assets — evidence: TransportShip.js (extends NeutralShip, persistent) + data/combat.json transport (135px=2.5x fighter 54px, hull 1500/shields 650 vs 60/0, volley dmg14x7 vs 8-9); CombatSystem.spawnTransport + persisten
- [x] escort-flow: Implement escort quest flow (rendezvous, route, follow, destination) — evidence: GameScene: acceptSideQuest→_establishEscort (rendezvous waypoint via buildPatrolRoute fork 'escort'; destination via escortDestinationFor = 1-2 hop gate-reachable neutral, seeded, planet/station objec
- [x] escort-combat: Elevated mixed enemy waves during escort — evidence: Escort director (EncounterDirector, sidequests.escort.encounters: roll 9-18s, chance 85-100%, cooldown 5-10s) vs normal (roll 45-90s, chance 50-60%, cooldown 30-60s) = ~5x elevated. Waves: 8 total / 4
- [x] escort-fail-success: Failure/reset and quest completion rules — evidence: updateEscort: transport.dead → phase 'failed' + toast + _escortBreakConvoy (clean teardown). startLanding hook → _escortResetForLanding: phase 'failed' only; re-stands rendezvous in CURRENT system (bu
- [x] escort-verify: End-to-end verification and save/load check — evidence: 69/69 dev tests pass. Escort verified 3 ways: escort.test.mjs (model: gate/destination/wave/XP/ledger+save round-trip), escort-flow.test.mjs (scene flow: accept→rendezvous→link→wave→jump→delivery; fai

View File

@ -100,3 +100,16 @@
{"type":"audit_result","goalId":"muc162bo-njvm8l","verdict":"approved","report":"Tests pass and the data file looks rich. Now let me inspect the actual code implementing these claims.\n\nNow I'll independently run the browser verification harnesses (dev server + headless Chromium via CDP) to confirm the executor's claims.\n\nAll evidence checks pass. Cleaning up the background processes I started:\n\nI've completed a full independent audit. Summary of what I verified against real artifacts (not the claim):\n\n**Tests (ran myself):**\n- `node dev/run-all.mjs` → ALL 66 TESTS PASSED, exit 0 — includes `encounters.test.mjs`, `neutral.test.mjs`, and the neutral-roster block in `combat.test.mjs` (all re-run individually: PASS).\n- `dev/combat-demo-check.mjs` (headless CDP, real browser) → **PASS** independently re-run: 7-pattern range, `?combat` boot, all 7 combat_demo waves.\n- `dev/encounter-hud-check.mjs` → **PASS** independently re-run: mixed encounter (arrows at both pirates' edges px-verified, never at neutrals; threat layer respects data; ally fire + pirate fire; kill feed live; survivors escort → break off → `active` false), neutrals-only (zero arrows/markers, escorts fly near player, never fire, 0 player damage, leave), and the Pirate Problems ambush (drain 3→3→3→2→1→0, state done).\n\n**Data-driven tunables (data/combat.json):** `encounters` section (45–90 s interval, 0.5–0.6 chance, 30–60 s cooldown, weights 1:1:1, bands 0–2→1–2 / 3–5→2–3 / 6+→4–5 with mixed 2–3 pirates + 1–2 neutrals at high end); `neutral` base + `neutralTypes` with 8 crafts on frames 20–27 (sheet is 1280×1280 = 10×10 frames — confirmed), 8 distinct patterns (incl. new `volley` in Weapons.js), monotonic hull/shields ramp, xp = (hull+shields)/5, escort 10–15 s.\n\n**Code seams (read and traced):**\n- Director: `Encounters.js` pure clock (interval/chance/cooldown/one-at-a-time/allowed-gate) + the ms-scaling regression guard; tests cover deterministic cadence, band bounds (45–90 s, 30–60 s at both rng extremes), 2000-run statistical median, even-kind statistics, and size bands.\n- Wiring: `GameScene.js:2562–2576` — `encounterAllowed(ship, isHomeSystem, faction, waveRunLive, playerDown)` + `live: combat.active` + `xpLevel: combat.xp.level`; `_spawnEncounter` materializes both rosters in the `_offscreenRadius` band (past camera edge, tested) with `respawn=false` one-shots.\n- Ally AI: `NeutralShip.js` approach → engage (nearest pirate, shared `firePattern` dispatch, player never a target) → escort (per-ship 10–15 s window) → break (flies off, `gone` when fully out of `visibleView`) → despawn; `active` drops when the last escort is off-screen.\n- No friendly fire: auto-fire and all target picks draw only from `this.enemies`; 'ally' bolts/seekers hit enemies only; 'enemy' bolts/seekers hit player + allies; verified by unit tests and the browser px checks.\n- Pirate targeting: `pickEnemyTarget` = player in range, else nearest in-range escort, else classic chase — pirates-only path unchanged.\n- Kills: `killEnemy` single XP grant (escort kills still bank pirate XP; ally kills bank none).\n\nThe one item not literally observed as a live browser soak (\"no encounters over several minutes in the home system\") is covered exactly as the verification contract prescribes: the gate is pure logic (`encounterAllowed`), millisecond-tested for home/faction/wave-run/downed states, and the production wiring passes the real `isHomeSystem`/`faction`/`waveRun` state. No gaps, no scaffold, no contradicted requirements found.\n\n<approved/>","at":"2026-09-22T12:33:12.715Z"}
{"type":"goal_completed","goalId":"muc162bo-njvm8l","archivePath":".pi/goals/archived/goal_2026092206331272_muc162bo-njvm8l.md","at":"2026-09-22T12:33:12.801Z"}
{"type":"goal_archived","goalId":"muc162bo-njvm8l","archivePath":".pi/goals/archived/goal_2026092206331272_muc162bo-njvm8l.md","at":"2026-09-22T12:33:12.803Z"}
{"type":"goal_created","goalId":"mudi9u8o-nektab","objective":"Add a new \"Escort\" side quest type to the job board where the player rendevouses with a neutral transport (and 1-2 escort fighters) at a local waypoint, leads them through a jump-gate route of 1-2 hops to a planet or space station in a reachable system amid heavily elevated hostile encounters, and completes the quest on arrival with no enemies on screen; transports are ~2.5x fighter size using new spritesheet frames, and if the transport is destroyed the player must land on a planet to reset and retry the quest.","sisyphus":false,"autoContinue":true,"at":"2026-09-23T02:50:32.664Z"}
{"type":"task_list_set","goalId":"mudi9u8o-nektab","taskCount":6,"blockCompletion":true,"at":"2026-09-23T02:50:32.664Z"}
{"type":"task_complete","goalId":"mudi9u8o-nektab","taskId":"escort-data","evidence":"data/sidequests.json: escort enabled:true, neutral-only, minLevel 3, 20 distinct descriptions, reward band, escort rules block + UI strings. dev/escort.test.mjs (gate/posting) + dev/sidequests.test.mj","at":"2026-09-23T03:14:27.732Z"}
{"type":"task_complete","goalId":"mudi9u8o-nektab","taskId":"escort-transport-ship","evidence":"TransportShip.js (extends NeutralShip, persistent) + data/combat.json transport (135px=2.5x fighter 54px, hull 1500/shields 650 vs 60/0, volley dmg14x7 vs 8-9); CombatSystem.spawnTransport + persisten","at":"2026-09-23T03:42:53.300Z"}
{"type":"task_complete","goalId":"mudi9u8o-nektab","taskId":"escort-flow","evidence":"GameScene: acceptSideQuest→_establishEscort (rendezvous waypoint via buildPatrolRoute fork 'escort'; destination via escortDestinationFor = 1-2 hop gate-reachable neutral, seeded, planet/station objec","at":"2026-09-23T03:50:51.671Z"}
{"type":"task_complete","goalId":"mudi9u8o-nektab","taskId":"escort-combat","evidence":"Escort director (EncounterDirector, sidequests.escort.encounters: roll 9-18s, chance 85-100%, cooldown 5-10s) vs normal (roll 45-90s, chance 50-60%, cooldown 30-60s) = ~5x elevated. Waves: 8 total / 4","at":"2026-09-23T03:51:34.196Z"}
{"type":"task_complete","goalId":"mudi9u8o-nektab","taskId":"escort-fail-success","evidence":"updateEscort: transport.dead → phase 'failed' + toast + _escortBreakConvoy (clean teardown). startLanding hook → _escortResetForLanding: phase 'failed' only; re-stands rendezvous in CURRENT system (bu","at":"2026-09-23T03:52:18.710Z"}
{"type":"task_complete","goalId":"mudi9u8o-nektab","taskId":"escort-verify","evidence":"69/69 dev tests pass. Escort verified 3 ways: escort.test.mjs (model: gate/destination/wave/XP/ledger+save round-trip), escort-flow.test.mjs (scene flow: accept→rendezvous→link→wave→jump→delivery; fai","at":"2026-09-23T04:15:42.055Z"}
{"type":"completion_requested","goalId":"mudi9u8o-nektab","at":"2026-09-23T04:16:51.557Z"}
{"type":"audit_started","goalId":"mudi9u8o-nektab","at":"2026-09-23T04:16:51.559Z"}
{"type":"audit_result","goalId":"mudi9u8o-nektab","verdict":"approved","report":"All claimed artifacts check out against the real code and data:\n\n**Data / model**\n- `data/sidequests.json`: `escort` category `enabled: true`, neutral-only, minLevel 3; escort rules block (waypoints [1,1], visitRadius 460, destinationHops [1,2], escortCount [1,2], transportFrame [0,3], dedicated encounter director numbers: roll 9–18s @ 85–100% chance, cooldown 5–10s vs normal 45–90s @ 50–60% / cooldown 30–60s — genuinely \"heavily elevated\"); wave total 8 / concurrent 4 vs normal bands 1–5; 20 escort descriptions, all 20 titles and bodies unique (verified programmatically); reward band [120,175].\n- `data/combat.json`: `transport` section — size 135 vs fighter size 54 (exactly 2.5x), hull 1500/shields 650, volley weapon, texture `assets/images/ships-transport.png` at 180×320 frames. Sheet exists on disk, 1800×640 (10×2 grid of 180×320 frames), so frames 0–3 are valid new spritesheet frames.\n\n**Code**\n- `js/combat/TransportShip.js`: persistent neutral freighter, portrait-sheet re-scale by long side (135/320), never targets player. `CombatSystem.spawnTransport` seam present.\n- `js/quests/SideQuests.js`: `escortDestinationFor` seeded 1–2 hop walk over the real jump-gate graph (neutral neighbors, planet first then free-space station), board feasibility gate so uncompletable escorts never post, wave roster with validation, XP payout, route-record normalization.\n- `js/scenes/GameScene.js`: accept → `_establishEscort` (rendezvous waypoint in posting's own system); `updateEscort` frame law — rendezvous link within radius → transit; convoy (`_ensureEscortConvoy`) re-forms across jump scene-restarts with record armor; escort director fires one wave at a time via `_fireEscortWave`; delivery only when at destination object within radius **and** `_escortFieldClear()` (no live enemies, combat inactive); transport death → phase `failed` + convoy teardown; `startLanding` hooks `_escortResetForLanding` (failed-only guard, fresh rendezvous in current system, transport restored to full standing armor, same destination, director re-armed).\n\n**Verification**\n- Ran the suite myself: 69 test files, 68 pass; the single failure was `escort-browser.test.mjs`, which requires the dev server to be up (its documented precondition). I started `node dev/server.mjs 8123`, ran it manually, and it passed fully: real Phaser boot, transport sheet preloads at 1800×640, neutral board renders an escort row with title/issuer/body, `_establishEscort` stands the route (rendezvous phase, 1-hop destination, full-armor transport, 2 fighters), markers/compass/landing-reset seams clean, zero console errors and zero missing resources. I then stopped the server.\n- Headless `escort-flow.test.mjs` drives the real `GameScene.prototype` methods through the accept→rendezvous→link→wave→jump-reform→delivery and loss→land→reset→retry sequences with mocked combat, and all 20 dossiers render.\n\nEvery objective requirement — board type, waypoint rendezvous with transport + 1–2 fighters, 1–2 hop gate route to a planet/station, elevated encounters, clear-sky completion rule, 2.5x new-sprite transport, and land-to-reset retry — is present in real code and data with genuine (non-stub) verification. The only failing run in the blind suite is fully explained by a startup precondition and passes when that precondition is met.\n\n<approved/>","at":"2026-09-23T04:18:11.852Z"}
{"type":"goal_completed","goalId":"mudi9u8o-nektab","archivePath":".pi/goals/archived/goal_2026092222181186_mudi9u8o-nektab.md","at":"2026-09-23T04:18:11.866Z"}
{"type":"goal_archived","goalId":"mudi9u8o-nektab","archivePath":".pi/goals/archived/goal_2026092222181186_mudi9u8o-nektab.md","at":"2026-09-23T04:18:11.866Z"}

View File

@ -374,6 +374,46 @@
}
},
"transport": {
"_comment": "The ESCORT CONTRACT's transport (sidequests.escort — data/sidequests.json): the neutral FREIGHTER the player rendevouses with at a local waypoint and leads through the jump gates to a planet / space station 1–2 hops away. A PERSISTENT player-side craft (side 'player' — it never targets or damages the player; CombatSystem's active/neutralOnly checks skip it via its `persistent` flag, so a standing convoy never holds the jump gate / the landing door). js/combat/TransportShip.js flies it (the NeutralShip escort/engage AI with the break-off window lapsed — it stays for the life of the contract). ART: assets/images/ships-transport.png, frames 0–3 at 180×320 (the art faces north, filling its frame — the long side is the ship's length). SIZE 135 reads as the hull's world LENGTH: ~2.5× a fighter's 54 (TransportShip scales by the sheet's long side, not the frameWidth the square enemy sheet assumes). ARMOR far above the escort roster (hull 1500 / shields 650 vs the roster's 60–150/0–60) — it soaks fire off the player; WEAPON a wide 'volley' fan (the roster's top pattern) whose per-bolt punch is honest (the transport is a bodyguard, not a gunboat). respawns: false — the convoy is spent when it's lost (the contract's reset, not a re-form).",
"texture": "assets/images/ships-transport.png",
"frameWidth": 180,
"frameHeight": 320,
"frame": 0,
"artFacing": "north",
"side": "player",
"size": 135,
"hitRadius": 46,
"speed": 245,
"turn": 2.6,
"orbitRadius": 320,
"orbitSpeed": 0.45,
"escortRadius": 175,
"hull": 1500,
"shields": 650,
"xp": 0,
"weapon": {
"pattern": "volley",
"range": 660,
"cooldown": 2.3,
"bulletSpeed": 300,
"damage": 14,
"spread": 0.02,
"bulletLife": 3.0,
"bolts": 7,
"fan": 1.5,
"stagger": 0.3,
"size": 22,
"boltLength": 64,
"boltWidth": 14,
"color": "#7ce8a4"
},
"ramDamage": 0,
"ramDamageSelf": 0,
"ramGrace": 0.8,
"respawn": false
},
"encounters": {
"_comment": "PERIODIC RANDOM ENCOUNTERS (js/combat/Encounters.js → EncounterDirector; GameScene spawns on a hit) — every NEUTRAL non-home system, while flying, no combat live, no wave run in flight, one at a time. The director rolls on the interval schedule (rollIntervalMin–rollIntervalMax s; each attempt fires at chanceMin–chanceMax probability), and after an encounter ENDS it stays quiet for cooldownMin–cooldownMax s before the next roll. KIND rolls at `weights` (shipped even — ~1/3 each). SIZE: the player's combat level (js/combat/XpManager — a fresh run starts at 0) picks a band; mixed keeps both kinds present. Encounter hostiles are one-shots (no respawn); escorts carry the neutral.escortMin/escortMax window and break off on their own (js/combat/NeutralShip.js). Seconds + 0–1 probabilities — no code constants.",
"enabled": true,

View File

@ -31,9 +31,13 @@
]
},
"escort": {
"enabled": false,
"enabled": true,
"label": "ESCORT",
"accent": "#7ce8a4"
"accent": "#7ce8a4",
"minLevel": 3,
"contexts": [
"neutral"
]
}
},
"rewards": {
@ -52,8 +56,8 @@
140
],
"escort": [
70,
110
120,
175
]
},
"factions": {
@ -195,6 +199,86 @@
"_comment": "NO contact color, deliberately: a patrol paints its contact waypoints red (→ patrol.marker.contactColor) because those contacts are the job; a hunt's legs must look IDENTICAL until something shoots at you, so only the NEXT leg in sequence lights and nothing marks where the mark is."
}
},
"escort": {
"_comment": "The ESCORT category's rules (the third live type — the model: js/quests/SideQuests.js → escortDestinationFor / deriveEscortPlan; the ship: js/combat/TransportShip.js + data/combat.json → transport; the scene: GameScene._establishEscort + updateEscort). An escort is a CONVOY JOB: a neutral TRANSPORT (its own ship class — ~2.5x the fighter's size, far heavier armor, a heavy lance) plus 1–2 escort fighters stands at a single RENDEZVOUS waypoint in the posting's own system (waypoints [1,1] — the route builder's shared placement law, the 'escort' fork). The player flies to the waypoint within visitRadius and the convoy LINKS (phase 'rendezvous' → 'transit') — from then on the transport + fighters hold their escort ring around the player through EVERY remaining leg, re-forming across jumps (the route record carries the transport's armor — a signed contract is a signed contract: a jump or a save never re-rolls a standing convoy). The DESTINATION is a planet or a free-space station 1–2 gate hops away (destinationHops [min,max], a seeded walk of the jump network — js/galaxy/JumpTravel.js's gate graph, the same links the player can actually fly), fixed at the accept and never re-rolled; the player flies the legs (1–2 jumps) and arrives at the destination object with the convoy intact. ENCOUNTERS — the lane is HOT: while the convoy is linked, the escort director (its own EncounterDirector instance with these tighter numbers — a shorter roll interval, a near-certain chance, a short quiet) fires WAVES of mixed fighter types (bands, by the player's combat level — a weighted roster of enemyTypes ids, validated like the patrol's) with LARGER concurrent counts than a normal encounter (wave.total / maxConcurrent) — one at a time, the wave run's completion the quiet's gate, exactly the patrol contact's law. The TRANSPORT is the job: it fights alongside (its own lance, js/combat/TransportShip.js), it is a legal enemy target (the allies roster — pickEnemyTarget's escort aggro), and if it is DESTROYED the contract fails (phase 'failed', the convoy breaks off) — the player must LAND ON A PLANET to reset it (GameScene.startLanding's escort reset — a fresh rendezvous, a fresh transport, the same destination: retry the run). COMPLETION — the convoy arrives at the destination object (visitRadius of the planet/station in the destination system) with NO enemy fighter on screen (the craftVisible roster rule — the bounty field-clear's law): the contract completes, the reward stands ready to claim. compass + marker = the escort's in-game face: the green next-stop arrow (the RENDEZVOUS waypoint pre-link, then the DESTINATION gate, then the destination object itself) and the in-world beacons (rendezvous = pulsing until linked, then the destination object's mark).",
"enabled": true,
"minLevel": 3,
"contexts": [
"neutral"
],
"waypoints": [
1,
1
],
"visitRadius": 460,
"destinationHops": [
1,
2
],
"escortCount": [
1,
2
],
"transportFrame": [
0,
3
],
"encounters": {
"_comment": "The escort's LANE COMBAT (the director's numbers + the wave's shape + the roster's bands): rollIntervalMin/Max + chanceMin/Max + cooldownMin/Max are the EscortDirector's own EncounterDirector config (data/combat.json → encounters' shape — seconds); wave = one rolling wave of `total` mixed fighters, `maxConcurrent` on screen at a time, each kill replaced (replacePerKill) — the patrol contact's rolling shape, bigger; bands = the roster by the player's COMBAT level (levels [lo,hi], roster = a weighted { type: weight } table over combat.enemyTypes ids — the unknown-id rule is the patrol's: a type that isn't in combat.enemyTypes never ships, a roster that empties takes the established type); no band standing → the fallback band's roster.",
"enabled": true,
"rollIntervalMin": 9,
"rollIntervalMax": 18,
"chanceMin": 0.85,
"chanceMax": 1,
"cooldownMin": 5,
"cooldownMax": 10,
"wave": {
"kind": "rolling",
"total": 8,
"maxConcurrent": 4,
"replacePerKill": 1
},
"bands": [
{
"levels": [
0,
4
],
"roster": {
"raider": 3,
"bomber": 1,
"spitter": 1
}
},
{
"levels": [
5,
99
],
"roster": {
"raider": 2,
"bomber": 1,
"spitter": 1,
"stinger": 1,
"hunter": 1
}
}
],
"fallbackRoster": {
"raider": 3,
"bomber": 1,
"spitter": 1
}
},
"compass": {
"color": "#7ce8a4",
"typeLabel": "Escort"
},
"marker": {
"color": "#7ce8a4",
"doneColor": "#67e863"
}
},
"descriptions": {
"_comment": "The variant pools: context (neutral | faction) × category. The generator picks ONE variant per posting, seeded on (galaxy seed, system id, world key, category, slot) — a world's contract is STABLE (the board doesn't reshuffle between visits or saves), and different worlds draw different ones (3+ variants per pool keeps the galaxy's boards from reading as one repeated ad). Placeholders (resolved at generation, never stored): {system} = the system's name; in the faction pools {faction} = the system's faction label (data/factions.json), {issuer} = that faction's board name (→ factions above), {enemies} = the OTHER two factions ('MindWeave and Zenith'). The NEUTRAL pools are the pirate-safety voice; the FACTION pools are the push-the-other-factions-out voice. THE BOUNTY POOLS ARE DIFFERENT — descriptions.neutral.bounty is the BOUNTY HUNT's WANTED LIST: 20 dossiers, each a named pirate leader with an epithet, a crime, and the craft it flies (`craft` = a data/combat.json → enemyTypes id, spread across all seven frames, so the copy and the fight agree; `craftLabel` is the readout's plain words for it). descriptions.faction.bounty is deliberately EMPTY — a documented stub: categories.bounty.contexts is ['neutral'], so a faction board never draws a hunt, and the pool stays empty until a faction author posts its own wanted list (the generator would otherwise fall back to the neutral list and post pirate-hunt copy on a faction board).",
"neutral": {
@ -443,12 +527,97 @@
{
"title": "Convoy Night",
"issuer": "The Tally",
"body": "Three lighters move out of the debris line tonight, and every pirate in the system knows it — they've heard it the way you hear a storm. Fly the point, hold the line, and put them through the far gate. The Tally pays for hulls that arrive, and the Tally counts them at the gate. Count them yourself too."
"body": "A freighter and her screen stand ready at the buoy, and every pirate in the system knows the lane is theirs tonight. The Tally pays for hulls that arrive, and the Tally counts them at the gate — not for the story of the ones that didn't. Keep the big one in one piece, and count them yourself too."
},
{
"title": "The Long Dark",
"issuer": "The Warden Guild",
"body": "A courier ship has a sealed manifest and a hard deadline, and the lane she has to cross is pirate country. Escort her to the system edge and stay on her wing until the gate swallows her. Whatever she's carrying, it's worth more than your ship — that's probably why the Guild wants you, not their own pilots."
"body": "A courier ship has a sealed manifest and a hard deadline, and the lane she has to cross is pirate country. Escort her across and stay on her wing until the gate swallows her. Whatever she's carrying, it's worth more than your ship — that's probably why the Guild wants you, not their own pilots."
},
{
"title": "Patient Hull",
"issuer": "The Salvage Court",
"body": "The Court's arbitration barge is the slowest ship in three systems, and the pirates know exactly what that means. She carries a verdict, not a cargo — a ruling that three of their toll posts are forfeit, sealed and witnessed. The barge has armor and she has guns, and she will get there eventually, one slow mile at a time. Your job is to make sure the eventually is short, and the miles stay quiet."
},
{
"title": "The Choir Ship",
"issuer": "The Longshore Chapel",
"body": "A pilgrim ship out of the chapel is carrying the season's choir — twelve voices and a pipe organ, all of it — to the far station for the solstice service. The lane is a kill-box and the organ does not fit in a fighter. Keep the ship honest, keep the choir alive, and the service happens. The chapel doesn't pay in credits first; it pays in the fact that you were the reason the music made it."
},
{
"title": "Bones in the Hold",
"issuer": "The Remand Office",
"body": "The Office is moving twelve condemned pirates off the local block to a proper holding station, sealed in the hold under a full guard. The transport is armed to the bulkheads and it does not ask forgiveness. Pirates are circling the lane because a kill like that is worth the fuel. Fly the point, keep the hold sealed, and deliver the whole lot — alive, guilty, and in one piece. The Office pays for the count, and the count has to match."
},
{
"title": "The Fuel King's Daughter",
"issuer": "The Harbormaster",
"body": "A refueling tender is running a full load of refined fuel to the outer post, and the tender is carrying the fuel king's daughter home with it. She's in the cabin, she's reading, and she doesn't know that the lane is a kill-box. The harbor master is paying for the fact that the king's daughter arrives reading, not on fire. Keep the tender in one piece, keep the fuel lit, and keep your wing where the pirates can see it."
},
{
"title": "The Ledger's Own",
"issuer": "The Ledger House",
"body": "The House is moving its own books — the physical ledgers, three seasons of every transaction in the system — to the archive station. The books are the House's memory, and the pirates know that a ledger in the wrong hands is a weapon. The transport is slow, it is armored, and it is carrying something that cannot be reprinted. Escort it across and the House will remember your name in the only book that matters — the one that lists who it trusts."
},
{
"title": "The Wrecker's Debt",
"issuer": "The Tally",
"body": "A wrecker's ship is carrying a full hold of salvaged hull plate to the smelting station, and the pirates who put it in that shape want it back. The Tally has already paid for the salvage, and the wreckers have already signed the deed. The ship is fast enough, the cargo is heavy, and the lane is the problem. Put the plate through and the Tally's books stay honest — that's the whole job, and the whole reward."
},
{
"title": "The Quiet Passenger",
"issuer": "The Pilots' Guild",
"body": "A courier is carrying a single sealed crate across the lane, and the crate has a passenger inside it who is not supposed to exist. The Guild won't say what's in the crate, and it won't say who's paying for the silence. What it will say is this: the lane is hot, the crate is heavy, and the pilot who puts it through gets paid in credits and in the fact that the Guild will never forget who kept the secret. Fly the wing, keep the crate sealed, and don't ask."
},
{
"title": "The Harvest Run",
"issuer": "The Warden Guild",
"body": "A harvest barge out of the outer field is carrying three seasons of grain to the depot, and the field is done producing — this is the last run of the season. The barge is slow, the grain is heavy, and the pirates know that a starving system is a system that pays. The Guild is paying for the fact that the grain arrives and the season doesn't end in a fire. Keep the barge moving, keep the hold dry, and put it through before the ice closes the lane."
},
{
"title": "The Marshal's Charge",
"issuer": "The Remand Office",
"body": "A marshal's transport is moving a full warrant of evidence — the physical records of a piracy case, sealed and witnessed — to the trial station. The pirates in the case know what's on that ship, and they know that the evidence is the only thing standing between them and the block. The transport is armed, the marshal is not asking forgiveness, and the lane is the problem. Escort the charge through and the Office will file your name in the record — as the reason the case held."
},
{
"title": "The Slow Smile's Freight",
"issuer": "The Tally",
"body": "A freighter is running a full load of ore to the smelting station, and the freighter has a pilot who used to fly for the pirates. The Tally doesn't care about the pilot's past, but the pirates care about the pilot's present — they want their old crew back, and they're willing to burn the freighter to get them. Keep the freighter in one piece, keep the ore moving, and the Tally will pay for the fact that the lane stayed open."
},
{
"title": "The Chapel Bell",
"issuer": "The Longshore Chapel",
"body": "A repair ship is carrying a new bell — the replacement for the one the pirates rang nine times for every ship they let through — to the chapel station. The bell is cast, it's blessed, and it's going to be rung for the first time at the solstice service. The lane is the problem and the bell is the weight. Put the bell through and the chapel will ring it once for you, in the only way it knows how — on the air, in every system, on the solstice."
},
{
"title": "The Toll Keepers' Pay",
"issuer": "The Harbormaster",
"body": "A pay transport is moving the season's toll payments to the outer posts, and the posts are three months behind on wages. The transport is carrying credits — not ore, not grain, credits — and the pirates know that a post with unpaid wardens is a post that can be bought. The harbor master is paying for the fact that the wardens get paid and the posts stay honest. Keep the transport moving, keep the credits sealed, and put it through before the wardens start to wonder."
},
{
"title": "The Courier's Last Run",
"issuer": "The Pilots' Guild",
"body": "A courier is making their last run before they retire, and the cargo is the Guild's own seal — the physical stamp, the one that makes a manifest real. The courier has flown this lane for twenty years and they know every gate, every shadow, every blind spot. The Guild is paying for the fact that the seal arrives and the courier retires with their name clean. Fly the wing, keep the seal sealed, and give the old pilot the send-off they earned."
},
{
"title": "The Ice Road Tender",
"issuer": "The Warden Guild",
"body": "A fuel tender is running a full load to the ice road post, and the post is the last honest fuel stop before the deep lane. The tender is slow, the fuel is volatile, and the ice road is a toll lane with a reputation. The Guild is paying for the fact that the post stays lit and the lane stays open. Keep the tender moving, keep the fuel safe, and put it through before the ice closes."
},
{
"title": "The Court's Witness",
"issuer": "The Salvage Court",
"body": "A witness transport is carrying a single witness — the only one who saw the Sable Convoy burn — to the trial station. The witness is alive, they are sealed in the cabin, and they are worth more than the ship that's carrying them. The pirates who burned the convoy know that a witness at the trial is a verdict, and they're willing to burn the transport to keep it. Escort the witness through and the Court will file your name in the record as the reason the truth held."
},
{
"title": "The Harbormaster's Tender",
"issuer": "The Harbormaster",
"body": "A harbor tender is carrying the season's repair parts to the outer dock, and the dock is the only honest repair yard in the system. The tender is slow, the parts are heavy, and the dock is three months behind on its parts order. The harbor master is paying for the fact that the dock gets its parts and the yard stays open. Keep the tender moving, keep the parts dry, and put it through before the dock starts to rust."
},
{
"title": "The Guild's Own Pilot",
"issuer": "The Warden Guild",
"body": "A Guild pilot is flying their own ship — the one they've flown for fifteen years — to the far station for the solstice service, and the lane is the problem. The ship is fast, the pilot is good, and the Guild is paying for the fact that the pilot arrives and the ship stays in one piece. The Guild doesn't pay for the story of the pilot who didn't make it. Fly the wing, keep the pilot alive, and put them through. That's the job, and the Guild will remember."
}
]
},
@ -568,6 +737,34 @@
"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.",
"escortActive": "CONVOY LINKED",
"escortRouteProgress": "LANE — {visited}/{total} SECTORS CROSSED",
"escortRendezvousToast": {
"glyph": "◈",
"color": "#7ce8a4",
"text": "CONVOY STANDING BY — REACH THE RENDEZVOUS TO LINK"
},
"escortLinkToast": {
"glyph": "⚓",
"color": "#7ce8a4",
"text": "CONVOY LINKED — {name} HOLDS YOUR WING THROUGH TO {dest}"
},
"escortArrivedToast": {
"glyph": "✓",
"color": "#67e863",
"text": "CONVOY DELIVERED — {name} IS THROUGH, ITS REWARD IS READY TO CLAIM"
},
"escortFailToast": {
"glyph": "✗",
"color": "#ff5c77",
"text": "TRANSPORT LOST — THE CONVOY HAS BROKEN UP. LAND ON A PLANET TO RESET THE CONTRACT"
},
"escortResetToast": {
"glyph": "⚓",
"color": "#7ce8a4",
"text": "CONTRACT RESET — A FRESH CONVOY STANDS AT THE RENDEZVOUS"
},
"_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

@ -125,10 +125,13 @@ section('THE BOARD — rows on Neutral boards, LOCKED below level 2');
const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40, zone: 'mid', type: 'void', capital: false };
const at = (level, wk = 'p1') => S.sideQuestPostingsFor(1234, REC, wk, null, level);
// The board is PER-WORLD now (1–3 postings): no single world is guaranteed
// a bounty slot, so these assertions pool the record's own worlds (the
// model's contract is that the NEUTRAL voice posts the hunt — on SOME
// world's board).
const scan = (level) => ['p1', 'p2', 'p3', 'p4'].flatMap((wk) => at(level, wk));
// a bounty slot — the neutral voice's categories (patrol / bounty / the
// escort, once it stands) share a world's seeded slots — so these
// assertions pool a WIDER slice of the record's worlds (the model's
// contract is that the NEUTRAL voice posts the hunt — on SOME world's
// board, not a fixed one).
const _worlds = [...Array(12).keys()].map((i) => `p${i + 1}`).concat(['s1', 's2', 's3', 's4']);
const scan = (level) => _worlds.flatMap((wk) => at(level, wk));
const low = at(1), high = at(2), noLevel = at(null);
const huntsLow = scan(1).filter((p) => p.category === 'bounty');
check('a Neutral board posts BOUNTY HUNT contracts', huntsLow.length > 0, `low=${huntsLow.length}`);

212
dev/escort-browser.test.mjs Normal file
View File

@ -0,0 +1,212 @@
// dev/escort-browser.test.mjs — the ESCORT contract in a REAL BROWSER
// (Playwright + the vendored Phaser). Run:
//
// node dev/server.mjs 8123 & # the static dev server
// node dev/escort-browser.test.mjs # default http://127.0.0.1:8123
//
// What this pins (the part the headless seam tests can't): the scene
// BOOTS with the escort's code live (the transport sheet preloads, the
// escort's lane director stands, the transport config resolves), and the
// escort's SCENE-side law runs in the real Phaser runtime without a thrown
// error — the accept stands the route, the rendezvous beacon paints, and
// the board's escort row renders with its title / issuer / body.
//
// This is the "it runs in the browser" half of the escort's verification;
// the "it behaves per contract" half is dev/escort.test.mjs (the model)
// + dev/escort-flow.test.mjs (the scene flow).
const PORT = process.env.ORBIT_PORT || '8123';
const BASE = `http://127.0.0.1:${PORT}`;
const { pathToFileURL } = await import('node:url');
const { chromium } = await import(pathToFileURL('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs').href);
const failures = [];
const ok = (cond, msg) => { if (!cond) failures.push(msg); console.log(` ${cond ? '✓' : '✗ FAIL'} ${msg}`); };
const check = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { console.log(` ✗ FAIL ${msg}`); failures.push(msg); } };
console.log(`== escort in the browser — ${BASE} ==`);
const browser = await chromium.launch({
headless: true,
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage();
const consoleErrors = [];
const missingResources = [];
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', (e) => consoleErrors.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) missingResources.push(`${r.status()} ${r.url()}`); });
await page.goto(`${BASE}/index.html`, { waitUntil: 'load' });
// Wait for the game to boot and the menu to stand.
await page.waitForFunction(() => !!(window.game && window.game.scene && window.game.scene.getScene && window.game.scene.getScene('MenuScene')), null, { timeout: 20000 });
console.log(' ✓ the game booted; the menu stands');
// Wait for the menu to be FULLY dressed (overlay + title built, not mid-decode).
await page.waitForFunction(() => {
const m = window.game?.scene?.getScene?.('MenuScene');
return !!(m && m.overlay && m.title && m.seedValue && !m.dead);
}, null, { timeout: 20000 });
console.log(' ✓ the menu is dressed (overlay + title + seed ready)');
// Start a NEW GAME (the menu's own door) → GameScene.
await page.evaluate(() => {
const m = window.game.scene.getScene('MenuScene');
if (!m.seedValue) m.seedValue = 'ESCORT01'; // the seed panel may still be decoding
m.startNewGame();
});
// Wait for GameScene to be active and the escort's seams to stand.
try {
await page.waitForFunction(() => {
const g = window.game;
if (!g || !g.scene || !g.scene.getScene) return false;
const s = g.scene.getScene('GameScene');
if (!s) return false;
try { if (!s.isActive()) return false; } catch { /* isActive optional */ }
return !!(s.escortDirector && s.combat);
}, null, { timeout: 40000 });
} catch (e) {
const diag = await page.evaluate(() => {
const g = window.game;
if (!g || !g.scene) return { game: !!g };
const s = g.scene.getScene('GameScene');
return {
sceneKeys: g.scene.getScenes ? g.scene.getScenes(true).map((x) => x.sys?.key ?? x.key) : null,
gs: s ? { isActive: (() => { try { return s.isActive(); } catch { return 'n/a'; } })(), hasEscortDirector: !!s.escortDirector, hasCombat: !!s.combat, hasShip: !!s.ship, createRan: !!s.systemRecord } : 'no GameScene',
};
});
console.log(' DIAGNOSTIC', JSON.stringify(diag));
if (consoleErrors.length) console.log(' CONSOLE ERRORS\n ' + consoleErrors.slice(0, 8).join('\n '));
if (missingResources.length) console.log(' MISSING RESOURCES\n ' + missingResources.slice(0, 8).join('\n '));
await browser.close();
process.exit(1);
}
console.log(' ✓ GameScene is active; the escort director + combat system stand');
// ---- 1. THE TRANSPORT SHEET preloads -----------------------------------
const tex = await page.evaluate(() => {
const s = window.game.scene.getScene('GameScene');
const key = '__enemy_ship_ships-transport';
const t = s.textures.get(key);
return { exists: s.textures.exists(key), frameWidth: t.realWidth ?? null, frameHeight: t.realHeight ?? null, sourceSize: t.getSourceImage() ? { w: t.getSourceImage().width, h: t.getSourceImage().height } : null };
});
ok(tex.exists, `the transport spritesheet preloaded (key __enemy_ship_ships-transport)`);
ok(tex.sourceSize && tex.sourceSize.w === 1800 && tex.sourceSize.h === 640, `…the sheet is 1800×640 (got ${tex.sourceSize ? tex.sourceSize.w + '×' + tex.sourceSize.h : 'none'})`);
// ---- 2. THE ESCORT'S RUNTIME SEAMS stand (the scene's doors) -------------
const seams = await page.evaluate(() => {
const s = window.game.scene.getScene('GameScene');
return {
spawnTransport: typeof s.combat?.spawnTransport === 'function',
spawnAlly: typeof s.combat?.spawnAlly === 'function',
escortDirector: !!s.escortDirector && typeof s.escortDirector.update === 'function',
updateEscort: typeof s.updateEscort === 'function',
establishEscort: typeof s._establishEscort === 'function',
drawMarkers: typeof s._drawEscortMarkers === 'function',
resetForLanding: typeof s._escortResetForLanding === 'function',
compassTarget: typeof s.escortCompassTarget === 'function',
};
});
ok(seams.spawnTransport, 'combat.spawnTransport stands (the transport seam)');
ok(seams.spawnAlly, 'combat.spawnAlly stands (the escort fighters seam)');
ok(seams.escortDirector, 'the escort lane director stands (update())');
ok(seams.updateEscort && seams.establishEscort, 'the escort scene law stands (updateEscort / _establishEscort)');
ok(seams.drawMarkers && seams.resetForLanding && seams.compassTarget, 'the escort markers / landing reset / compass stand');
// ---- 3 + 4. THE BOARD renders an escort row AND the scene law runs ------
// (level raised above the escort's minLevel gate, so the row stands ACCEPTED;
// a neutral system is found by scanning its worlds for an escort slot — the
// scene's board seam is bound to the current system, so we point it there)
const boardAndLaw = await page.evaluate(() => {
const s = window.game.scene.getScene('GameScene');
const prior = { systemRecord: s.systemRecord, systemContent: s.systemContent, level: s.combat?.xp?.level };
const isNeutral = (rec) => {
const f = rec.faction;
return !(typeof f === 'string' && f !== '' && f.toLowerCase() !== 'neutral');
};
try {
// The escort's minLevel gate (level 3): the player stands at 5.
if (s.combat?.xp) s.combat.xp.level = 5;
// Find a NEUTRAL system whose board stands an escort row (scan its worlds).
let found = null;
outer:
for (const rec of s.galaxy.records ?? []) {
if (!isNeutral(rec)) continue;
let content = null;
try { content = s.galaxy.ensureContent(rec.id); } catch { continue; }
s.systemRecord = rec;
s.systemContent = content;
const worlds = [null, ...(content.planets ?? []).map((p) => p.name), ...(content.settlements ?? []).filter((x) => x?.anchor?.type === 'space').map((x) => x.name)];
for (const wname of worlds) {
let posts = [];
try { posts = s.sideQuestPostings(wname, false) ?? []; } catch { continue; }
const e = posts.find((p) => p.category === 'escort');
if (e) { found = { rec, wname, posts, escort: e }; break outer; }
}
}
if (!found) return { error: 'no neutral board stands an escort row (across systems/worlds)' };
const { rec, wname, escort } = found;
// ACCEPT + ESTABLISH + paint the rendezvous beacon (the real runtime).
s.sideQuestState.accept(escort.id);
s.sideQuestState.setActive(escort.id);
s._establishEscort(escort.id, escort);
const route = s.sideQuestState.routeFor(escort.id);
if (!route || route.kind !== 'escort') return { error: 'route not established', route };
s._drawEscortMarkers();
return {
ok: true,
sysName: rec.name,
world: wname ?? 'p1',
board: { total: found.posts.length, title: escort.title, issuer: escort.issuer, description: escort.description, credits: escort.reward?.credits },
law: {
jobId: escort.id, kind: route.kind, phase: route.escort?.phase,
origin: route.escort?.originSystemId, dest: route.escort?.destSystemId, hops: route.escort?.hops,
waypoints: route.waypoints?.length, transportMaxHull: route.escort?.transportMaxHull,
escortCount: route.escort?.escortCount, credits: route.credits, xp: route.xp,
},
};
} catch (e) {
return { error: String(e?.message ?? e), stack: e?.stack };
} finally {
s.systemRecord = prior.systemRecord;
s.systemContent = prior.systemContent;
if (prior.level != null && s.combat?.xp) s.combat.xp.level = prior.level;
}
});
if (boardAndLaw.error) {
ok(false, `board + scene law — ${boardAndLaw.error}`);
} else {
const b = boardAndLaw.board;
ok(!!b.title && b.title.length > 0, `the neutral board stands an ESCORT row (system ${boardAndLaw.sysName}, world ${boardAndLaw.world}, ${b.total} postings): "${b.title}"`);
ok(!!b.issuer && b.issuer.length > 0, `…the issuer renders: "${b.issuer}"`);
ok(!!b.description && b.description.length > 40, `…the body renders (${b.description ? b.description.length : 0} chars)`);
ok((b.credits ?? 0) >= 5, `…the reward meets the floor (${b.credits} CR)`);
const L = boardAndLaw.law;
ok(L.kind === 'escort', `the route is typed escort (${L.jobId})`);
ok(L.phase === 'rendezvous', `…standing at its RENDEZVOUS (${L.phase})`);
ok(L.dest && L.dest.length > 0, `…aimed at a destination (${L.dest}, ${L.hops} hop${L.hops === 1 ? '' : 's'})`);
ok((L.waypoints ?? 0) >= 1, `…with a rendezvous waypoint standing (${L.waypoints})`);
ok(L.transportMaxHull >= 1000, `…a transport at full standing armor (hull ${L.transportMaxHull})`);
ok(L.escortCount >= 1 && L.escortCount <= 2, `…${L.escortCount} escort fighter(s)`);
ok(L.credits >= 5 && L.xp > 0, `…payout fixed (${L.credits} CR · +${L.xp} XP)`);
}
// ---- 5. NO uncaught errors / missing resources from the escort's code --
const escortErrors = consoleErrors.filter((t) => /escort|transport|convoy/i.test(t));
check(escortErrors.length === 0, `no console errors from the escort/transport code${escortErrors.length ? ` — ${escortErrors[0]}` : ''}`);
check(missingResources.length === 0, `no missing resources on boot${missingResources.length ? ` — ${missingResources[0]}` : ''}`);
await page.close();
await browser.close();
if (failures.length) {
console.log(`\nescort-browser: FAIL — ${failures.length} failure(s)`);
for (const f of failures) console.log(` ✗ ${f}`);
process.exit(1);
}
console.log('\nescort-browser: OK — the scene boots with the escort live, the sheet preloads, the board renders, the scene law runs clean');
process.exit(0);

348
dev/escort-flow.test.mjs Normal file
View File

@ -0,0 +1,348 @@
// 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)');

265
dev/escort.test.mjs Normal file
View File

@ -0,0 +1,265 @@
// dev/escort.test.mjs — the ESCORT contract's model, verified headless
// (node dev/escort.test.mjs — no browser, no Phaser render).
//
// What this pins (the escort's contract — data/sidequests.json → escort):
// - The GATE — escort is LIVE by default, neutral-only (a faction board
// never posts it), and carries a minLevel.
// - The DESTINATION — escortDestinationFor's seeded 1–2 hop walk: a
// neutral, gate-reachable system, a real planet/station object, the
// same world across calls (a signed contract is a signed contract), a
// different seed draws its own, and a dead-end origin degrades to null.
// - The WAVE ROSTER — escortWaveRoster's mixed fighters (valid types,
// finite wave shape, a sensible concurrent/replace band).
// - The XP — escortEncounterXp's positive payout.
// - The LEDGER — SideQuestState.setRoute's escort record (phase,
// destination, the transport's armor) + the save round-trip (the
// phase + armor ride the save; a malformed record degrades to a fresh
// 'rendezvous' on the same destination).
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));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const DATA = configData;
const {
SIDE_QUEST_CATEGORIES,
sideQuestCategoryMeta,
enabledSideQuestCategories,
sideQuestAcceptable,
sideQuestPostingsFor,
escortDestinationFor,
escortWaveRoster,
escortEncounterXp,
SideQuestState,
} = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
const SEED = 77031;
const JOB = 'sq-sys-home-p1-escort-0';
// A small gate graph (the neutral-only scope):
// sys-home (neutral) — the posting's system (where the convoy stands)
// ├─ sys-a (neutral) — one hop away
// │ └─ sys-b (neutral) — two hops away
// └─ sys-zenith (zenith) — a FACTION neighbor (must be excluded)
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: 'neutral', d: 35, zone: 'mid', type: 'void' },
'sys-b': { id: 'sys-b', name: 'Basalt', faction: null, d: 65, zone: 'far', type: 'void' },
'sys-zenith': { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 55, zone: 'mid', type: 'void' },
};
const ADJ = {
'sys-home': ['sys-a', 'sys-zenith'],
'sys-a': ['sys-home', 'sys-b'],
'sys-b': ['sys-a'],
'sys-zenith': ['sys-home'],
};
const CONTENT = {
'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 makeGalaxy = () => ({
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) => {
const c = CONTENT[id];
if (!c) throw new Error(`no content for ${id}`);
return c;
},
contentOf: (id) => makeGalaxy().ensureContent(id),
});
const galaxy = makeGalaxy();
// ===========================================================================
console.log('== the gate — escort is LIVE, neutral-only, level-gated ==');
{
const meta = sideQuestCategoryMeta('escort');
assert.ok(meta, 'the escort meta stands');
assert.equal(meta.enabled, true, 'escort is LIVE by default (the data’s enabled flag)');
assert.ok(meta.minLevel >= 1, 'escort carries a minLevel gate (the data’s minLevel)');
assert.deepEqual(meta.contexts, ['neutral'], 'escort is neutral-only (a faction board never posts it)');
assert.ok(enabledSideQuestCategories('neutral').includes('escort'), 'a NEUTRAL board posts escort');
assert.ok(!enabledSideQuestCategories('faction').includes('escort'), 'a FACTION board never posts escort');
// The level gate: below the minLevel the contract is locked; at/above it, open.
const locked = sideQuestAcceptable({ category: 'escort' }, meta.minLevel - 1);
const open = sideQuestAcceptable({ category: 'escort' }, meta.minLevel);
assert.equal(locked.locked, true, 'below the minLevel the contract is LOCKED');
assert.equal(open.locked, false, 'at the minLevel the contract is open');
assert.equal(open.ok, true, 'at the minLevel the contract is acceptable');
}
// ===========================================================================
console.log('== the posting — the neutral board stands an escort; the faction board does not ==');
{
const neutral = { id: 'sys-home', name: 'Hearth', faction: null, d: 5, zone: 'near', type: 'main' };
const faction = { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 55, zone: 'mid', type: 'void' };
// Neutral board: an escort posting stands (the category is live + neutral).
const nPosts = sideQuestPostingsFor(SEED, neutral, 'p1', galaxy, 99);
assert.ok(nPosts.some((p) => p.category === 'escort'), 'a NEUTRAL board stands an escort (the live category)');
const e = nPosts.find((p) => p.category === 'escort');
assert.equal(e.faction, null, 'the escort posting carries no faction');
assert.equal(e.reward.credits >= 5, true, 'the escort reward meets the 5 CR floor');
assert.ok(e.description.length > 20, 'the escort posting’s body reads');
// Faction board: no escort stands (neutral-only).
const fPosts = sideQuestPostingsFor(SEED, faction, 'p1', galaxy, 99);
assert.ok(!fPosts.some((p) => p.category === 'escort'), 'a FACTION board never stands an escort');
}
// ===========================================================================
console.log('== the DESTINATION — the seeded 1–2 hop neutral walk ==');
{
const d = escortDestinationFor(SEED, JOB, 'sys-home', galaxy);
assert.ok(d, 'a destination stands for a connected origin');
assert.ok(d.hops >= 1 && d.hops <= 2, `hops in [1,2] (got ${d.hops})`);
assert.equal(d.path[0], 'sys-home', 'the path starts at the origin');
assert.equal(d.path.length, d.hops + 1, 'the path is the hop count + 1 systems');
// The destination is a NEUTRAL system (not the faction neighbor).
const dest = RECS[d.destSystemId];
assert.ok(dest, 'the destination system is a known record');
assert.ok(
typeof dest.faction !== 'string' || dest.faction === '' || dest.faction === 'neutral',
'the destination system is NEUTRAL (the faction neighbor is excluded)'
);
assert.notEqual(d.destSystemId, 'sys-home', 'the destination is not the origin itself');
// The destination OBJECT exists and is a planet or a free-space station.
assert.equal(typeof d.destObjectType, 'string', 'the destination object type stands');
assert.ok(['planet', 'station'].includes(d.destObjectType), `the destination is a planet or station (got ${d.destObjectType})`);
assert.ok(d.destObjectId.length > 0, 'the destination object has an id');
assert.ok(d.destObjectName.length > 0, 'the destination object has a name');
// Determinism — the same contract points at the same world across calls.
const d2 = escortDestinationFor(SEED, JOB, 'sys-home', galaxy);
assert.deepEqual(d2, d, 'the same destination stands across calls (a signed contract is a signed contract)');
// A different job id draws its own (the fork is per-contract).
const dOther = escortDestinationFor(SEED, 'sq-sys-home-p1-escort-3', 'sys-home', galaxy);
assert.ok(dOther, 'another contract on the same board stands its own destination');
// A dead-end origin (a system with no neutral neighbors) degrades to null.
const lonely = {
...makeGalaxy(),
jumpGatesFor: () => [],
};
assert.equal(escortDestinationFor(SEED, JOB, 'sys-home', lonely), null, 'a dead-end origin (no neighbors) → null (no destination stands)');
}
// ===========================================================================
console.log('== the WAVE ROSTER — mixed fighters, a sane wave shape ==');
{
for (const level of [0, 3, 7, 12]) {
const w = escortWaveRoster(level);
assert.ok(Object.keys(w.roster).length >= 1, `level ${level}: at least one fighter type`);
for (const t of Object.keys(w.roster)) {
assert.match(t, /^[a-z]+$/, `level ${level}: a type id (${t})`);
assert.ok(w.roster[t] > 0, `level ${level}: positive weights`);
}
assert.ok(w.total >= 1, `level ${level}: a wave total`);
assert.ok(w.maxConcurrent >= 1 && w.maxConcurrent <= w.total, `level ${level}: maxConcurrent within [1,total]`);
assert.ok(w.replacePerKill >= 0, `level ${level}: replacePerKill is non-negative`);
}
// Higher level draws the wider roster (the data’s band).
const low = escortWaveRoster(0);
const high = escortWaveRoster(99);
assert.ok(Object.keys(high.roster).length >= Object.keys(low.roster).length, 'a higher level’s roster is at least as wide (the data’s bands)');
}
// ===========================================================================
console.log('== the XP — a positive payout, scaling with the lane ==');
{
const flat = escortEncounterXp({ hops: 1, level: 3 });
const deep = escortEncounterXp({ hops: 2, level: 3 });
assert.ok(flat > 0, 'a positive XP payout');
assert.ok(deep >= flat, 'the deeper lane pays at least as much XP');
assert.ok(Number.isInteger(flat), 'the XP payout is whole');
}
// ===========================================================================
console.log('== the LEDGER — the escort route record + the save round-trip ==');
{
const st = new SideQuestState();
st.accept(JOB);
const dest = escortDestinationFor(SEED, JOB, 'sys-home', galaxy);
const escort = {
phase: 'transit',
originSystemId: 'sys-home',
originSystemName: 'Hearth',
destSystemId: dest.destSystemId,
destSystemName: dest.destSystemName,
destObjectType: dest.destObjectType,
destObjectId: dest.destObjectId,
destObjectName: dest.destObjectName,
hops: dest.hops,
transportHull: 900,
transportShields: 300,
escortCount: 2,
name: 'Convoy Night',
};
const ok = st.setRoute(JOB, {
kind: 'escort',
targetSystemId: dest.destSystemId,
credits: 140,
xp: escortEncounterXp({ hops: dest.hops, level: 3 }),
waypoints: [[12, 34]], // the single RENDEZVOUS waypoint
visited: [0],
escort,
});
assert.equal(ok, true, 'the escort route stands');
const route = st.routeFor(JOB);
assert.equal(route.kind, 'escort', 'the route is typed escort');
assert.equal(route.escort.phase, 'transit', 'the phase rides the record');
assert.equal(route.escort.transportHull, 900, 'the transport’s hull rides the record');
assert.equal(route.escort.transportShields, 300, 'the transport’s shields ride the record');
assert.equal(route.escort.escortCount, 2, 'the escort count rides the record');
assert.equal(route.escort.destSystemId, dest.destSystemId, 'the destination rides the record');
assert.equal(route.targetSystemId, dest.destSystemId, 'the route’s target = the destination system');
assert.ok(route.xp > 0, 'the XP payout rides the record');
assert.equal(route.credits, 140, 'the credits ride the record');
// The save round-trip (toJSON → fromJSON) preserves the escort record.
const restored = SideQuestState.fromJSON(st.toJSON(), new Set([JOB]));
const r2 = restored.routeFor(JOB);
assert.equal(r2.kind, 'escort', 'the save round-trip keeps the escort type');
assert.equal(r2.escort.phase, 'transit', '…the phase');
assert.equal(r2.escort.transportHull, 900, '…the transport’s hull');
assert.equal(r2.escort.destSystemId, dest.destSystemId, '…the destination');
// A malformed record degrades to a fresh 'rendezvous' on the same destination.
const st2 = new SideQuestState();
st2.accept(JOB);
st2.setRoute(JOB, {
kind: 'escort',
targetSystemId: dest.destSystemId,
credits: 140,
waypoints: [[12, 34]],
visited: [],
escort: { phase: 'BROKEN', destSystemId: dest.destSystemId, destObjectName: dest.destObjectName },
});
const r3 = st2.routeFor(JOB);
assert.equal(r3.escort.phase, 'rendezvous', 'a broken phase degrades to a fresh rendezvous');
assert.equal(r3.escort.destSystemId, dest.destSystemId, '…on the same destination (the contract’s signature)');
assert.ok(r3.escort.transportHull >= 1, '…with a fresh transport (full armor)');
}
console.log('\nescort: OK — the gate, the destination walk, the wave roster, the XP, the ledger');

View File

@ -107,23 +107,23 @@ const setEnabled = (map) => {
config.data.sidequests.categories[cat].enabled = !!map[cat];
}
};
// The data’s default gates (PATROL + BOUNTY HUNT live — the two categories
// whose rules landed; DELIVERY and ESCORT stay OFFLINE until theirs do).
const restoreDefaultGates = () => setEnabled({ patrol: true, bounty: true });
// The data’s default gates (PATROL + BOUNTY HUNT + ESCORT live — the three
// categories whose rules landed; DELIVERY stays OFFLINE until its rules do).
const restoreDefaultGates = () => setEnabled({ patrol: true, bounty: true, escort: true });
// ===========================================================================
console.log('== the category gate — patrol + bounty hunt LIVE by default, the rest OFFLINE ==');
console.log('== the category gate — patrol + bounty hunt + escort LIVE by default, delivery OFFLINE ==');
{
assert.deepEqual(SIDE_QUEST_CATEGORIES, ['patrol', 'delivery', 'bounty', 'escort'], 'the four types, canonical slot order');
assert.equal(sideQuestsEnabled(), true, 'the feature’s master switch is ON (the data’s enabled flag)');
assert.deepEqual(enabledSideQuestCategories(), ['patrol', 'bounty'], 'PATROL and the BOUNTY HUNT are the live categories (the gates that landed)');
assert.deepEqual(enabledSideQuestCategories('faction'), ['patrol'], 'a FACTION board never draws a hunt (categories.bounty.contexts is ["neutral"])');
assert.deepEqual(enabledSideQuestCategories(), ['patrol', 'bounty', 'escort'], 'PATROL, the BOUNTY HUNT, and ESCORT are the live categories (the gates that landed)');
assert.deepEqual(enabledSideQuestCategories('faction'), ['patrol'], 'a FACTION board never draws a hunt or an escort (their contexts are ["neutral"])');
assert.equal(sideQuestCategoryMeta('huntr'), null, 'an unknown category → null (no fabrication)');
for (const cat of SIDE_QUEST_CATEGORIES) {
const meta = sideQuestCategoryMeta(cat);
assert.equal(meta.id, cat, `${cat}: its meta stands`);
assert.equal(meta.enabled, cat === 'patrol' || cat === 'bounty', `${cat}: live iff its gate says so (patrol + bounty live, the rest OFFLINE by default)`);
assert.equal(meta.enabled, cat === 'patrol' || cat === 'bounty' || cat === 'escort', `${cat}: live iff its gate says so (patrol + bounty + escort live, delivery OFFLINE by default)`);
assert.ok(meta.label.length > 0, `${cat}: a label for the board’s rows`);
assert.match(meta.accent, /^#[0-9a-f]{6}$/i, `${cat}: a row-chip accent`);
const band = DATA.sidequests.rewards[cat];
@ -146,11 +146,11 @@ console.log('== the category gate — patrol + bounty hunt LIVE by default, the
}
// The DEFAULT board stands live with the live categories’ postings (patrol +
// bounty — delivery/escort stay OFFLINE).
// bounty + escort — delivery stays OFFLINE).
const def = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1');
const [lo, hi] = sideQuestCountRange();
assert.ok(def.length >= lo && def.length <= hi, `the default board: ${def.length} postings within [${lo},${hi}]`);
assert.ok(def.every((p) => p.category === 'patrol' || p.category === 'bounty'), 'the default board posts only the live types');
assert.ok(def.every((p) => p.category === 'patrol' || p.category === 'bounty' || p.category === 'escort'), 'the default board posts only the live types');
}
// ===========================================================================
@ -201,7 +201,12 @@ console.log('== the gate flips — one live type → only it posts; all live →
assert.ok(posts.length >= lo && posts.length <= hi, `${rec.id}: ${posts.length} postings within [${lo},${hi}]`);
const ids = new Set(posts.map((p) => p.id));
assert.equal(ids.size, posts.length, `${rec.id}: the ids are unique (category + slot)`);
assert.ok(new Set(posts.map((p) => p.category)).size >= Math.min(3, posts.length), `${rec.id}: the slots spread across the live types`);
// The slots can't spread past the board's LIVE categories for that
// context (a faction board never draws a neutral-only type — bounty
// and escort stand off it), nor past the posting count itself.
const ctx = rec.faction ? 'faction' : 'neutral';
const liveInCtx = enabledSideQuestCategories(ctx).length;
assert.ok(new Set(posts.map((p) => p.category)).size >= Math.min(liveInCtx, posts.length), `${rec.id}: the slots spread across the live types for the board's context (${liveInCtx} live in ${ctx})`);
}
// DETERMINISM — same seed + record + world → the same board across
@ -748,7 +753,12 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl
// ===========================================================================
console.log('== the scene’s seams — the QUESTS console + tracker see the accepted contracts ==');
{
// The scene’s seams (GameScene.questSnapshot / claimQuest /
// This section pins the PATROL's seams (setRoute / updatePatrol /
// patrolProgress / the checklist). Stand PATROL alone so the board's
// seeded single slot on the neutral record is guaranteed to be a patrol
// (with the live set wider, that one slot could land on bounty/escort).
setEnabled({ patrol: true });
// The scene's seams (GameScene.questSnapshot / claimQuest /
// setPriorityQuest / claimSideQuest) run over the SAME ledger + postings
// this file pins — called headless on a stub `this` (the jobboard’s
// makeScene pattern): the ledger is the real SideQuestState, the story

View File

@ -1,7 +1,8 @@
import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js';
import { NeutralShip } from './NeutralShip.js';
import { enemyTypeConfig, neutralTypeConfig } from './EnemyTypes.js';
import { TransportShip } from './TransportShip.js';
import { enemyTypeConfig, neutralTypeConfig, transportConfig } from './EnemyTypes.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, inView, pickTarget } from './Combat.js';
@ -198,8 +199,12 @@ export class CombatSystem {
return (
this.enemies.some((e) => !e.dead || (e._respawnPending && e.respawnAt > 0)) ||
// A live escort keeps the fight up too (its lances are live even
// when the pack is spent — the encounter ends when it's OFF).
this.allies.some((a) => !a.dead && !a.gone && !a._removed)
// when the pack is spent — the encounter ends when it's OFF). A
// PERSISTENT ally (the escort contract's transport —
// js/combat/TransportShip.js) does NOT: it stands for the life of
// the contract, so it can't hold the jump gate / the landing door
// open — the fight is over the moment the last pirate is down.
this.allies.some((a) => !a.persistent && !a.dead && !a.gone && !a._removed)
);
}
@ -211,7 +216,7 @@ export class CombatSystem {
*/
_neutralOnly() {
const enemiesLive = this.enemies.some((e) => e && !e.dead);
const alliesLive = this.allies.some((a) => a && !a.dead && !a.gone && !a._removed);
const alliesLive = this.allies.some((a) => a && !a.persistent && !a.dead && !a.gone && !a._removed);
return alliesLive && !enemiesLive;
}
@ -334,10 +339,10 @@ export class CombatSystem {
const base = config.section('combat.neutral', {});
const emin = Math.max(1, Number(base.escortMin) || 10);
const emax = Math.max(emin, Number(base.escortMax) || 15);
const escortMs =
Number.isFinite(Number(o.escortMs)) && Number(o.escortMs) > 0
? Number(o.escortMs)
: (emin + Math.random() * (emax - emin)) * 1000;
const oE = Number(o.escortMs);
// A positive window (or +Infinity — the escort contract's convoy crew /
// transport never lapse) is taken as-is; otherwise roll the neutral band.
const escortMs = oE > 0 ? oE : (emin + Math.random() * (emax - emin)) * 1000;
const a = new NeutralShip(this.scene, x, y, index, cfg, {
escortMs,
viewFn: typeof o.viewFn === 'function' ? o.viewFn : () => this.visibleView(),
@ -352,6 +357,35 @@ export class CombatSystem {
return a;
}
/**
* The ESCORT contract's TRANSPORT (data/combat.json → transport —
* assets/images/ships-transport.png, frames 0–3): a PERSISTENT
* player-side freighter (js/combat/TransportShip.js). The break-off
* window is lapsed (it stays for the life of the contract), and it is
* flagged `persistent` — the `active` / `_neutralOnly` gates above skip
* it, so a standing convoy never holds the jump gate or the landing
* door. Degraded boot (no transport config) still flies: a persistent
* NEUTRAL craft stands in so the contract's route doesn't break.
* @returns {TransportShip|NeutralShip}
*/
spawnTransport(x, y, index, c = null) {
const cfg = c && typeof c === 'object' ? c : transportConfig();
if (!cfg) {
const a = this.spawnAlly(x, y, index, null, { escortMs: Number.POSITIVE_INFINITY });
a.persistent = true;
return a;
}
const a = new TransportShip(this.scene, x, y, index, cfg, { viewFn: () => this.visibleView() });
a.setDepth(10);
const px = this.ship ? this.ship.x : x;
const py = this.ship ? this.ship.y : y;
a.orbitAngle = Math.atan2(y - py, x - px); // anchor on the spawn bearing
a.nextFireAt = 1000; // the pack's grace (ms clock)
this.allies.push(a);
this._setActive();
return a;
}
// ----------------------------------------------------------------- waves
/**

View File

@ -162,3 +162,40 @@ export function neutralSheetList() {
for (const id of neutralTypeIds()) add(neutralTypeConfig(id));
return out;
}
// ------------------------------------------------------ the escort's transport
//
// The ESCORT contract's freighter (data/combat.json → transport) — a
// standalone section (not an enemyType, not a neutralType): it has its own
// spritesheet (assets/images/ships-transport.png), its own armor/weapon
// ramp, and a `persistent` flag the CombatSystem's active/neutralOnly
// gates skip. js/combat/TransportShip.js flies it; the helpers here are
// the pure seams (the full config + the preload list).
/**
* The transport's FULL config (data/combat.json → transport). Null when the
* section is absent or malformed (the escort's spawner then falls back to a
* NeutralShip so the contract still flies — degraded, not broken).
* @returns {object|null}
*/
export function transportConfig() {
const c = config.section('combat.transport', null);
return isPlain(c) ? c : null;
}
/**
* The transport's spritesheet (the preload seam — GameScene.preload loads
* exactly this, EnemyShip.textureKey naming the key). Null when the
* transport has no texture configured (then the built-in wedge stands in).
* @returns {{src: string, frameWidth: number, frameHeight: number}|null}
*/
export function transportSheet() {
const c = transportConfig();
const src = c && typeof c.texture === 'string' ? c.texture : '';
if (src === '') return null;
return {
src,
frameWidth: Math.max(1, Number(c.frameWidth) || 180),
frameHeight: Math.max(1, Number(c.frameHeight) || 320),
};
}

View File

@ -0,0 +1,60 @@
import { NeutralShip } from './NeutralShip.js';
/**
* The ESCORT CONTRACT's transport (data/combat.json → transport — the
* neutral freighter from assets/images/ships-transport.png): a PERSISTENT
* player-side craft the player rendevouses with at a local waypoint and
* leads through the jump gates to a planet / space station (js/scenes/
* GameScene.js → the escort's updateEscort).
*
* It rides the NeutralShip's escort/engage AI verbatim (hold the ring
* around the player, fight the NEAREST pirate up with its own volley,
* never aim at the player — js/combat/NeutralShip.js) with ONE difference
* that makes it a convoy member instead of a passing escort: the break-off
* window is lapsed (escortMs = Infinity), so it never peels off. It stays
* for the life of the contract — the contract's reset (GameScene's
* landing hook) is what ends it, not the escort clock.
*
* PLAYER-SAFE BY CONSTRUCTION (inherited): the target pool is the enemy
* roster, the collide passes never route an 'ally' bolt into the player,
* and the player's auto-fire never targets this roster.
*
* THE PERSISTENT FLAG (`persistent = true`) is the seam the CombatSystem
* reads (its `active` + `_neutralOnly` gates skip a persistent ally): a
* standing convoy must NOT hold the jump gate open or keep the
* encounter's "active" flag up — the fight is over the moment the last
* pirate is down, even with the convoy still flying.
*
* ART + SCALE: the transport sheet is 180×320 with the art facing north
* (the LONG side is the ship's length). EnemyShip's scale rule assumes a
* square sheet (scale = size / frameWidth) — wrong for a portrait
* freighter. So `size` (data → transport.size, 135) reads as the hull's
* WORLD LENGTH: ~2.5× a fighter's 54. We re-scale by the sheet's long
* side (320) and re-anchor the hit circle to the new scale.
*/
export class TransportShip extends NeutralShip {
/**
* @param {object} scene the combat scene
* @param {number} x spawn
* @param {number} y spawn
* @param {number} index slot (orbit direction alternates)
* @param {object} [c] the transport's FULL config (data → transport)
* @param {object} [o] NeutralShip's options (escortMs is lapsed below)
*/
constructor(scene, x, y, index, c = null, o = {}) {
// The break-off window is lapsed (Infinity) — a convoy member stays.
super(scene, x, y, index, c, { ...o, escortMs: Number.POSITIVE_INFINITY });
this.persistent = true; // CombatSystem's active/neutralOnly gates skip this
this.isTransport = true;
// Portrait-sheet re-scale (see the class doc): size = world length.
const fw = Number(c?.frameWidth) || 128;
const fh = Number(c?.frameHeight) || 128;
const longSide = Math.max(fw, fh);
const size = Number(this.size) || 54;
const s = size / longSide;
this.setScale(s);
const body = this.body;
if (body && typeof body.setCircle === 'function') body.setCircle(this.hitRadius / s);
}
}

View File

@ -1412,6 +1412,19 @@ function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier =
const meta = sideQuestCategoryMeta(cat);
if (!meta) return null;
// ESCORT FEASIBILITY — the board only posts an escort contract that is
// actually COMPLETABLE: a NEUTRAL destination 1–2 gate hops away (the
// contract's signature, fixed at the accept). When the system has no
// gate-reachable neutral world the escort slot stands EMPTY (a contract
// that can't be delivered is never offered — the player can't accept a
// broken escort). The check is per-contract (seeded on the posting's id),
// so it agrees EXACTLY with the destination the accept will fix (a signed
// contract is a signed contract).
if (cat === 'escort' && galaxy) {
const postingId = `sq-${rec.id}-${world}-${cat}-${slot}`;
if (!escortDestinationFor(seed, postingId, rec.id, galaxy)) return null;
}
const poolPath = `sidequests.descriptions.${ctx.context}.${cat}`;
let pool = config.get(poolPath, null);
if (!Array.isArray(pool)) pool = config.get(`sidequests.descriptions.neutral.${cat}`, null); // the neutral voice as fallback
@ -1614,6 +1627,221 @@ function normalizePatrolEncounters(raw, wpCount) {
return out;
}
// ------------------------------------------- the ESCORT (category 'escort')
// The convoy job (data/sidequests.json → escort). A neutral TRANSPORT (its own
// ship class — js/combat/TransportShip.js) plus 1–2 escort fighters stands at
// a single RENDEZVOUS waypoint in the posting's own system; the player links
// it and flies the convoy through 1–2 gate hops to a seeded DESTINATION
// (a planet or a free-space station) in a gate-reachable NEUTRAL system,
// through a heavily elevated lane of mixed-fighter waves (its own director,
// data → escort.encounters). If the transport is destroyed the contract fails
// (phase 'failed') and the player must LAND ON A PLANET to reset it (a fresh
// convoy stands at the rendezvous); arriving at the destination object with
// the convoy intact and the sky clear completes it.
//
// The scene's share (js/scenes/GameScene.js → _establishEscort / updateEscort)
// uses these pure pieces:
// escortDestinationFor(…) — the seeded 1–2 hop destination + object (fork
// 'dest'; a signed contract is a signed contract)
// escortEncounterXp(plan) — the claim's XP beside the credits (the lane's worth)
// escortWaveRoster(level) — the mixed roster for the player's combat level
// escortRouteComplete(route) — the delivery + sky-clear witness (scene-side)
/** A system record is NEUTRAL when it carries no faction (null/''/'neutral'). */
function isNeutralSystem(rec) {
if (!rec) return false;
const f = rec.faction;
return typeof f !== 'string' || f === '' || f.toLowerCase() === 'neutral';
}
/**
* The escort's DESTINATION — a planet or a free-space station 1–2 gate hops
* away in a gate-reachable NEUTRAL system (data → escort.destinationHops),
* rolled seeded on its own labelled fork (seed, 'sidequests', <origin>,
* 'escort', <jobId>, 'dest'). The origin is the posting's system (where the
* convoy stands); the walk takes NEUTRAL neighbors only (the neutral-only
* scope), the 2-hop leg falling back to a 1-hop when the far leg gives up.
* A destination object (planet first, then a free-space station, then any
* laid-down object) is picked seeded, so the same contract always points at
* the same world across re-accepts, jumps, and saves.
* @param {string|number} seed the galaxy's seed
* @param {string} jobId the accepted contract's id
* @param {string} originSystemId the convoy's standing system (the posting's)
* @param {object|null} [galaxy] the galaxy (jumpGatesFor / ensureContent)
* @returns {?{hops:number, path:string[], destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string}}
*/
export function escortDestinationFor(seed, jobId, originSystemId, galaxy = null) {
const origin = String(originSystemId ?? '').trim();
if (!origin || !galaxy || typeof galaxy.jumpGatesFor !== 'function') return null;
const hopsRange = config.get('sidequests.escort.destinationHops', [1, 2]);
let lo = Math.max(1, Math.floor(Number(Array.isArray(hopsRange) ? hopsRange[0] : 1)));
let hi = Math.max(lo, Math.floor(Number(Array.isArray(hopsRange) ? hopsRange[1] : 2)));
const r = Rng.derive(seed, seedLabel(), origin, 'escort', String(jobId), 'dest');
const hops = r.int(lo, hi);
const neighbors = (id) => {
let links = null;
try { links = galaxy.jumpGatesFor(id) ?? []; } catch { links = []; }
return (Array.isArray(links) ? links : [])
.filter((nb) => nb && typeof nb.id === 'string' && nb.id !== '' && nb.id !== origin && isNeutralSystem(nb));
};
// The 1-hop set (neutral neighbors of the origin, the origin itself out).
const first = neighbors(origin);
if (first.length === 0) return null; // a dead-end origin (a leaf with one gate back) — no destination stands
let dest = null;
let path = [origin];
if (hops >= 2) {
const mid = first[r.int(0, first.length - 1)];
const second = neighbors(mid).filter((nb) => nb.id !== mid.id);
if (second.length > 0) {
dest = second[r.int(0, second.length - 1)];
path = [origin, mid.id, dest.id];
}
}
if (!dest) {
// The 2-hop leg gave up (or asked for one): a 1-hop destination stands.
dest = first[r.int(0, first.length - 1)];
path = [origin, dest.id];
}
// The destination OBJECT — a planet first, then a free-space station, then
// any laid-down object (a leaf with no planets/stations still stands).
let content = null;
try { content = galaxy.ensureContent(dest.id) ?? null; } catch { content = null; }
const planets = (content && Array.isArray(content.planets) ? content.planets : [])
.filter((p) => Number.isFinite(p?.x) && Number.isFinite(p?.y));
const stations = (content && Array.isArray(content.settlements) ? content.settlements : [])
.filter((s) => s?.anchor?.type === 'space' && Number.isFinite(s?.x) && Number.isFinite(s?.y));
let obj = null;
let type = '';
if (planets.length > 0) {
obj = planets[r.int(0, planets.length - 1)];
type = 'planet';
} else if (stations.length > 0) {
obj = stations[r.int(0, stations.length - 1)];
type = 'station';
} else {
const any = (content ? [...(content.planets ?? []), ...(content.settlements ?? [])] : [])
.filter((o) => Number.isFinite(o?.x) && Number.isFinite(o?.y));
if (any.length === 0) return null; // a system with nothing laid down — no destination
obj = any[r.int(0, any.length - 1)];
type = obj?.anchor?.type === 'space' ? 'station' : 'planet';
}
return {
hops: path.length - 1,
path,
destSystemId: dest.id,
destSystemName: String(dest.name ?? dest.id),
destObjectType: type,
destObjectId: String(obj?.id ?? ''),
destObjectName: String(obj?.name ?? obj?.id ?? ''),
};
}
/**
* The escort's mixed-fighter ROSTER at the player's combat level (data →
* escort.encounters.bands: a { type: weight } table over combat.enemyTypes
* ids, the unknown-id rule being the patrol's — a type that isn't in
* combat.enemyTypes never ships; a roster that empties takes the standing
* type). A band that doesn't stand falls back to the data's fallbackRoster.
* @param {number} level the player's combat level
* @returns {{roster: Record<string,number>, total:number, maxConcurrent:number, replacePerKill:number}}
*/
export function escortWaveRoster(level = 0) {
const bands = config.get('sidequests.escort.encounters.bands', null);
const lv = Math.max(0, Math.floor(Number(level) || 0));
let band = null;
if (Array.isArray(bands)) {
for (const b of bands) {
if (!b) continue;
const lv2 = Array.isArray(b.levels) ? b.levels : [0, 999999];
if (lv >= (Number(lv2[0]) || 0) && lv <= (Number(lv2[1]) || 999999)) { band = b; break; }
}
}
const raw = band?.roster ?? config.get('sidequests.escort.encounters.fallbackRoster', { raider: 1 });
const valid = new Set(enemyTypeIds());
const roster = {};
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
for (const [t, w] of Object.entries(raw)) {
const key = String(t).trim();
if (key === '' || !valid.has(key)) continue;
const wt = Math.max(0, Number(w) || 0);
if (wt > 0) roster[key] = wt;
}
}
if (Object.keys(roster).length === 0) roster[ESTABLISHED_ENEMY_TYPES[0]] = 1;
const w = config.get('sidequests.escort.encounters.wave', {});
const total = Math.max(1, Math.floor(numOr(w.total, 8)));
return {
roster,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 4)))),
replacePerKill: Math.max(0, Math.floor(numOr(w.replacePerKill, 1))),
};
}
/**
* The escort contract's XP PAYOUT (the claim's second reward — beside the
* credits): the lane's experience WORTH, a flat worth scaled off the lane's
* wave (the data's wave total × the roster's mean xp × the hops). Fixed at
* the accept (a signed contract is a signed contract).
* @param {{hops?:number, level?:number}} [o]
* @returns {number}
*/
export function escortEncounterXp(o = {}) {
const w = escortWaveRoster(Number(o?.level) || 0);
const hops = Math.max(1, Math.floor(numOr(o?.hops, 1)));
let weightSum = 0;
let xpWeighted = 0;
for (const [t, wt] of Object.entries(w.roster)) {
weightSum += wt;
xpWeighted += wt * Math.max(0, Number(enemyTypeConfig(String(t))?.xp) || 0);
}
if (weightSum === 0) return 0;
const meanXp = xpWeighted / weightSum;
return Math.max(0, Math.round(w.total * meanXp * (1 + 0.25 * (hops - 1))));
}
/**
* An escort route's `escort` sub-record, normalized (SideQuestState.setRoute's
* pass): a valid phase, a destination system + object (finite), the transport's
* armor finite (clamped to a sane floor), the escort count a small int. A
* malformed record degrades to a fresh 'rendezvous' record on the same
* destination (the destination is the contract's signature — it is never
* dropped), so a hand-edited save or a future data shape can't break the
* convoy (it re-stands, at full armor, at the rendezvous).
* @param {*} raw the raw spec (_establishEscort's result, or a save's)
* @returns {{phase:string, originSystemId:string, originSystemName:string, destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string, hops:number, transportHull:number, transportShields:number, escortCount:number, name:string}}
*/
function normalizeEscort(raw) {
const PHASES = ['rendezvous', 'transit', 'failed', 'delivered'];
const phase = PHASES.includes(raw?.phase) ? raw.phase : 'rendezvous';
const escortCountRange = config.get('sidequests.escort.escortCount', [1, 2]);
const eLo = Math.max(0, Math.floor(Number(Array.isArray(escortCountRange) ? escortCountRange[0] : 1)));
const eHi = Math.max(eLo, Math.floor(Number(Array.isArray(escortCountRange) ? escortCountRange[1] : 2)));
const num = (v, fb) => (Number.isFinite(Number(v)) ? Math.max(0, Number(v)) : fb);
return {
phase,
originSystemId: String(raw?.originSystemId ?? ''),
originSystemName: String(raw?.originSystemName ?? ''),
destSystemId: String(raw?.destSystemId ?? ''),
destSystemName: String(raw?.destSystemName ?? ''),
destObjectType: raw?.destObjectType === 'station' ? 'station' : 'planet',
destObjectId: String(raw?.destObjectId ?? ''),
destObjectName: String(raw?.destObjectName ?? ''),
hops: Math.max(0, Math.floor(num(raw?.hops, 1))),
// THE ARMOR — the STANDING (max) hull is the reset's full armor; the LIVE
// hull is clamped to it. An old save (pre-max fields) carries only the
// live armor: the max falls back to it (a degraded record can't gain
// armor it never had). Live can't exceed max (a hand-edited save can't
// out-heal the standing hull).
transportMaxHull: (() => { const live = Math.max(1, Math.floor(num(raw?.transportHull, 1200))); return Math.max(1, Math.floor(num(raw?.transportMaxHull, live))); })(),
transportMaxShields: (() => { const live = Math.max(0, Math.floor(num(raw?.transportShields, 500))); return Math.max(0, Math.floor(num(raw?.transportMaxShields, live))); })(),
transportHull: Math.min(Math.max(1, Math.floor(num(raw?.transportHull, 1200))), Math.max(1, Math.floor(num(raw?.transportMaxHull, 1200)))),
transportShields: Math.min(Math.max(0, Math.floor(num(raw?.transportShields, 500))), Math.max(0, Math.floor(num(raw?.transportMaxShields, 500)))),
escortCount: Math.min(eHi, Math.max(eLo, Math.floor(num(raw?.escortCount, Math.floor((eLo + eHi) / 2))))),
name: String(raw?.name ?? ''),
};
}
export class SideQuestState {
constructor() {
this.accepted = new Set();
@ -1748,32 +1976,46 @@ export class SideQuestState {
(i) => Number.isInteger(i) && i >= 0 && i < waypoints.length,
),
)];
const kind = route.kind === 'bounty' ? 'bounty' : 'patrol';
const kind = route.kind === 'bounty'
? 'bounty'
: route.kind === 'escort'
? 'escort'
: 'patrol';
const encounters = normalizePatrolEncounters(route.encounters, waypoints.length);
const bounty = kind === 'bounty' ? normalizeBountyPlan(route.bounty, waypoints.length) : null;
// The ESCORT's convoy record — its phase + destination + the transport's
// armor (the live read-out the scene syncs each frame) + the escort
// count + the convoy's name. Only an escort route carries one; the
// destination is the contract's signature and is never dropped (a
// malformed record re-stands a fresh 'rendezvous' on the same world).
const escort = kind === 'escort' ? normalizeEscort(route.escort ?? route) : null;
this.routes.set(id, {
// The route's TYPE — 'patrol' (the default: every patrol, and every
// save written before the hunts existed) or 'bounty' (a hunt: SEQUENTIAL
// legs + the hidden plan). The type is what branches the scene's update;
// a patrol record normalizes exactly as it always did.
// save written before the hunts existed), 'bounty' (a hunt: SEQUENTIAL
// legs + the hidden plan), or 'escort' (a convoy: the rendezvous + the
// transport + the destination). The type is what branches the scene's
// update; a patrol record normalizes exactly as it always did.
kind,
targetSystemId: String(route.targetSystemId ?? ''),
credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's CREDITS payout — fixed at the accept
// The contract's XP payout (the claim's second reward — beside the
// credits): the accept fixes it on the record (patrolEncounterXp,
// the scene's _establishPatrol); a route that stands without one
// (an old save) derives it from its own spec — the same contract,
// a signed contract is a signed contract.
// credits): the accept fixes it on the record (patrolEncounterXp /
// escortEncounterXp, the scene's _establishPatrol / _establishEscort);
// a route that stands without one (an old save) derives it from its
// own spec — the same contract, a signed contract is a signed contract.
xp:
Number.isFinite(Number(route.xp)) && Number(route.xp) >= 0
? Number(route.xp)
: kind === 'bounty'
? bountyHuntXp(bounty)
: patrolEncounterXp(encounters),
: kind === 'escort'
? escortEncounterXp({ hops: escort?.hops ?? 1, level: 0 })
: patrolEncounterXp(encounters),
waypoints,
visited,
encounters,
bounty,
escort,
});
return true;
}

View File

@ -13,7 +13,7 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js';
import { EnemyShip } from '../combat/EnemyShip.js';
import { enemySheetList, enemyTypeIds, neutralTypeIds } from '../combat/EnemyTypes.js';
import { enemySheetList, enemyTypeIds, neutralTypeIds, transportSheet } from '../combat/EnemyTypes.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
@ -82,6 +82,9 @@ import {
nextBountyWaypoint,
bountyRouteComplete,
bountyText,
escortDestinationFor,
escortEncounterXp,
escortWaveRoster,
} from '../quests/SideQuests.js';
import { tutorialTarget } from '../quests/TutorialGuide.js';
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
@ -190,6 +193,19 @@ export class GameScene extends Phaser.Scene {
this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight });
}
// The ESCORT's TRANSPORT sheet (data/combat.json → transport.texture —
// assets/images/ships-transport.png, frames 0–3 at 180×320): a distinct
// file from the enemy sheet, loaded under its own key (the escort
// contract's freighter — js/combat/TransportShip.js).
const transportSheetInfo = transportSheet();
if (transportSheetInfo) {
this.load.spritesheet(
EnemyShip.textureKey(transportSheetInfo.src),
transportSheetInfo.src,
{ frameWidth: transportSheetInfo.frameWidth, frameHeight: transportSheetInfo.frameHeight },
);
}
// The asteroid spritesheet (data/asteroids.json → texture): 128×128
// rock frames, one per cluster member (the generator picks frames).
const asteroidTexture = config.get('asteroids.texture', '');
@ -1113,6 +1129,14 @@ export class GameScene extends Phaser.Scene {
// materializes the pack off-screen (the existing spawn path; their
// AI streams them in).
this.encounters = new EncounterDirector(config.get('combat.encounters', {}));
// THE ESCORT LANE'S DIRECTOR (data/sidequests.json → escort.encounters —
// tighter numbers than the normal director: a shorter roll interval,
// a near-certain chance, a short quiet). It owns no ships either: on a
// hit it's just a "go", and updateEscort fires the escort's mixed-fighters
// wave (escortWaveRoster's roster, bigger concurrent counts). One at a
// time (the wave run's completion is the quiet's gate — the patrol's law).
this.escortDirector = new EncounterDirector(config.get('sidequests.escort.encounters', {}));
this._escortTransport = null; // the escort contract's transport (the scene-local convoy reference)
this._encounterSerial = 10000; // off the dev console's 100+i band
// ?combat boot (the tuning sandbox): the SEVEN-TYPE DEMO around the
// ship (data/combat.json → waves.scripts.combat_demo) — a sequence
@ -3768,7 +3792,12 @@ export class GameScene extends Phaser.Scene {
*/
_encountersLockedByQuest() {
if (config.get('quests.enabled', true) === false) return false;
return this.questState?.isClaimed?.('special_delivery') !== true;
if (this.questState?.isClaimed?.('special_delivery') !== true) return true; // the 'Special Delivery' hold
// ESCORT transit: the convoy's own director owns the lane — the normal
// director holds (the escort's waves are the combat, one at a time).
const sel = this._escortRoute?.();
if (sel && sel.route.escort?.phase === 'transit') return true;
return false;
}
/**
@ -3864,6 +3893,12 @@ export class GameScene extends Phaser.Scene {
// story ledger's lastLandedWorld, and on the NEUTRAL CAPITAL — the
// bar: the trade gate lifts + the package lands in the hold.
this._recordLanding(name);
// ESCORT RESET (the failure's remedy): the transport is LOST and the
// player has landed — re-stand the contract. A fresh rendezvous in this
// system, a fresh transport, the same destination (the retry starts
// where they are). A no-op unless the escort is the active contract in
// its 'failed' phase (a normal landing — the convoy intact — never resets).
this._escortResetForLanding();
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? target.sheetFrame ?? 0),
name,
@ -5267,6 +5302,7 @@ export class GameScene extends Phaser.Scene {
// keeps the SAME coordinates — the route never re-rolls).
if (posting.category === 'patrol') this._establishPatrol(jobId, posting);
else if (posting.category === 'bounty') this._establishBountyHunt(jobId, posting);
else if (posting.category === 'escort') this._establishEscort(jobId, posting);
this.playSfx('construct'); // the power-up tick — the contract lands
return { ok: true, posting };
}
@ -5394,7 +5430,474 @@ export class GameScene extends Phaser.Scene {
this.sideQuestState.setRoute(jobId, route);
}
/** The active contract's ROUTE kind ('patrol' | 'bounty'); null with nothing flying. */
// ------------------------------------------------------- ESCORT (the convoy job)
//
// The third contract: a neutral TRANSPORT (its own ship class —
// js/combat/TransportShip.js, ~2.5× a fighter, far heavier armor) plus 1–2
// escort FIGHTERS stands at a single RENDEZVOUS waypoint in the posting's
// own system. The player flies to the waypoint (visitRadius) and the convoy
// LINKS (phase 'rendezvous' → 'transit') — from then on the transport +
// fighters hold their escort ring around the player through every remaining
// leg, re-forming across jumps (the route record carries the transport's
// armor — a signed contract is a signed contract). The DESTINATION is a
// planet / free-space station 1–2 gate hops away (a seeded neutral walk),
// fixed at the accept. While linked the LANE IS HOT: the escort director
// (its own EncounterDirector) fires waves of mixed fighter types, one at a
// time (the patrol contact's law). The TRANSPORT is the job: it fights
// alongside, it is a legal enemy target, and if it is DESTROYED the
// contract FAILS (phase 'failed') — the player must LAND ON A PLANET to
// reset it (startLanding's escort reset: a fresh rendezvous, a fresh
// transport, the same destination). COMPLETION — the convoy arrives at the
// destination object with the sky clear (no enemy fighter up).
/** The active ESCORT contract (its id + route record) or null. */
_escortRoute() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || st.isCompleted(id) || st.isClaimed(id)) return null;
const route = st.routeFor(id);
if (!route || route.kind !== 'escort' || !route.escort) return null;
return { id, route };
}
/**
* ACCEPT the escort (the board's ACCEPT → here): stand the RENDEZVOUS at a
* single waypoint in the posting's own system (the route builder's shared
* placement law, its 'escort' fork), compute the 1–2 hop neutral DESTINATION
* (seeded — a signed contract is a signed contract), and fix the reward +
* the transport's standing armor on the route record (the escort sub-record
// rides the save; setRoute normalizes it).
*/
_establishEscort(jobId, posting) {
const originId = String(posting?.systemId ?? this.systemRecord?.id ?? '');
if (!originId || !this.sideQuestState) return;
const level = Number(this.combat?.xp?.level) || 0;
// DESTINATION — the seeded 1–2 hop neutral walk (degrades: no destination
// stands → the route still flies, the delivery waits at the origin).
const dest = escortDestinationFor(this.galaxy?.seed ?? 0, jobId, originId, this.galaxy) ?? null;
// RENDEZVOUS — ONE waypoint inside the origin system's flight envelope.
const { region, anchors } = this._routeFlightEnvelope(originId);
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, originId, region, anchors, {
waypoints: [1, 1], // a single standing point — the convoy waits here
fork: 'escort', // its own seeded stream (a patrol of the same id never collides)
});
route.kind = 'escort'; // THE branch flag
route.targetSystemId = dest?.destSystemId ?? originId; // the DESTINATION system (compass / delivery)
route.credits = posting.reward?.credits ?? 0;
route.xp = dest ? escortEncounterXp({ hops: dest.hops, level }) : 0; // the claim's second reward
// THE TRANSPORT'S STANDING ARMOR (data/combat.json → transport) — the
// record is the contract: a jump or a save never re-rolls a standing hull.
const tc = config.get('combat.transport', {}) || {};
const thull = Math.max(1, Math.floor(Number(tc.hull) || 1200));
const tshields = Math.max(0, Math.floor(Number(tc.shields) || 500));
// THE ESCORT FIGHTERS' COUNT (data → escort.escortCount [1,2], seeded).
const erange = config.get('sidequests.escort.escortCount', [1, 2]);
const eLo = Math.max(0, Math.floor(Number(Array.isArray(erange) ? erange[0] : 1)));
const eHi = Math.max(eLo, Math.floor(Number(Array.isArray(erange) ? erange[1] : 2)));
const r = Rng.derive(this.galaxy?.seed ?? 0, 'sidequests', originId, 'escort', String(jobId), 'crew');
route.escort = {
phase: 'rendezvous',
originSystemId: originId,
originSystemName: String(this.systemRecord?.name ?? originId),
destSystemId: dest?.destSystemId ?? '',
destSystemName: dest?.destSystemName ?? '',
destObjectType: dest?.destObjectType ?? 'planet',
destObjectId: dest?.destObjectId ?? '',
destObjectName: dest?.destObjectName ?? '',
hops: dest?.hops ?? 1,
transportMaxHull: thull, // the STANDING armor (a reset re-stands at this)
transportMaxShields: tshields,
transportHull: thull, // the LIVE armor (persists across a jump's restart)
transportShields: tshields,
escortCount: eHi >= eLo ? r.int(eLo, eHi) : 1,
name: String(posting?.title ?? posting?.label ?? 'the Convoy'), // the convoy's name (the posting's title)
};
this.sideQuestState.setRoute(jobId, route);
// POINTING — the rendezvous beacon is already up (_drawEscortMarkers);
// a pre-link toast calls the player over to it.
const t = config.get('sidequests.ui.escortRendezvousToast', {});
this.playSfx('discovery');
this.consoleToast(String(t.text ?? 'CONVOY STANDING BY — REACH THE RENDEZVOUS TO LINK'), {
glyph: t.glyph ?? '◇', glyphColor: t.color ? toCss(t.color) : undefined,
});
}
/**
* The compass's next stop for the escort: before the link, the RENDEZVOUS
* waypoint (in the origin system); after the link, the DESTINATION — its
* object (when already in the destination system), else the gate that jumps
* toward it (planRoute's next). The escort's own color + type label.
*/
escortCompassTarget() {
const sel = this._escortRoute();
if (!sel || !this.ship || !this.systemRecord) return null;
const { route } = sel;
const esc = route.escort;
const style = {
color: config.get('sidequests.escort.compass.color', '#7ce8a4'),
typeLabel: config.get('sidequests.escort.compass.typeLabel', 'Escort'),
};
const originId = esc.originSystemId;
const destId = esc.destSystemId;
// BEFORE the link (or with no destination): the rendezvous waypoint.
if (esc.phase === 'rendezvous' || !destId) {
if (originId === this.systemRecord.id) {
const wp = Array.isArray(route.waypoints) && route.waypoints.length > 0 ? route.waypoints[0] : null;
if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1])) return { kind: 'waypoint', idx: 0, x: wp[0], y: wp[1], ...style };
}
// Left the origin (defensive) — the gate back to it.
for (const gt of this.systemGates ?? []) if (gt.gate?.to === originId) return { kind: 'gate', gate: gt, ...style };
return null;
}
// AFTER the link: the DESTINATION.
if (destId === this.systemRecord.id) {
const obj = this._escortDestinationObject(route);
if (obj) return { kind: 'waypoint', idx: 0, x: obj.x, y: obj.y, ...style };
}
// Not there yet — the gate toward the next hop on the way to it.
let next = null;
try {
next = planRoute(this.galaxy, this.systemRecord.id, destId)?.next ?? null;
} catch { next = null; }
if (next) for (const gt of this.systemGates ?? []) if (gt.gate?.to === next) return { kind: 'gate', gate: gt, ...style };
return null;
}
/**
* The destination OBJECT in the CURRENT system (the delivery's final stop) —
* matched by its record id / name against the live system content (the scene's
* `systemContent` IS galaxy.ensureContent, so the ids line up with the record).
* Null when not in the destination system (the compass / delivery wait).
*/
_escortDestinationObject(route) {
const esc = route.escort;
if (!esc || !esc.destSystemId) return null;
if (route.targetSystemId !== this.systemRecord?.id) return null; // not here yet
const content = this.systemContent ?? null;
if (!content) return null;
const id = String(esc.destObjectId ?? '');
const name = String(esc.destObjectName ?? '');
const pool = [
...(Array.isArray(content.planets) ? content.planets : []),
...(Array.isArray(content.settlements) ? content.settlements : []),
];
const rec =
pool.find((o) => o && String(o.id) === id && id !== '') ??
pool.find((o) => o && String(o.name) === name && name !== '');
if (rec && Number.isFinite(rec.x) && Number.isFinite(rec.y)) return { x: rec.x, y: rec.y, id: rec.id, name: rec.name };
return null;
}
/**
* THE CONVOY — stand the transport + its escort fighters around the player.
* Idempotent + self-healing: a scene restart (a jump) tears the scene-local
* sprites down, so this re-forms them on the next frame; a reset (a landing)
// re-stands a fresh transport at full armor. The record's armor is the
// contract — a jump/save never re-rolls a standing hull.
*/
_ensureEscortConvoy(id, route) {
if (!this.combat) return; // no combat system — the route degrades to the flight alone
const esc = route.escort;
if (esc.phase === 'failed' || esc.phase === 'delivered') return;
const px = this.ship?.x ?? 0;
const py = this.ship?.y ?? 0;
// TRANSPORT — a persistent player-side craft (the job itself).
if (!this._escortTransport || this._escortTransport.dead) {
const a = Math.PI * 0.9; // behind the player, on its left
const rad = 130 + 40 * (Number(esc.escortCount) || 1);
const t = this.combat.spawnTransport(px + Math.cos(a) * rad, py + Math.sin(a) * rad, 0);
// THE ARMOR — the record is the contract: the STANDING (max) hull is
// fixed, and the LIVE hull is restored exactly (a jump never heals a
// scrape; a reset — _escortResetForLanding — is what re-stands it full).
const maxH = Math.max(1, Math.floor(Number(esc.transportMaxHull) || Number(esc.transportHull) || 1));
const maxS = Math.max(0, Math.floor(Number(esc.transportMaxShields) || Number(esc.transportShields) || 0));
t.maxHull = maxH;
t.hull = Math.min(maxH, Math.max(1, Number(esc.transportHull) || maxH));
t.maxShields = maxS;
t.shields = Math.min(maxS, Math.max(0, Number(esc.transportShields) || maxS));
this._escortTransport = t;
}
// ESCORT FIGHTERS — persistent too (they hold the wing for the contract's
// life). Stand the record's count (a lost one re-forms on the next frame).
const want = Math.max(0, Math.floor(Number(esc.escortCount) || 0));
let have = 0;
for (const a of this.combat.allies) if (a && a._escortCrew && !a.dead && !a._removed) have++;
for (let i = 0; i < want - have; i++) {
const ang = Math.PI * (1.1 + i * 0.6);
const rad = 150 + i * 34;
const f = this.combat.spawnAlly(px + Math.cos(ang) * rad, py + Math.sin(ang) * rad, 40 + i, null, {
escortMs: Number.POSITIVE_INFINITY, // a convoy fighter stays (no break-off)
});
f.escortMs = Number.POSITIVE_INFINITY; // the window never lapses (spawnAlly's roll is finite)
f.persistent = true; // the active / neutralOnly gates skip it (like the transport)
f._escortCrew = true; // the count above tracks only these
}
}
/**
* THE CONVOY STOPS — pull the transport + its fighters out of the world (the
* contract is over: delivered, or the transport is lost). A clean teardown —
// no kill FX, no feed entry (they stand down, they aren't shot down).
*/
_escortBreakConvoy() {
if (!this.combat) return;
const kill = (a) => {
if (!a || a.destroyed) return;
try { this.combat.fx?.releaseCraft?.(a); } catch { /* fx optional */ }
try { a.destroy(); } catch { /* already gone */ }
};
for (const a of [...(this.combat.allies ?? [])]) if (a && (a.isTransport || a._escortCrew || a === this._escortTransport)) kill(a);
this.combat.allies = (this.combat.allies ?? []).filter((a) => !(a && (a.isTransport || a._escortCrew || a === this._escortTransport)));
this._escortTransport = null;
try { this.combat._setActive?.(); } catch { /* defensive */ }
}
/** The escort wave's SCRIPT (a single rolling wave — escortWaveRoster's roster). */
escortWaveScript(level = 0) {
const r = escortWaveRoster(level);
return {
waves: [{
kind: 'rolling',
roster: r.roster,
total: r.total,
maxConcurrent: r.maxConcurrent,
replacePerKill: r.replacePerKill,
}],
};
}
/** THE LANE'S COMBAT — one mixed-fighters wave at a time (the patrol's law). */
_fireEscortWave(id, route) {
if (!this.combat || typeof this.combat.startWaves !== 'function') return;
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // one at a time
if (!this.escortDirector) return;
const level = Number(this.combat.xp?.level) || 0;
const comp = this.escortDirector.update(this.time?.currentTime ?? 0, {
allowed: this._escortLaneAllowed(),
live: this.combat.active,
xpLevel: level,
});
if (!comp) return; // the clock hasn't rolled a hit (yet)
const total = Number(escortWaveRoster(level).total) || 8;
const t = config.get('sidequests.ui.patrolContactToast', {});
this.playSfx('discovery');
this.consoleToast(
String(t.text ?? 'HOSTILE CONTACT — {n} ENEMIES INBOUND').replace('{n}', String(total)),
{ glyph: t.glyph ?? '⚔', glyphColor: t.color ? toCss(t.color) : undefined },
);
this.combat.startWaves(this.escortWaveScript(level), {
onComplete: () => {
this.playSfx('construct'); // the cleared tick
const tc = config.get('sidequests.ui.patrolContactClearedToast', {});
this.consoleToast(
String(tc.text ?? 'LANE CLEARED — THE CONVOY HOLDS').replace('{n}', String(total)),
{ glyph: tc.glyph ?? '✓', glyphColor: tc.color ? toCss(tc.color) : undefined },
);
},
});
}
/** The escort lane's gate — neutral, non-home, the player upright, no other run. */
_escortLaneAllowed() {
if (config.get('sidequests.escort.encounters.enabled', true) === false) return false;
if (!this.ship || this.combat?.playerDown) return false;
if (this.isHomeSystem) return false;
if (this.systemRecord?.faction != null) return false; // the lane is neutral space
const run = this.combat?.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return false; // one at a time
return true;
}
/** THE SKY IS CLEAR? — no enemy fighter up (the delivery's witness). */
_escortFieldClear() {
const c = this.combat;
if (!c) return true;
if (c.active) return false;
return !c.enemies.some((e) => e && !e.dead);
}
/**
* The active escort's FRAME — the per-frame contract: the convoy re-forms
* (across a jump's scene restart), the link lands on the rendezvous, the lane
* fires its waves, the transport's loss FAILS the contract, and the delivery
* completes it. Reads the route's OWN phase (the record is the state — a save
// re-stands the convoy exactly where the contract left it).
*/
updateEscort() {
const sel = this._escortRoute();
if (!sel) return;
const { id, route } = sel;
const esc = route.escort;
if (!this.ship || !this.systemRecord) return;
// TRANSPORT LOST — the contract FAILS (the convoy breaks off). The player
// must LAND ON A PLANET to reset (startLanding's escort reset re-stands it).
if (esc.phase === 'rendezvous' || esc.phase === 'transit') {
if (this._escortTransport && this._escortTransport.dead) {
esc.phase = 'failed';
this.sideQuestState.setRoute(id, route); // persist the phase
this._escortBreakConvoy();
const t = config.get('sidequests.ui.escortFailToast', {});
this.playSfx('ui_close');
this.consoleToast(String(t.text ?? 'TRANSPORT LOST — LAND ON A PLANET TO RESET THE CONTRACT'), {
glyph: t.glyph ?? '✕', glyphColor: t.color ? toCss(t.color) : undefined, durationMs: 4200,
});
return;
}
}
if (esc.phase === 'failed' || esc.phase === 'delivered') return; // over (a landing resets / a claim closes)
// RENDEZVOUS LINK — the ship reaches the convoy's standing point; the convoy
// forms on the wing and the lane goes hot.
if (esc.phase === 'rendezvous' && esc.originSystemId === this.systemRecord.id) {
const wp = Array.isArray(route.waypoints) && route.waypoints.length > 0 ? route.waypoints[0] : null;
const radius = Math.max(1, Number(config.get('sidequests.escort.visitRadius', 460)) || 460);
if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1]) && Math.hypot(this.ship.x - wp[0], this.ship.y - wp[1]) <= radius) {
esc.phase = 'transit';
this.sideQuestState.setRoute(id, route); // persist the phase
this._ensureEscortConvoy(id, route); // form on the wing
const t = config.get('sidequests.ui.escortLinkToast', {});
this.playSfx('construct');
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'),
{ glyph: t.glyph ?? '➳', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
return;
}
// TRANSIT — the convoy follows (re-forms after a jump), the lane fires its
// waves, and the delivery lands on arrival at the destination object.
this._ensureEscortConvoy(id, route);
// KEEP THE RECORD'S LIVE ARMOR IN SYNC (the damage survives a jump's
// scene restart — a fresh spawn reads esc.transportHull, not full). Write
// only on change, so a steady convoy doesn't dirty the save every frame.
const tr = this._escortTransport;
if (tr && !tr.dead) {
const nh = Math.max(0, Math.floor(tr.hull));
const ns = Math.max(0, Math.floor(tr.shields));
if (nh !== esc.transportHull || ns !== esc.transportShields) {
esc.transportHull = nh;
esc.transportShields = ns;
this.sideQuestState.setRoute(id, route);
}
}
if (this._escortLaneAllowed()) this._fireEscortWave(id, route);
if (esc.destSystemId === this.systemRecord.id) {
const obj = this._escortDestinationObject(route);
const radius = Math.max(1, Number(config.get('sidequests.escort.visitRadius', 460)) || 460);
if (obj && Math.hypot(this.ship.x - obj.x, this.ship.y - obj.y) <= radius && this._escortFieldClear()) {
esc.phase = 'delivered';
this.sideQuestState.setRoute(id, route);
this._escortBreakConvoy(); // the job's done — the convoy stands down
if (!this.sideQuestState.isCompleted(id)) this.sideQuestState.complete(id);
const t = config.get('sidequests.ui.escortArrivedToast', {});
this.playSfx('construct');
this.consoleToast(String(t.text ?? 'CONVOY DELIVERED — {name} IS THROUGH').replace('{name}', esc.name || 'the convoy'), {
glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined,
});
}
}
}
/**
* THE ESCORT RESET (startLanding's hook — the failure's remedy): the
* transport is LOST and the player has landed on a planet — re-stand the
* contract. A fresh RENDEZVOUS stands in the CURRENT system (the one they
* just landed in — the retry starts where they are), a fresh TRANSPORT at
* full armor (the standing hull is the record's, restored on the next
* _ensureEscortConvoy), the SAME destination (a signed contract is a signed
* contract). The broken convoy is already gone (the failure's break-off);
* this re-arms the rendezvous + the lane's director so the run can be flown
* again. A no-op unless the escort is the active contract in 'failed'.
*/
_escortResetForLanding() {
const sel = this._escortRoute?.();
if (!sel) return;
const { id, route } = sel;
const esc = route.escort;
if (!esc || esc.phase !== 'failed') return; // only a LOST contract resets (an intact convoy never does)
const hereId = String(this.systemRecord?.id ?? esc.originSystemId ?? '');
if (!hereId) return;
// A fresh RENDEZVOUS in the CURRENT system (its own seeded stream — a
// distinct standing point from the first, so the retry's beacon is fresh).
let fresh = null;
try {
const { region, anchors } = this._routeFlightEnvelope(hereId);
fresh = buildPatrolRoute(this.galaxy?.seed ?? 0, id, hereId, region, anchors, {
waypoints: [1, 1],
fork: 'escort-reset',
});
} catch { fresh = null; }
route.waypoints = fresh?.waypoints?.length > 0 ? fresh.waypoints : [[0, 0]];
esc.phase = 'rendezvous';
esc.originSystemId = hereId;
esc.originSystemName = String(this.systemRecord?.name ?? hereId);
// A FRESH TRANSPORT at FULL armor (the standing hull — the scrape is gone).
esc.transportHull = esc.transportMaxHull;
esc.transportShields = esc.transportMaxShields;
this._escortTransport = null; // the dead reference — a fresh one stands on the next frame
// Re-arm the lane's director (a fresh roll schedule for the retry).
if (this.escortDirector) {
this.escortDirector.state = 'idle';
this.escortDirector.nextRollAt = null;
this.escortDirector.quietUntil = 0;
}
this.sideQuestState.setRoute(id, route); // persist the re-stood contract
const t = config.get('sidequests.ui.escortResetToast', {});
this.playSfx('construct');
this.consoleToast(String(t.text ?? 'CONTRACT RESET — A FRESH CONVOY STANDS AT THE RENDEZVOUS'), {
glyph: t.glyph ?? '➳', glyphColor: t.color ? toCss(t.color) : undefined,
});
}
/**
* The escort's in-world BEACONS — the rendezvous point (in the origin system, until the link) and the destination object (in the destination system, from the link on). The escort's green (data → escort.marker). Mirrors _drawPatrolMarkers' shape (the shared beacon layer).
*/
_drawEscortMarkers(time) {
const sel = this._escortRoute();
if (!sel) return;
const { route } = sel;
const esc = route.escort;
const color = config.get('sidequests.escort.marker.color', '#7ce8a4');
const doneColor = config.get('sidequests.escort.marker.doneColor', '#67e863');
const g = this.patrolG;
if (!g) return;
g.clear();
const t = (time ?? 0) / 1000;
const pulse = 0.6 + 0.4 * Math.sin(t * 2.4);
// RENDEZVOUS (origin system, until the link) — the convoy's standing point.
if (esc.originSystemId === this.systemRecord.id && esc.phase === 'rendezvous') {
const wp = Array.isArray(route.waypoints) && route.waypoints.length > 0 ? route.waypoints[0] : null;
if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1])) {
const c = toColor(color);
g.lineStyle(2, c, pulse);
g.strokeCircle(wp[0], wp[1], 26 + 6 * Math.sin(t * 2.4));
g.lineStyle(1.5, c, 0.9);
g.strokeCircle(wp[0], wp[1], 12);
g.fillStyle(c, 0.9 * pulse);
g.fillCircle(wp[0], wp[1], 4);
}
}
// DESTINATION (destination system, from the link on) — the delivery's stop.
if (esc.phase === 'transit' && esc.destSystemId === this.systemRecord.id) {
const obj = this._escortDestinationObject(route);
if (obj) {
const c = toColor(color);
g.lineStyle(2, c, pulse);
g.strokeCircle(obj.x, obj.y, 34 + 7 * Math.sin(t * 2.2));
g.lineStyle(1.5, c, 0.9);
g.strokeCircle(obj.x, obj.y, 15);
g.fillStyle(c, 0.9 * pulse);
g.fillCircle(obj.x, obj.y, 5);
}
}
}
/** The active contract's ROUTE kind ('patrol' | 'bounty' | 'escort'); null with nothing flying. */
_sideRouteKind() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
@ -5404,13 +5907,17 @@ export class GameScene extends Phaser.Scene {
/** Per-frame route update — the record's OWN kind picks the law (patrol untouched). */
updateSideQuestRoute() {
if (this._sideRouteKind() === 'bounty') this.updateBountyHunt();
const kind = this._sideRouteKind();
if (kind === 'bounty') this.updateBountyHunt();
else if (kind === 'escort') this.updateEscort();
else this.updatePatrol();
}
/** The route's in-world beacons, same dispatch (the hunt paints no contact red — see _drawBountyMarkers). */
_drawSideQuestMarkers(time) {
if (this._sideRouteKind() === 'bounty') this._drawBountyMarkers(time);
const kind = this._sideRouteKind();
if (kind === 'bounty') this._drawBountyMarkers(time);
else if (kind === 'escort') this._drawEscortMarkers(time);
else this._drawPatrolMarkers(time);
}
@ -5423,7 +5930,9 @@ export class GameScene extends Phaser.Scene {
* at the gate that jumps there (the patrol's rule).
*/
activeRouteCompassTarget() {
if (this._sideRouteKind() !== 'bounty') return this.patrolCompassTarget();
const kind = this._sideRouteKind();
if (kind === 'escort') return this.escortCompassTarget();
if (kind !== 'bounty') return this.patrolCompassTarget();
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.ship || !this.systemRecord) return null;