Rework faction systems into active war zones with faction-driven encount

- Faction-controlled systems now spawn only opposing-faction hostiles (never pirates/neutrals), and local-faction fighters join the player as allies during the fight.
- Rework patrol, bounty, and escort side-quests to the faction war model: patrols are always in-system vs one seeded rival; bounties target a rival's member in a hostile neighbor gated on jump-gate access; escorts rescue a trapped transport in hostile space and deliver it back through the gate into friendly territory.
- Implement per-faction reputation standing (setFaction/changeFaction) with save persistence; faction-owned places now resolve standingFor() via the controlling faction.
- Job boards on faction worlds post only faction-context missions with generic war-voice descriptions ({enemy} placeholder); neutral boards unchanged.
- Update all dev test suites (encounters, bounty-hunts, escort, escort-flow, reputation, sidequests) to cover the new faction rules and gate locks.
This commit is contained in:
Brian Fertig 2026-09-29 21:22:42 -06:00
parent b8e537787d
commit f9f5dca694
28 changed files with 2352 additions and 462 deletions

View File

@ -1 +1 @@
{"version":1,"dirMtimeMs":1790729816668.1836,"goals":[]} {"version":1,"dirMtimeMs":1790737651584.4287,"goals":[]}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,149 @@
{
"version": 3,
"id": "munfk6gc-xfi6jf",
"objective": "Make faction-controlled star systems play as active war zones: hostile encounters come only from the two opposing factions (never neutrals or pirates), local-faction fighters join the player in combat against them, planet reputation on faction worlds is governed by standing with the controlling faction (starting neutral), faction worlds' job boards post only faction missions, and the three live side-quest types are reworked to the faction war model — patrol (inside the issuing system vs. a sighted opposing faction), bounty (hunting a wanted opposing-faction target in a nearby hostile faction system, gated on jump-gate access to that system), and escort (rescuing a trapped friendly transport in a nearby hostile faction system and flying the convoy physically through the jump gate into friendly territory).\n\nSuccess criteria:\n- In any faction-controlled system: no neutral ships or pirate ships ever spawn as ambient encounters; only ships of the two opposing factions do, and they are hostile to the player on sight. Neutral wild space behavior (pirates/neutrals) is unchanged.\n- While the player fights opposing-faction ships inside faction A's territory, one or more faction A fighters join the fight as player allies (engage the enemy NPCs, never the player).\n- Reputation standingFor() on a faction-owned world resolves to the player's standing with that faction; a new player has neutral (0) standing with all three factions; faction standings are persisted in saves. (Mechanics that CHANGE standing are explicitly out of scope — the seam is in place.)\n- Job boards on faction worlds post only faction-context postings (patrol/bounty/escort, generic placeholder descriptions with faction labels); neutral-world boards keep posting neutral missions.\n- Patrol: route is always built inside the issuing system; the encounter enemy is one of the two opposing factions (seeded per posting); description is a generic \"reports of <faction> in-system\" placeholder.\n- Bounty: target system is a nearby opposing-faction system whose owner matches the wanted target's faction; the posting is locked/unacceptable until the player's jump-gate capability is unlocked for that system (existing gate lock mechanics); combat is the wanted target + escorts owned by that system's faction.\n- Escort: the friendly transport + escort fighters stand in a nearby hostile faction system behind the first waypoint; finding them arms the escort lane (mixed enemy-faction attacks along the way); the convoy follows and must physically fly through the jump gate into the issuing friendly faction system — completion only on arrival.\n- Delivery remains offline (category gate stays off).\n- Determinism contract preserved: same seed ⇒ same postings, routes, targets (labelled Rng forks); dev/*.test.mjs suites pass.\n\nBoundaries:\n- In scope: encounter director context for faction systems, ally-fighter join mechanic, factionStanding() implementation + save persistence, job-board context filtering, patrol/bounty/escort rework per above, generic description placeholders, node tests.\n- Out of scope: anything that changes faction standing over time (reputation-gain/loss rules), delivery category, faction-specific flavor text (placeholders now), NPC-vs-NPC auto-resolved combat, diplomacy/trade effects of standing.\n\nConstraints:\n- Keep modules pure where they are today (SideQuests.js, Encounters.js, Reputation.js testable in bare Node; scene owns spawning/Phaser).\n- Preserve the worldgen determinism contract (seeded, labelled Rng forks; same seed ⇒ same output).\n- Respect existing save compatibility patterns: new save fields load as sane defaults in old saves (existing fromJSON fallback style); in-flight quest ledger rules follow the documented Path A precedent where they conflict.\n- Player's home system stays safe space — no hostiles ever placed there.\n- No new art assets; use existing faction fighter rosters (EnemyTypes / DuelPicker rosters) and the existing TransportShip.\n\nVerification contract:\n- Run all dev/*.test.mjs node test suites (0 failures), including updated patrol/bounty/escort/encounters/reputation tests.\n- Manual checklist via the dev boot flags where they exist: spawn into a faction system (no neutral/pirate spawns; opposing-faction hostiles; ally join on engagement), neutral system (pirates/neutrals still spawn), faction job board (only faction postings, patrol route in-system, bounty gated on gate access, escort find-then-fly-through-gate completion), neutral board unchanged, old save loads without error with neutral faction standings.\n- Re-read this goal's requirements and confirm every item is addressed before marking complete.\n\nIf blocked: stop and ask the user.",
"status": "complete",
"autoContinue": true,
"usage": {
"tokensUsed": 20479313,
"activeSeconds": 4724
},
"sisyphus": false,
"revision": 306,
"createdAt": "2026-09-30T01:32:17.964Z",
"updatedAt": "2026-09-30T03:07:31.585Z",
"scheduler": {
"version": 1,
"owner": "01a0efeb-794f-75c1-ab86-375a32984c6e",
"generation": "ba7c0cd9-889a-481c-b926-1851d42f83af",
"used": 1,
"phase": "running",
"repairUsed": false,
"decision": {
"kind": "ready",
"nextAction": "Continue pursuing the goal, then verify and complete it when satisfied.",
"purpose": "ready"
},
"dispatch": {
"id": "a6a203f5-1406-4744-83f8-e5530332ece5",
"kind": "ready",
"claimedAt": 1790731938027
}
},
"taskList": {
"tasks": [
{
"id": "task-1",
"title": "Faction encounters + ally join",
"status": "complete",
"verificationContract": "Encounter director rolls opposing-faction kinds (B and C) in faction A systems and never pirates/neutrals there; neutral-space rolls unchanged; faction A fighters spawn as allies when the player engages B/C in A's territory, fight enemy NPCs, never the player; node tests for the pure roll/composition rules pass.",
"completedAt": "2026-09-30T01:43:00.803Z",
"evidence": "Encounters.compose gains faction context (opposing-faction roll, band-sized, seeded); encounterAllowed admits factionContext; GameScene passes _encounterContext + _spawnFactionEncounter (hostiles from"
},
{
"id": "task-2",
"title": "Faction standing in reputation + saves",
"status": "complete",
"verificationContract": "factionStanding() returns stored per-faction standing (neutral 0 for a new player); standingFor() on faction worlds resolves via faction standing; standings serialize in toJSON/fromJSON with old-save fallback to neutral; reputation.test.mjs passes.",
"completedAt": "2026-09-30T01:35:02.798Z",
"evidence": "reputation.test.mjs 80 checks pass incl. new faction-standing checks; toJSON/fromJSON carry `factions` with old-save fallback; commsTargetFor passes {id,owner} to standingFor."
},
{
"id": "task-3",
"title": "Job board context filtering + generic faction descriptions",
"status": "complete",
"verificationContract": "Faction worlds post only patrol/bounty/escort faction-context postings with generic placeholder text ({faction}/{issuer}/{enemies} resolved); neutral worlds post unchanged neutral missions; delivery stays offline; postings remain deterministic per seed/world.",
"completedAt": "2026-09-30T02:51:55.026Z",
"evidence": "buildPosting faction branch posts patrol/bounty/escort with enemyFaction; enabledSideQuestCategories filters by contexts (delivery offline); neutral boards unchanged; {enemy} placeholders resolve to t"
},
{
"id": "task-4",
"title": "Patrol: in-system vs sighted opposing faction",
"status": "complete",
"verificationContract": "patrolTargetSystem always resolves to the issuing system; encounter enemy is one seeded opposing faction (not both, not neutral/pirate); generic sighting-report description; buildPatrolRoute/encounter tests updated and passing.",
"completedAt": "2026-09-30T02:52:12.995Z",
"evidence": "patrolTargetSystem → issuing system always; seededOpposingFaction picks one rival (never pirate/both); derivePatrolEncounters roster = that faction's types (apex_/zenith_ verified, no raider/bomber); "
},
{
"id": "task-5",
"title": "Bounty: hostile-system hunt gated on jump-gate access",
"status": "complete",
"verificationContract": "Bounty target system is a nearby system owned by the target's own (opposing) faction; posting locked/unacceptable until gate access to that system is unlocked; combat = wanted target + escorts of that faction; bounty tests updated and passing.",
"completedAt": "2026-09-30T02:52:30.658Z",
"evidence": "hostileNeighborSystem picks a gate-reachable rival system (owner = target's faction); buildPosting sets targetSystemId=ground.id + gateLevel; sideQuestAcceptable gates on gateLevel (target-driven gate"
},
{
"id": "task-6",
"title": "Escort: rescue + convoy through the gate into friendly space",
"status": "complete",
"verificationContract": "Escort posting arms only when a reachable hostile-system leg stands; first waypoint → trapped transport + escort fighters; escort lane (mixed opposing-faction attacks) active during the leg; convoy physically flies through the jump gate into the issuing friendly faction system and completion fires only on arrival; escort tests updated and passing.",
"completedAt": "2026-09-30T02:52:48.889Z",
"evidence": "buildPosting faction escort: enemySystemId=hostile neighbor (rendezvous), destSystemId=board's own friendly system; _establishEscort faction branch (origin=hostile, dest=friendly via escortFriendlyDes"
},
{
"id": "task-7",
"title": "Full verification pass",
"status": "complete",
"verificationContract": "All dev/*.test.mjs suites run with 0 failures; manual checklist from the goal's verification contract executed (faction/neutral spawn rules, ally join, boards, gate-gated bounty, escort gate fly-through, old save load); every objective item re-checked against the requirements.",
"completedAt": "2026-09-30T02:57:43.411Z",
"evidence": "Final run: 79/79 dev/*.test.mjs suites PASS, 0 failures (5 browser suites with dev server up). Syntax OK on all 5 modified modules. Manual checklist re-verified against code: faction/neutral spawn (en"
}
],
"blockCompletion": true,
"proposedAt": "2026-09-30T01:31:39.493Z"
},
"stopReason": "agent",
"archivedPath": ".pi/goals/archived/goal_2026092921073158_munfk6gc-xfi6jf.md"
}
# Goal Prompt
Make faction-controlled star systems play as active war zones: hostile encounters come only from the two opposing factions (never neutrals or pirates), local-faction fighters join the player in combat against them, planet reputation on faction worlds is governed by standing with the controlling faction (starting neutral), faction worlds' job boards post only faction missions, and the three live side-quest types are reworked to the faction war model — patrol (inside the issuing system vs. a sighted opposing faction), bounty (hunting a wanted opposing-faction target in a nearby hostile faction system, gated on jump-gate access to that system), and escort (rescuing a trapped friendly transport in a nearby hostile faction system and flying the convoy physically through the jump gate into friendly territory).
Success criteria:
- In any faction-controlled system: no neutral ships or pirate ships ever spawn as ambient encounters; only ships of the two opposing factions do, and they are hostile to the player on sight. Neutral wild space behavior (pirates/neutrals) is unchanged.
- While the player fights opposing-faction ships inside faction A's territory, one or more faction A fighters join the fight as player allies (engage the enemy NPCs, never the player).
- Reputation standingFor() on a faction-owned world resolves to the player's standing with that faction; a new player has neutral (0) standing with all three factions; faction standings are persisted in saves. (Mechanics that CHANGE standing are explicitly out of scope — the seam is in place.)
- Job boards on faction worlds post only faction-context postings (patrol/bounty/escort, generic placeholder descriptions with faction labels); neutral-world boards keep posting neutral missions.
- Patrol: route is always built inside the issuing system; the encounter enemy is one of the two opposing factions (seeded per posting); description is a generic "reports of <faction> in-system" placeholder.
- Bounty: target system is a nearby opposing-faction system whose owner matches the wanted target's faction; the posting is locked/unacceptable until the player's jump-gate capability is unlocked for that system (existing gate lock mechanics); combat is the wanted target + escorts owned by that system's faction.
- Escort: the friendly transport + escort fighters stand in a nearby hostile faction system behind the first waypoint; finding them arms the escort lane (mixed enemy-faction attacks along the way); the convoy follows and must physically fly through the jump gate into the issuing friendly faction system — completion only on arrival.
- Delivery remains offline (category gate stays off).
- Determinism contract preserved: same seed ⇒ same postings, routes, targets (labelled Rng forks); dev/*.test.mjs suites pass.
Boundaries:
- In scope: encounter director context for faction systems, ally-fighter join mechanic, factionStanding() implementation + save persistence, job-board context filtering, patrol/bounty/escort rework per above, generic description placeholders, node tests.
- Out of scope: anything that changes faction standing over time (reputation-gain/loss rules), delivery category, faction-specific flavor text (placeholders now), NPC-vs-NPC auto-resolved combat, diplomacy/trade effects of standing.
Constraints:
- Keep modules pure where they are today (SideQuests.js, Encounters.js, Reputation.js testable in bare Node; scene owns spawning/Phaser).
- Preserve the worldgen determinism contract (seeded, labelled Rng forks; same seed ⇒ same output).
- Respect existing save compatibility patterns: new save fields load as sane defaults in old saves (existing fromJSON fallback style); in-flight quest ledger rules follow the documented Path A precedent where they conflict.
- Player's home system stays safe space — no hostiles ever placed there.
- No new art assets; use existing faction fighter rosters (EnemyTypes / DuelPicker rosters) and the existing TransportShip.
Verification contract:
- Run all dev/*.test.mjs node test suites (0 failures), including updated patrol/bounty/escort/encounters/reputation tests.
- Manual checklist via the dev boot flags where they exist: spawn into a faction system (no neutral/pirate spawns; opposing-faction hostiles; ally join on engagement), neutral system (pirates/neutrals still spawn), faction job board (only faction postings, patrol route in-system, bounty gated on gate access, escort find-then-fly-through-gate completion), neutral board unchanged, old save loads without error with neutral faction standings.
- Re-read this goal's requirements and confirm every item is addressed before marking complete.
If blocked: stop and ask the user.
## Progress
- Status: complete
- Auto-continue: on
- Sisyphus mode: no
- Time spent: 1h18m44s
- Tokens used: 20M (20,479,313) tokens
## Tasks
<!-- blockCompletion: true -->
- [x] task-1: Faction encounters + ally join — evidence: Encounters.compose gains faction context (opposing-faction roll, band-sized, seeded); encounterAllowed admits factionContext; GameScene passes _encounterContext + _spawnFactionEncounter (hostiles from
- [x] task-2: Faction standing in reputation + saves — evidence: reputation.test.mjs 80 checks pass incl. new faction-standing checks; toJSON/fromJSON carry `factions` with old-save fallback; commsTargetFor passes {id,owner} to standingFor.
- [x] task-3: Job board context filtering + generic faction descriptions — evidence: buildPosting faction branch posts patrol/bounty/escort with enemyFaction; enabledSideQuestCategories filters by contexts (delivery offline); neutral boards unchanged; {enemy} placeholders resolve to t
- [x] task-4: Patrol: in-system vs sighted opposing faction — evidence: patrolTargetSystem → issuing system always; seededOpposingFaction picks one rival (never pirate/both); derivePatrolEncounters roster = that faction's types (apex_/zenith_ verified, no raider/bomber);
- [x] task-5: Bounty: hostile-system hunt gated on jump-gate access — evidence: hostileNeighborSystem picks a gate-reachable rival system (owner = target's faction); buildPosting sets targetSystemId=ground.id + gateLevel; sideQuestAcceptable gates on gateLevel (target-driven gate
- [x] task-6: Escort: rescue + convoy through the gate into friendly space — evidence: buildPosting faction escort: enemySystemId=hostile neighbor (rendezvous), destSystemId=board's own friendly system; _establishEscort faction branch (origin=hostile, dest=friendly via escortFriendlyDes
- [x] task-7: Full verification pass — evidence: Final run: 79/79 dev/*.test.mjs suites PASS, 0 failures (5 browser suites with dev server up). Syntax OK on all 5 modified modules. Manual checklist re-verified against code: faction/neutral spawn (en

View File

@ -185,3 +185,19 @@
{"type":"audit_result","goalId":"muncyzq1-dtfe7d","verdict":"approved","report":"Data section looks solid. Now let me verify the ability implementations and wiring.\n\nAll success criteria independently verified against real artifacts:\n\n**Data (data/combat.json)** — 8 new `zenith_*` enemyTypes with frames 50–57 each exactly once, no duplicate ids (JSON keys + verified programmatically); `factions.zenith` roster lists all 8 in order. 4/4 split confirmed: frames 50–53 carry `flank_barrage`, frames 54–57 carry `shock_dart`. Each ship's weapon pattern is distinct (rapid/burst/spray/sweep vs lance/rapid/ring/shell) and every ability param set is unique (node test asserts distinctness explicitly).\n\n**Balance** — raider baseline confirmed (hull 60, damage 8); every Zenith ship: damage 16 (2×), hull 175 + shields 35 = 210 (3.5×), xp 42 = 210/5. Rationale fully documented in `_zenith_note` plus per-ship `$comment` blocks.\n\n**Combo archetype (js/combat/EnemyAbilities.js:495)** — `flank_barrage` fires seekers out of BOTH wings at `heading ± 90°` with per-wing fans, capped turn-rate (0.7–1.3 rad/s, well below the 2.2 nose-seeker cap; cap enforced in `Missile.js` `homingStep`), homing on the player via `targetRef`. Browser test confirms out-both-flanks, ≤1.3 rad/s seek, 16 dmg, homing.\n\n**Elemental archetype (EnemyAbilities.js:544 + `stepShock`)** — fast darts detonate within `proximity` of the player, blooming a zone (startR→maxR ease-out over growMs 1.4–2.4 s), finite life (2.6–4.2 s), fading alpha, DoT ticks (3–5 per 350–450 ms) on anything inside, visible as an on-scene disc + fx ring/sparks. `stepShock` is wired into `CombatSystem.update` (line 772).\n\n**Arena wiring** — `GameScene._combatBootMode` generalized via `factionIds()` (data-driven; combat.json diff is purely additive — zero removed lines), DuelPicker/CombatSystem untouched beyond comments. `?combat=zenith` boot verified.\n\n**Independent test runs (all executed by me, not trusted from the claim):**\n- `node dev/zenith.test.mjs` — ALL CHECKS PASSED (all 8 ships: volleys out both wings, darts→growing zones, DoT inside only, finite life, distinctness)\n- `node dev/zenith-arena-browser.mjs` (real headless Chromium, dev server booted by me) — 14/14 passed, zero console errors, 8 picker rows, visible ship, pack duel, and regressions: `?combat=apex` and bare `?combat` demo both still boot cleanly\n- `dev/combat.test.mjs`, `dev/factions.test.mjs`, `dev/frames.test.mjs` — all pass (including \"frames 50–57 carry art, no blank/invisible ships\" and \"pirate frames untouched\")\n\n**One noted deviation:** the objective asserted frames 50–57 were \"already in the sheet\" with sprite editing out of scope, but `assets/images/ships-enemy.png/.psd` were modified (frames evidently needed painting). This was a necessary correction to make the arena genuinely usable (invisible ships would fail \"listing all 8 fighters\"), is test-verified, and doesn't touch out-of-scope balance or UI — I treat it as justified scope recovery, not a violation.\n\nEvery item in the verification contract is satisfied with hard evidence, and the out-of-scope content (apex/mindweave/pirate balance, existing UI) is untouched.\n\n<approved/>","at":"2026-09-30T00:56:56.658Z"} {"type":"audit_result","goalId":"muncyzq1-dtfe7d","verdict":"approved","report":"Data section looks solid. Now let me verify the ability implementations and wiring.\n\nAll success criteria independently verified against real artifacts:\n\n**Data (data/combat.json)** — 8 new `zenith_*` enemyTypes with frames 50–57 each exactly once, no duplicate ids (JSON keys + verified programmatically); `factions.zenith` roster lists all 8 in order. 4/4 split confirmed: frames 50–53 carry `flank_barrage`, frames 54–57 carry `shock_dart`. Each ship's weapon pattern is distinct (rapid/burst/spray/sweep vs lance/rapid/ring/shell) and every ability param set is unique (node test asserts distinctness explicitly).\n\n**Balance** — raider baseline confirmed (hull 60, damage 8); every Zenith ship: damage 16 (2×), hull 175 + shields 35 = 210 (3.5×), xp 42 = 210/5. Rationale fully documented in `_zenith_note` plus per-ship `$comment` blocks.\n\n**Combo archetype (js/combat/EnemyAbilities.js:495)** — `flank_barrage` fires seekers out of BOTH wings at `heading ± 90°` with per-wing fans, capped turn-rate (0.7–1.3 rad/s, well below the 2.2 nose-seeker cap; cap enforced in `Missile.js` `homingStep`), homing on the player via `targetRef`. Browser test confirms out-both-flanks, ≤1.3 rad/s seek, 16 dmg, homing.\n\n**Elemental archetype (EnemyAbilities.js:544 + `stepShock`)** — fast darts detonate within `proximity` of the player, blooming a zone (startR→maxR ease-out over growMs 1.4–2.4 s), finite life (2.6–4.2 s), fading alpha, DoT ticks (3–5 per 350–450 ms) on anything inside, visible as an on-scene disc + fx ring/sparks. `stepShock` is wired into `CombatSystem.update` (line 772).\n\n**Arena wiring** — `GameScene._combatBootMode` generalized via `factionIds()` (data-driven; combat.json diff is purely additive — zero removed lines), DuelPicker/CombatSystem untouched beyond comments. `?combat=zenith` boot verified.\n\n**Independent test runs (all executed by me, not trusted from the claim):**\n- `node dev/zenith.test.mjs` — ALL CHECKS PASSED (all 8 ships: volleys out both wings, darts→growing zones, DoT inside only, finite life, distinctness)\n- `node dev/zenith-arena-browser.mjs` (real headless Chromium, dev server booted by me) — 14/14 passed, zero console errors, 8 picker rows, visible ship, pack duel, and regressions: `?combat=apex` and bare `?combat` demo both still boot cleanly\n- `dev/combat.test.mjs`, `dev/factions.test.mjs`, `dev/frames.test.mjs` — all pass (including \"frames 50–57 carry art, no blank/invisible ships\" and \"pirate frames untouched\")\n\n**One noted deviation:** the objective asserted frames 50–57 were \"already in the sheet\" with sprite editing out of scope, but `assets/images/ships-enemy.png/.psd` were modified (frames evidently needed painting). This was a necessary correction to make the arena genuinely usable (invisible ships would fail \"listing all 8 fighters\"), is test-verified, and doesn't touch out-of-scope balance or UI — I treat it as justified scope recovery, not a violation.\n\nEvery item in the verification contract is satisfied with hard evidence, and the out-of-scope content (apex/mindweave/pirate balance, existing UI) is untouched.\n\n<approved/>","at":"2026-09-30T00:56:56.658Z"}
{"type":"goal_completed","goalId":"muncyzq1-dtfe7d","archivePath":".pi/goals/archived/goal_2026092918565666_muncyzq1-dtfe7d.md","at":"2026-09-30T00:56:56.668Z"} {"type":"goal_completed","goalId":"muncyzq1-dtfe7d","archivePath":".pi/goals/archived/goal_2026092918565666_muncyzq1-dtfe7d.md","at":"2026-09-30T00:56:56.668Z"}
{"type":"goal_archived","goalId":"muncyzq1-dtfe7d","archivePath":".pi/goals/archived/goal_2026092918565666_muncyzq1-dtfe7d.md","at":"2026-09-30T00:56:56.669Z"} {"type":"goal_archived","goalId":"muncyzq1-dtfe7d","archivePath":".pi/goals/archived/goal_2026092918565666_muncyzq1-dtfe7d.md","at":"2026-09-30T00:56:56.669Z"}
{"type":"goal_created","goalId":"munfk6gc-xfi6jf","objective":"Make faction-controlled star systems play as active war zones: hostile encounters come only from the two opposing factions (never neutrals or pirates), local-faction fighters join the player in combat against them, planet reputation on faction worlds is governed by standing with the controlling faction (starting neutral), faction worlds' job boards post only faction missions, and the three live side-quest types are reworked to the faction war model — patrol (inside the issuing system vs. a sighted opposing faction), bounty (hunting a wanted opposing-faction target in a nearby hostile faction system, gated on jump-gate access to that system), and escort (rescuing a trapped friendly transport in a nearby hostile faction system and flying the convoy physically through the jump gate into friendly territory).\n\nSuccess criteria:\n- In any faction-controlled system: no neutral ships or pirate ships ever spawn as ambient encounters; only ships of the two opposing factions do, and they are hostile to the player on sight. Neutral wild space behavior (pirates/neutrals) is unchanged.\n- While the player fights opposing-faction ships inside faction A's territory, one or more faction A fighters join the fight as player allies (engage the enemy NPCs, never the player).\n- Reputation standingFor() on a faction-owned world resolves to the player's standing with that faction; a new player has neutral (0) standing with all three factions; faction standings are persisted in saves. (Mechanics that CHANGE standing are explicitly out of scope — the seam is in place.)\n- Job boards on faction worlds post only faction-context postings (patrol/bounty/escort, generic placeholder descriptions with faction labels); neutral-world boards keep posting neutral missions.\n- Patrol: route is always built inside the issuing system; the encounter enemy is one of the two opposing factions (seeded per posting); description is a generic \"reports of <faction> in-system\" placeholder.\n- Bounty: target system is a nearby opposing-faction system whose owner matches the wanted target's faction; the posting is locked/unacceptable until the player's jump-gate capability is unlocked for that system (existing gate lock mechanics); combat is the wanted target + escorts owned by that system's faction.\n- Escort: the friendly transport + escort fighters stand in a nearby hostile faction system behind the first waypoint; finding them arms the escort lane (mixed enemy-faction attacks along the way); the convoy follows and must physically fly through the jump gate into the issuing friendly faction system — completion only on arrival.\n- Delivery remains offline (category gate stays off).\n- Determinism contract preserved: same seed ⇒ same postings, routes, targets (labelled Rng forks); dev/*.test.mjs suites pass.\n\nBoundaries:\n- In scope: encounter director context for faction systems, ally-fighter join mechanic, factionStanding() implementation + save persistence, job-board context filtering, patrol/bounty/escort rework per above, generic description placeholders, node tests.\n- Out of scope: anything that changes faction standing over time (reputation-gain/loss rules), delivery category, faction-specific flavor text (placeholders now), NPC-vs-NPC auto-resolved combat, diplomacy/trade effects of standing.\n\nConstraints:\n- Keep modules pure where they are today (SideQuests.js, Encounters.js, Reputation.js testable in bare Node; scene owns spawning/Phaser).\n- Preserve the worldgen determinism contract (seeded, labelled Rng forks; same seed ⇒ same output).\n- Respect existing save compatibility patterns: new save fields load as sane defaults in old saves (existing fromJSON fallback style); in-flight quest ledger rules follow the documented Path A precedent where they conflict.\n- Player's home system stays safe space — no hostiles ever placed there.\n- No new art assets; use existing faction fighter rosters (EnemyTypes / DuelPicker rosters) and the existing TransportShip.\n\nVerification contract:\n- Run all dev/*.test.mjs node test suites (0 failures), including updated patrol/bounty/escort/encounters/reputation tests.\n- Manual checklist via the dev boot flags where they exist: spawn into a faction system (no neutral/pirate spawns; opposing-faction hostiles; ally join on engagement), neutral system (pirates/neutrals still spawn), faction job board (only faction postings, patrol route in-system, bounty gated on gate access, escort find-then-fly-through-gate completion), neutral board unchanged, old save loads without error with neutral faction standings.\n- Re-read this goal's requirements and confirm every item is addressed before marking complete.\n\nIf blocked: stop and ask the user.","sisyphus":false,"autoContinue":true,"at":"2026-09-30T01:32:17.964Z"}
{"type":"task_list_set","goalId":"munfk6gc-xfi6jf","taskCount":7,"blockCompletion":true,"at":"2026-09-30T01:32:17.964Z"}
{"type":"task_started","goalId":"munfk6gc-xfi6jf","taskId":"task-2","at":"2026-09-30T01:32:30.744Z"}
{"type":"task_complete","goalId":"munfk6gc-xfi6jf","taskId":"task-2","evidence":"reputation.test.mjs 80 checks pass incl. new faction-standing checks; toJSON/fromJSON carry `factions` with old-save fallback; commsTargetFor passes {id,owner} to standingFor.","at":"2026-09-30T01:35:02.799Z"}
{"type":"task_complete","goalId":"munfk6gc-xfi6jf","taskId":"task-1","evidence":"Encounters.compose gains faction context (opposing-faction roll, band-sized, seeded); encounterAllowed admits factionContext; GameScene passes _encounterContext + _spawnFactionEncounter (hostiles from","at":"2026-09-30T01:43:00.804Z"}
{"type":"task_started","goalId":"munfk6gc-xfi6jf","taskId":"task-3","at":"2026-09-30T01:43:28.323Z"}
{"type":"task_complete","goalId":"munfk6gc-xfi6jf","taskId":"task-3","evidence":"buildPosting faction branch posts patrol/bounty/escort with enemyFaction; enabledSideQuestCategories filters by contexts (delivery offline); neutral boards unchanged; {enemy} placeholders resolve to t","at":"2026-09-30T02:51:55.027Z"}
{"type":"task_complete","goalId":"munfk6gc-xfi6jf","taskId":"task-4","evidence":"patrolTargetSystem → issuing system always; seededOpposingFaction picks one rival (never pirate/both); derivePatrolEncounters roster = that faction's types (apex_/zenith_ verified, no raider/bomber); ","at":"2026-09-30T02:52:12.995Z"}
{"type":"task_complete","goalId":"munfk6gc-xfi6jf","taskId":"task-5","evidence":"hostileNeighborSystem picks a gate-reachable rival system (owner = target's faction); buildPosting sets targetSystemId=ground.id + gateLevel; sideQuestAcceptable gates on gateLevel (target-driven gate","at":"2026-09-30T02:52:30.658Z"}
{"type":"task_complete","goalId":"munfk6gc-xfi6jf","taskId":"task-6","evidence":"buildPosting faction escort: enemySystemId=hostile neighbor (rendezvous), destSystemId=board's own friendly system; _establishEscort faction branch (origin=hostile, dest=friendly via escortFriendlyDes","at":"2026-09-30T02:52:48.890Z"}
{"type":"task_complete","goalId":"munfk6gc-xfi6jf","taskId":"task-7","evidence":"Final run: 79/79 dev/*.test.mjs suites PASS, 0 failures (5 browser suites with dev server up). Syntax OK on all 5 modified modules. Manual checklist re-verified against code: faction/neutral spawn (en","at":"2026-09-30T02:57:43.411Z"}
{"type":"completion_requested","goalId":"munfk6gc-xfi6jf","at":"2026-09-30T02:58:09.840Z"}
{"type":"audit_started","goalId":"munfk6gc-xfi6jf","at":"2026-09-30T02:58:09.842Z"}
{"type":"audit_result","goalId":"munfk6gc-xfi6jf","verdict":"approved","report":"All the code seams are in place. Now let me verify the tests actually pass — first the directly relevant node suites, then the full run.\n\nEnvironment supports the browser suites. Let me start the dev server and run the full suite.\n\nThe scene-level evidence is strong so far. Let me directly verify the ally-join behavior headlessly (it's the one criterion not directly pinned by a suite test).\n\nAll five objective areas are implemented and verified against real artifacts. Audit summary:\n\n**1. Faction encounters + ally join (verified in code + executed headless)**\n- `Encounters.js` `compose()` takes a faction context and returns only an opposing-faction comp (never pirates/neutrals); `encounterAllowed` admits faction space only with a war context, and always excludes the home system. Neutral space gets `context: null` (GameScene `_encounterContext()` returns null for `faction: null`) so the classic roll is unchanged — pinned by `dev/encounters.test.mjs` (11 faction checks incl. \"both rivals reachable\", \"seeded reproduction\", \"neutral unchanged\").\n- `GameScene._spawnFactionEncounter` spawns hostile rivals (roster types, one-shots) plus local-faction allies via the `side: 'player'` NeutralShip seam (roster contract: never hostile to the player) with finite window. I executed this method headless: 3 apex hostiles + 2 player-side allies (ram disabled, 90 s window) — PASS.\n\n**2. Reputation + saves**\n- `Reputation.factionStanding/setFaction/changeFaction` exist; `standingFor()` resolution order home → stored → owner-faction → neutral; new player is neutral (null → 0). `toJSON`/`fromJSON` persist `factions`; a pre-faction record loads all-neutral. 80 checks in `dev/reputation.test.mjs` pass.\n\n**3. Job boards**\n- `enabledSideQuestCategories('faction')` → patrol/bounty/escort only (delivery `enabled:false` in data, gate off); faction postings carry `enemyFaction` and faction-pool copy with `{enemy}` resolving to a seeded rival (faction tables: Apex War Ministry / MindWeave Chorus / Zenith Concord); neutral boards keep neutral voice. Pinned in `dev/sidequests.test.mjs` + `dev/bounty-hunts.test.mjs`.\n\n**4. Patrol**\n- `patrolTargetSystem` always returns null (posting's own system); `seededOpposingFaction` picks exactly one of the two rivals; `patrolEnemyRoster`/`derivePatrolEncounters` build the contact roster from that faction's types (tests verify `zenith_`/`apex_` keys, no `raider`/`bomber`); neutral patrols keep the pirate mix.\n\n**5. Bounty**\n- `hostileNeighborSystem` picks a gate-reachable rival system (neutral neighbors excluded; no ground → slot empty); posting `gateLevel` = `gates.jump.factionLock.minLevel` (5) via `sideQuestAcceptable` (locked at 4, open at 5 — tested); `_establishBountyHunt` passes `faction` to `deriveBountyPlan`. I executed 200 seeded plans: 100% faction roster (elite + raiders + picket waves), neutral hunt unchanged.\n\n**6. Escort**\n- Faction branch: rendezvous in the hostile neighbor, delivery in the board's own system (`escortFriendlyDestination`), lane waves are the captors' roster, `_escortLaneAllowed` admits hostile faction space for faction escorts, convoy re-forms across the jump's scene restart, completion only on arrival + sky clear + convoy on screen. `dev/escort-flow.test.mjs` drives the full hostile→link→captor-wave→gate-fly-through→friendly-delivery→claim sequence headless and passes.\n\n**Verification contract**\n- Full suite: 78/79 passed under parallel load; the one timeout (`skills-devdoor-browser`, unrelated to this goal) passes when run alone — a parallelism/load artifact of the browser suite, not a product failure. 79/79 effectively green.\n- Constraints honored: pure modules stay Node-testable, seeded labelled Rng forks, old-save fallbacks, home system safe, no new art (existing faction rosters + TransportShip), standing-change mechanics remain out of scope (seams only).\n\n<approved/>","at":"2026-09-30T03:07:31.566Z"}
{"type":"goal_completed","goalId":"munfk6gc-xfi6jf","archivePath":".pi/goals/archived/goal_2026092921073158_munfk6gc-xfi6jf.md","at":"2026-09-30T03:07:31.585Z"}
{"type":"goal_archived","goalId":"munfk6gc-xfi6jf","archivePath":".pi/goals/archived/goal_2026092921073158_munfk6gc-xfi6jf.md","at":"2026-09-30T03:07:31.586Z"}

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,8 @@
"accent": "#ff2d6f", "accent": "#ff2d6f",
"minLevel": 2, "minLevel": 2,
"contexts": [ "contexts": [
"neutral" "neutral",
"faction"
] ]
}, },
"escort": { "escort": {
@ -36,7 +37,8 @@
"accent": "#7ce8a4", "accent": "#7ce8a4",
"minLevel": 3, "minLevel": 3,
"contexts": [ "contexts": [
"neutral" "neutral",
"faction"
] ]
} }
}, },
@ -67,7 +69,7 @@
"zenith": "Zenith Concord" "zenith": "Zenith Concord"
}, },
"patrol": { "patrol": {
"_comment": "The PATROL category's rules (the first live type — js/quests/SideQuests.js → buildPatrolRoute + patrolTargetSystem; the scene's updatePatrol/check-ins read visitRadius). ACCEPT builds the patrol's ROUTE in the posting's target system: waypoints [min,max] of plain coordinates, inside the system's UNLOCKED tether regions (the live tether field's union, the player's range there) when the target is the system the player stands in, and inside ONE tether region (a level-1 radius, tether.level1Radius) of any of the target's planets / space stations / jump gates when it is a system the player hasn't unlocked yet (a NEIGHBOR — below). Placement keeps the points ISOLATED: anchorClearance px clear of every anchor (planet/station/gate center) and minSeparation px apart, retrying seeded, relaxing the clearance and separation (never the region) if the field is tight. visitRadius = the ship is this far from a waypoint when it counts as REACHED (checked off IN ORDER — the route is a PATH flown 1, 2, 3, …; only the first unvisited waypoint logs). neighborMinD = the '40% mark diagonally from home' (the system record's d — the home→far diagonal, 0 at the home corner): in a FACTION system at d ≥ neighborMinD, a patrol may instead be assigned to a NEIGHBORING system (a jump-gate link) controlled by ANOTHER faction — the push-them-out voice made a route; neighborChance is the per-posting seeded odds of the neighbor draw (the posting's target stands stable for the run). compass + marker = the in-game face of the active patrol: the yellow next-waypoint arrow on the off-screen compass (js/ui/DiscoveryCompass's color override — the route gate's orange precedent) and the in-world waypoint beacons (unvisited = pulsing, visited = the dimmed done mark).", "_comment": "The PATROL category's rules — js/quests/SideQuests.js → buildPatrolRoute + patrolTargetSystem; the scene's updatePatrol/check-ins read visitRadius. WAR MODEL: the patrol is ALWAYS the posting's OWN system (patrolTargetSystem stands home — the old neighbor rule is gone): the board's own space, cleaned of the sighted rival's fighters. The sighted enemy is ONE seeded opposing faction (the posting's enemyFaction — js/quests/SideQuests.js's seededOpposingFaction), and the encounter roster draws from THAT faction's combat.factions roster (patrolEnemyRoster's first rule; the bands below stand for neutral boards only). ACCEPT builds the route in the posting's system: waypoints [min,max] of plain coordinates inside the system's UNLOCKED tether regions (the live tether field's union, the player's range there). Placement keeps the points ISOLATED: anchorClearance px clear of every anchor (planet/station/gate center) and minSeparation px apart, retrying seeded, relaxing the clearance and separation (never the region) if the field is tight. visitRadius = the ship is this far from a waypoint when it counts as REACHED (checked off IN ORDER — the route is a PATH flown 1, 2, 3, …; only the first unvisited waypoint logs). compass + marker = the in-game face of the active patrol: the yellow next-waypoint arrow on the off-screen compass (js/ui/DiscoveryCompass's color override — the route gate's orange precedent) and the in-world waypoint beacons (unvisited = pulsing, visited = the dimmed done mark).",
"waypoints": [ "waypoints": [
3, 3,
5 5
@ -76,15 +78,22 @@
"anchorClearance": 1100, "anchorClearance": 1100,
"minSeparation": 1500, "minSeparation": 1500,
"regionInset": 96, "regionInset": 96,
"neighborMinD": 0.4,
"neighborChance": 0.5,
"encounters": { "encounters": {
"_comment": "The patrol's ENEMY ENCOUNTERS — the contract's hostile CONTACTS standing on route waypoints (the data contract: data/sidequests.json → patrol.encounters; the logic: js/quests/SideQuests.js → derivePatrolEncounters + patrolEncounterScript; the firing: the scene's updatePatrol → _firePatrolContact — the wave starts when the ship checks in at a CONTACT's waypoint, and the cleared mark lands on the wave run's COMPLETION — the witness is the completion, not the attempt: a mid-fight quit re-arms the contact on return, the combatDone precedent — and the route's completion, with the reward's door, waits for every contact). COUNTS — the odds of how many CONTACTS a patrol carries (1 contact on 20% of patrols, 2 on 20%, 3 on 60%): [count, odds] pairs, drawn SEED-STABLE at the accept from the labelled fork (seed, 'sidequests', <targetSystemId>, 'patrol', <jobId>, 'encounters') and fixed on the route record — a signed contract is a signed contract: a later re-tune of the odds can't re-roll a standing contract's combat. WAVE — each contact's fight: ONE rolling wave of the band's roster (equal weights — a single-type band is a pure type + total), the data's shape — six hostiles, three at a time, each kill replaced — the combat.waves rolling mechanic (js/combat/Waves.js); the script is built by patrolEncounterScript (no named entry in data/combat.json — the roster comes from the band below). BANDS — the difficulty's first axis (the player's COMBAT level × the target system's HOPS from home — the jump network's tree depth, galaxy.jumpNetwork.depth — the 'systems 1–2 hops out' rule): the FIRST band in list order whose levels AND hops windows both contain the player stands, and its enemyTypes are the hostiles its contacts fly (validated against combat.enemyTypes — js/combat/EnemyTypes.js — an unknown id never ships). No band stands, or a roster empties, → fallbackEnemyTypes. TODAY the low band — levels 1–5 at 1–2 hops from home — flies the ESTABLISHED type only (raider — the base craft's named id, the 'Pirate Problems' wave's own type); the farther/higher bands — with the enemy types they introduce — append to this list when those types land, and the other difficulty axes (the distance's own scaling, the level's bonus) wire onto this same seam. POSITIONING — the contacts stand on route waypoints SPREAD evenly across the route (patrolEncounterPositions): for ≥ 2 contacts, the most evenly spread subset of the route's waypoints (scored by its MINIMUM pairwise separation — maximin — then its total: on a near-linear route that's the ends and the middle; a 3–5-point route's contacts never cluster); a single contact stands on the waypoint nearest the route's centroid (the patrol's middle). Deterministic — a pure function of the seed, the contract, and the route's geometry. XP REWARD — beside the credits, the claim pays the XP these defeated contacts are WORTH (js/quests/SideQuests.js → patrolEncounterXp: the contacts × the wave's total × the roster's type xp, data/combat.json → enemy.xp / enemyTypes): fixed on the route record at the accept (route.xp, beside route.credits — a signed contract is a signed contract) and granted into the run's XP bank on the claim (XpManager's 'sidequest' source — the kills' 'kill' grants' sibling).", "_comment": "The patrol's ENEMY ENCOUNTERS — the contract's hostile CONTACTS standing on route waypoints (the data contract: data/sidequests.json → patrol.encounters; the logic: js/quests/SideQuests.js → derivePatrolEncounters + patrolEncounterScript; the firing: the scene's updatePatrol → _firePatrolContact — the wave starts when the ship checks in at a CONTACT's waypoint, and the cleared mark lands on the wave run's COMPLETION — the witness is the completion, not the attempt: a mid-fight quit re-arms the contact on return, the combatDone precedent — and the route's completion, with the reward's door, waits for every contact). COUNTS — the odds of how many CONTACTS a patrol carries (1 contact on 20% of patrols, 2 on 20%, 3 on 60%): [count, odds] pairs, drawn SEED-STABLE at the accept from the labelled fork (seed, 'sidequests', <targetSystemId>, 'patrol', <jobId>, 'encounters') and fixed on the route record — a signed contract is a signed contract: a later re-tune of the odds can't re-roll a standing contract's combat. WAVE — each contact's fight: ONE rolling wave of the band's roster (equal weights — a single-type band is a pure type + total), the data's shape — six hostiles, three at a time, each kill replaced — the combat.waves rolling mechanic (js/combat/Waves.js); the script is built by patrolEncounterScript (no named entry in data/combat.json — the roster comes from the band below). BANDS — the difficulty's first axis (the player's COMBAT level × the target system's HOPS from home — the jump network's tree depth, galaxy.jumpNetwork.depth — the 'systems 1–2 hops out' rule): the FIRST band in list order whose levels AND hops windows both contain the player stands, and its enemyTypes are the hostiles its contacts fly (validated against combat.enemyTypes — js/combat/EnemyTypes.js — an unknown id never ships). No band stands, or a roster empties, → fallbackEnemyTypes. TODAY the low band — levels 1–5 at 1–2 hops from home — flies the ESTABLISHED type only (raider — the base craft's named id, the 'Pirate Problems' wave's own type); the farther/higher bands — with the enemy types they introduce — append to this list when those types land, and the other difficulty axes (the distance's own scaling, the level's bonus) wire onto this same seam. POSITIONING — the contacts stand on route waypoints SPREAD evenly across the route (patrolEncounterPositions): for ≥ 2 contacts, the most evenly spread subset of the route's waypoints (scored by its MINIMUM pairwise separation — maximin — then its total: on a near-linear route that's the ends and the middle; a 3–5-point route's contacts never cluster); a single contact stands on the waypoint nearest the route's centroid (the patrol's middle). Deterministic — a pure function of the seed, the contract, and the route's geometry. XP REWARD — beside the credits, the claim pays the XP these defeated contacts are WORTH (js/quests/SideQuests.js → patrolEncounterXp: the contacts × the wave's total × the roster's type xp, data/combat.json → enemy.xp / enemyTypes): fixed on the route record at the accept (route.xp, beside route.credits — a signed contract is a signed contract) and granted into the run's XP bank on the claim (XpManager's 'sidequest' source — the kills' 'kill' grants' sibling).",
"enabled": true, "enabled": true,
"counts": [ "counts": [
[1, 0.2], [
[2, 0.2], 1,
[3, 0.6] 0.2
],
[
2,
0.2
],
[
3,
0.6
]
], ],
"wave": { "wave": {
"kind": "rolling", "kind": "rolling",
@ -95,42 +104,144 @@
"bands": [ "bands": [
{ {
"_comment": "TIER 1 (levels 1–5, 1–2 hops) — the established type only. The low patrols never change: raiders, one rolling six, three at a time.", "_comment": "TIER 1 (levels 1–5, 1–2 hops) — the established type only. The low patrols never change: raiders, one rolling six, three at a time.",
"levels": [1, 5], "levels": [
"hops": [1, 2], 1,
"enemyTypes": ["raider"] 5
],
"hops": [
1,
2
],
"enemyTypes": [
"raider"
]
}, },
{ {
"_comment": "TIER 2 — THE LEVEL-6 JUMP: the wave grows 6→10 with four at a time, and the pack turns into a mix — raiders stay the anchor, spitters (spray) and bombers (lances) land. hops 1–99: past level 5 the LEVEL is the axis (any system the board can post).", "_comment": "TIER 2 — THE LEVEL-6 JUMP: the wave grows 6→10 with four at a time, and the pack turns into a mix — raiders stay the anchor, spitters (spray) and bombers (lances) land. hops 1–99: past level 5 the LEVEL is the axis (any system the board can post).",
"levels": [6, 8], "levels": [
"hops": [1, 99], 6,
"roster": { "raider": 5, "spitter": 3, "bomber": 2 }, 8
"wave": { "kind": "rolling", "total": 10, "maxConcurrent": 4, "replacePerKill": 1 }, ],
"hops": [
1,
99
],
"roster": {
"raider": 5,
"spitter": 3,
"bomber": 2
},
"wave": {
"kind": "rolling",
"total": 10,
"maxConcurrent": 4,
"replacePerKill": 1
},
"xpScale": 0.7 "xpScale": 0.7
}, },
{ {
"_comment": "TIER 3 (9–12) — the wave grows to 14 at five; stingers (bursts) and gunners (rapid fire) join the mix.", "_comment": "TIER 3 (9–12) — the wave grows to 14 at five; stingers (bursts) and gunners (rapid fire) join the mix.",
"levels": [9, 12], "levels": [
"hops": [1, 99], 9,
"roster": { "raider": 4, "spitter": 2, "stinger": 2, "gunner": 2, "bomber": 1 }, 12
"wave": { "kind": "rolling", "total": 14, "maxConcurrent": 5, "replacePerKill": 1 }, ],
"hops": [
1,
99
],
"roster": {
"raider": 4,
"spitter": 2,
"stinger": 2,
"gunner": 2,
"bomber": 1
},
"wave": {
"kind": "rolling",
"total": 14,
"maxConcurrent": 5,
"replacePerKill": 1
},
"xpScale": 0.55 "xpScale": 0.55
}, },
{ {
"_comment": "TIER 4 (13–18) — 18 at six; hunters (missiles) join. The contact-count odds skew toward three (every waypoint carrying a contact).", "_comment": "TIER 4 (13–18) — 18 at six; hunters (missiles) join. The contact-count odds skew toward three (every waypoint carrying a contact).",
"levels": [13, 18], "levels": [
"hops": [1, 99], 13,
"roster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2, "bomber": 1 }, 18
"wave": { "kind": "rolling", "total": 18, "maxConcurrent": 6, "replacePerKill": 1 }, ],
"counts": [[1, 0.1], [2, 0.3], [3, 0.6]], "hops": [
1,
99
],
"roster": {
"raider": 3,
"spitter": 2,
"stinger": 2,
"gunner": 2,
"hunter": 2,
"bomber": 1
},
"wave": {
"kind": "rolling",
"total": 18,
"maxConcurrent": 6,
"replacePerKill": 1
},
"counts": [
[
1,
0.1
],
[
2,
0.3
],
[
3,
0.6
]
],
"xpScale": 0.5 "xpScale": 0.5
}, },
{ {
"_comment": "TIER 5 (19+, the cap) — 20 at six, the seats' ceiling; barrages (ring bursts) round out the mix. Past here the count holds and the mix's weight is the pressure.", "_comment": "TIER 5 (19+, the cap) — 20 at six, the seats' ceiling; barrages (ring bursts) round out the mix. Past here the count holds and the mix's weight is the pressure.",
"levels": [19, 99], "levels": [
"hops": [1, 99], 19,
"roster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2, "barrage": 2 }, 99
"wave": { "kind": "rolling", "total": 20, "maxConcurrent": 6, "replacePerKill": 1 }, ],
"counts": [[1, 0.1], [2, 0.3], [3, 0.6]], "hops": [
1,
99
],
"roster": {
"raider": 3,
"spitter": 2,
"stinger": 2,
"gunner": 2,
"hunter": 2,
"barrage": 2
},
"wave": {
"kind": "rolling",
"total": 20,
"maxConcurrent": 6,
"replacePerKill": 1
},
"counts": [
[
1,
0.1
],
[
2,
0.3
],
[
3,
0.6
]
],
"xpScale": 0.45 "xpScale": 0.45
} }
], ],
@ -149,7 +260,7 @@
} }
}, },
"bounty": { "bounty": {
"_comment": "The BOUNTY HUNT category's rules (the second live type — the model: js/quests/SideQuests.js → deriveBountyPlan / bountyEngagements / bountyHuntXp / bountyRouteComplete / nextBountyWaypoint; the scene: GameScene._establishBountyHunt + updateBountyHunt). A hunt is a PATROL WITH A NAME ON IT: three waypoints along one pirate leader's recent travel lanes, flown IN ORDER (a patrol's waypoints too — the shared sequential law — and the chain's odds read a first, second and third leg), with the ending hidden from the player: the mark stands on ONE of the legs and the fight around them IS the contract. minLevel = the combat level the board demands before the contract can be taken (js/combat/XpManager.js's level; under it the row is LOCKED and GameScene.acceptSideQuest refuses it). contexts = the boards that post it — ['neutral'], because the 20 dossiers below are the neutral pirate-hunt voice and the faction pool is a documented empty stub (→ descriptions.faction.bounty). waypoints = the leg count [min,max]: the design is exactly three, and the chain's guarantee reads the LENGTH (the LAST leg always holds the mark), so raising the max lengthens the hunt without touching code. Route placement reuses patrol's anchorClearance / minSeparation / regionInset unless this block overrides them; visitRadius = how close the ship must come for a leg to LOG.", "_comment": "The BOUNTY HUNT category's rules (the second live type — the model: js/quests/SideQuests.js → deriveBountyPlan / bountyEngagements / bountyHuntXp / bountyRouteComplete / nextBountyWaypoint; the scene: GameScene._establishBountyHunt + updateBountyHunt). A hunt is a PATROL WITH A NAME ON IT: three waypoints along one pirate leader's recent travel lanes, flown IN ORDER (a patrol's waypoints too — the shared sequential law — and the chain's odds read a first, second and third leg), with the ending hidden from the player: the mark stands on ONE of the legs and the fight around them IS the contract. minLevel = the combat level the board demands before the contract can be taken (js/combat/XpManager.js's level; under it the row is LOCKED and GameScene.acceptSideQuest refuses it). contexts = the boards that post it — ['neutral','faction']: the neutral boards post the 20 pirate dossiers below (unchanged), and the FACTION boards post the WAR MODEL hunt (→ descriptions.faction.bounty) — the mark is a member of the TARGET system's own faction (one of the board's two rivals), the target system is a NEIGHBOR that faction controls (a jump-gate link — js/quests/SideQuests.js's hostileNeighborSystem), and the posting is LOCKED until the player's jump-gate capability is unlocked INTO that system (gates.jump.factionLock.minLevel — the posting's gateLevel, beside minLevel). The mark's frame + its pickets draw from the mark's OWN faction roster (deriveBountyPlan's opts.faction rule — the neutral pirate frames stand for neutral boards). waypoints = the leg count [min,max]: the design is exactly three, and the chain's guarantee reads the LENGTH (the LAST leg always holds the mark), so raising the max lengthens the hunt without touching code. Route placement reuses patrol's anchorClearance / minSeparation / regionInset unless this block overrides them; visitRadius = how close the ship must come for a leg to LOG.",
"minLevel": 2, "minLevel": 2,
"contexts": [ "contexts": [
"neutral" "neutral"
@ -205,26 +316,117 @@
}, },
{ {
"id": "marksman", "id": "marksman",
"levels": [6, 10], "levels": [
6,
10
],
"_comment": "TIER 2 — THE LEVEL-6 JUMP (the stub's pass, landed): the pickets grow 6→10 with four at a time and turn into a mix (spitters + bombers beside the raiders); the hunt grows 9→13 — a twelve-strong escort in the same three-at-a-time pressure with one more seat, and the mark's armor/damage scale steps 2→3. escortRoster = the hunt's escort MIX (weighted — js/quests/SideQuests.js → bountyEngagementScript draws it; a band without one is the plain raiderType count). xpScale — the tier's subtle XP dial (the fatter wave's worth, trimmed so the payout's pace stays with the curve): the tier's payout ≈ 298 XP (vs the base 372).", "_comment": "TIER 2 — THE LEVEL-6 JUMP (the stub's pass, landed): the pickets grow 6→10 with four at a time and turn into a mix (spitters + bombers beside the raiders); the hunt grows 9→13 — a twelve-strong escort in the same three-at-a-time pressure with one more seat, and the mark's armor/damage scale steps 2→3. escortRoster = the hunt's escort MIX (weighted — js/quests/SideQuests.js → bountyEngagementScript draws it; a band without one is the plain raiderType count). xpScale — the tier's subtle XP dial (the fatter wave's worth, trimmed so the payout's pace stays with the curve): the tier's payout ≈ 298 XP (vs the base 372).",
"skirmishWave": { "kind": "rolling", "total": 10, "maxConcurrent": 4, "replacePerKill": 1, "roster": { "raider": 5, "spitter": 3, "bomber": 2 } }, "skirmishWave": {
"bountyWave": { "kind": "rolling", "total": 13, "raiders": 12, "maxConcurrent": 4, "replacePerKill": 1, "escortRoster": { "raider": 5, "spitter": 2, "bomber": 2 }, "elite": { "healthScale": 3, "damageScale": 3 } }, "kind": "rolling",
"total": 10,
"maxConcurrent": 4,
"replacePerKill": 1,
"roster": {
"raider": 5,
"spitter": 3,
"bomber": 2
}
},
"bountyWave": {
"kind": "rolling",
"total": 13,
"raiders": 12,
"maxConcurrent": 4,
"replacePerKill": 1,
"escortRoster": {
"raider": 5,
"spitter": 2,
"bomber": 2
},
"elite": {
"healthScale": 3,
"damageScale": 3
}
},
"xpScale": 0.8 "xpScale": 0.8
}, },
{ {
"id": "deadeye", "id": "deadeye",
"levels": [11, 14], "levels": [
11,
14
],
"_comment": "TIER 3 (11–14) — pickets 14 at five (stingers + gunners join the mix); the hunt 17 — a sixteen-strong escort, the mark at 3.5× armor and damage.", "_comment": "TIER 3 (11–14) — pickets 14 at five (stingers + gunners join the mix); the hunt 17 — a sixteen-strong escort, the mark at 3.5× armor and damage.",
"skirmishWave": { "kind": "rolling", "total": 14, "maxConcurrent": 5, "replacePerKill": 1, "roster": { "raider": 4, "spitter": 2, "stinger": 2, "gunner": 2 } }, "skirmishWave": {
"bountyWave": { "kind": "rolling", "total": 17, "raiders": 16, "maxConcurrent": 5, "replacePerKill": 1, "escortRoster": { "raider": 4, "spitter": 2, "stinger": 2, "gunner": 2 }, "elite": { "healthScale": 3.5, "damageScale": 3.5 } }, "kind": "rolling",
"total": 14,
"maxConcurrent": 5,
"replacePerKill": 1,
"roster": {
"raider": 4,
"spitter": 2,
"stinger": 2,
"gunner": 2
}
},
"bountyWave": {
"kind": "rolling",
"total": 17,
"raiders": 16,
"maxConcurrent": 5,
"replacePerKill": 1,
"escortRoster": {
"raider": 4,
"spitter": 2,
"stinger": 2,
"gunner": 2
},
"elite": {
"healthScale": 3.5,
"damageScale": 3.5
}
},
"xpScale": 0.7 "xpScale": 0.7
}, },
{ {
"id": "apex", "id": "apex",
"levels": [15, 99], "levels": [
15,
99
],
"_comment": "TIER 4 (15+, the cap) — pickets 18 at six (hunters join); the hunt 21 — a twenty-strong escort with barrages in the mix, the mark at 4× armor and damage. Past here the seats hold and the mix's weight + the mark's scales are the pressure.", "_comment": "TIER 4 (15+, the cap) — pickets 18 at six (hunters join); the hunt 21 — a twenty-strong escort with barrages in the mix, the mark at 4× armor and damage. Past here the seats hold and the mix's weight + the mark's scales are the pressure.",
"skirmishWave": { "kind": "rolling", "total": 18, "maxConcurrent": 6, "replacePerKill": 1, "roster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2 } }, "skirmishWave": {
"bountyWave": { "kind": "rolling", "total": 21, "raiders": 20, "maxConcurrent": 6, "replacePerKill": 1, "escortRoster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2, "barrage": 2 }, "elite": { "healthScale": 4, "damageScale": 4 } }, "kind": "rolling",
"total": 18,
"maxConcurrent": 6,
"replacePerKill": 1,
"roster": {
"raider": 3,
"spitter": 2,
"stinger": 2,
"gunner": 2,
"hunter": 2
}
},
"bountyWave": {
"kind": "rolling",
"total": 21,
"raiders": 20,
"maxConcurrent": 6,
"replacePerKill": 1,
"escortRoster": {
"raider": 3,
"spitter": 2,
"stinger": 2,
"gunner": 2,
"hunter": 2,
"barrage": 2
},
"elite": {
"healthScale": 4,
"damageScale": 4
}
},
"xpScale": 0.65 "xpScale": 0.65
} }
], ],
@ -665,16 +867,16 @@
"faction": { "faction": {
"patrol": [ "patrol": [
{ {
"title": "Sweep the Lanes", "title": "Sighting Report",
"body": "A foreign surveyor has been working the outer gate for a week, and it doesn't belong here. {enemies} think this system is a neutral route — it isn't. Sweep the lane until there's nothing left on it but our own flag. Report the wrecks to the {issuer} and leave the survivors to them. That's the whole job." "body": "Recon puts a {enemy} flight moving through {system} — fighters on a working pattern, not a transit. Sweep the grid and put them down. The {issuer} is paying for clean lanes and it doesn't care who asks twice."
}, },
{ {
"title": "No Foreign Lights", "title": "Foreign Fighters",
"body": "We've logged foreign transponders on the approach lanes since the last moon. {enemies} are running their people through our space like it's a corridor they paid for. It isn't, and it won't stay that way. Show them the difference, one hull at a time, until the lanes run clean under {faction} lights again." "body": "Three of our outposts logged {enemy} transponders inside {system} this week. That's not a patrol line — it's a claim. Burn the claim out before it becomes a border. The {issuer} will credit the scrap to whoever files the wrecks."
}, },
{ {
"title": "The Gate Watch", "title": "Lane Watch",
"body": "The gate is the only honest road in this system, and foreign traffic keeps trying to use it. Keep the approach lanes free of {enemies} flags — the {issuer} is paying for the lesson, and it would rather never have to teach it. If you meet them out there, remember which side of the line you're on." "body": "The approach lanes run hot: {enemy} fighters have been working them at long range, taking nothing, watching everything. Show them which side of the lane pays. The {issuer} has a standing order that the lanes stay under our flag — this is the order, not a discussion."
} }
], ],
"delivery": [ "delivery": [
@ -691,19 +893,47 @@
"body": "The outer posts run on our rations and your nerve. {enemies} want the lane to starve them out, and they've said so in the intercepts. Run the lane anyway. Put the posts in the dark they wanted or in the light we pay for — the {issuer} is paying for the light, and only the light." "body": "The outer posts run on our rations and your nerve. {enemies} want the lane to starve them out, and they've said so in the intercepts. Run the lane anyway. Put the posts in the dark they wanted or in the light we pay for — the {issuer} is paying for the light, and only the light."
} }
], ],
"bounty": [], "bounty": [
{
"title": "WANTED — {enemy} Officer",
"issuer": "{issuer}",
"name": "The Reckoner",
"epithet": "First Officer of a {enemy} strike group",
"crime": "Raiding friendly lanes and holding their crews for ransom",
"craftLabel": "a {enemy} fighter — frame unknown, expect the works",
"body": "The {issuer} has a name for the officer who has been taking our lanes, and the name is a habit: same runs, same timing, same signature on the wrecks. Their base sits in a {enemy}-held system off our gates — the {issuer} will not send its own pilots there, so it is hiring yours. Find them, put them down, and file the hull. The {issuer} pays for the habit ending."
},
{
"title": "WANTED — {enemy} Officer",
"issuer": "{issuer}",
"name": "The Bellwether",
"epithet": "A {enemy} scout who leads other people's raids",
"crime": "Scouting the approach lanes and selling the timing",
"craftLabel": "a {enemy} fighter — fast, and it flies alone",
"body": "Every {enemy} raid we have lost this season started with a single long-dwell scout pass: one scout, long dwell, no weapons. The {issuer} wants the scout, not the wreckage of it. The scout lives in {enemy}-held space a gate-hop out — the {issuer} pays to have the question asked over their hull."
},
{
"title": "WANTED — {enemy} Officer",
"issuer": "{issuer}",
"name": "The Tollkeeper",
"epithet": "A {enemy} gunner taxing the outer lanes",
"crime": "Holding a friendly run at cannon point",
"craftLabel": "a {enemy} fighter — heavy on the guns, light on manners",
"body": "Something in a {enemy} hull has been standing on the outer lane and billing every ship that passes. Our manifest says the lane is ours, and the {issuer} says the bill is final — in their favor, against the tollkeeper. The tollkeeper's ground is a {enemy} system off our gates. Bring the ledger back. The {issuer} will settle the rest."
}
],
"escort": [ "escort": [
{ {
"title": "Convoy Under Flag", "title": "Stranded Freight",
"body": "A convoy moves through the system under {faction} colors, and {enemies} scouts have been seen in the approach lanes. Fly the point, hold the line, and keep the colors in the lead. The {issuer} pays for the convoy that arrives — not for the story of the one that didn't. The story is for the dead, and we can afford the convoy." "body": "A {faction} transport is sitting in {enemy}-held space, engines cold and its crew waiting it out. It flew in working, not hiding. Get to it, link the convoy, and fly it back through the gate into {system}. The {issuer} does not lose its freight to bad luck, and it will not let this one be the exception."
}, },
{ {
"title": "The Minister's Courier", "title": "The Convoy That Stopped",
"body": "The courier carries a seal, not cargo, and the lane is foreign country tonight. {enemies} will know the moment it launches, because they've been watching our docks since the last moon. Stay on the wing, keep the seal aboard, and put the courier through. What's in the case is the difference between a system and a battlefield — deliver it and keep the system a system." "body": "Our own freight ran a {enemy} patrol wrong and lost its escort wing; the ship is standing alone in {enemy}-held space with a full hold and a thin margin. Bring it home: link at its position, hold the lane, and put it down at {system} with its cargo intact. The {issuer} is paying for the run and for the lesson — in that order."
}, },
{ {
"title": "Long Haul, Short Fuse", "title": "Freight Under Watch",
"body": "Three lighters, one lane, and {enemies} scouts already circling the fuel line. Escort them to the gate and keep the line intact — no drops, no detours, no negotiations. The {issuer} counts hulls at the gate and pays per hull that arrives. Count them yourself too, and keep the extra for your nerve." "body": "A {faction} freighter is trapped in {enemy} territory — the {enemy} have it under watch, not in tow, which means they are waiting for it to move. Make it move: link the convoy, and fly the whole run home through our gate to {system}. Keep the flag on the hull and the lane clear of {enemy} lights. The {issuer} considers the freight its own until it is back in its own system."
} }
] ]
} }
@ -725,7 +955,7 @@
"activate": "SET PATROL ACTIVE", "activate": "SET PATROL ACTIVE",
"bountyActive": "HUNT ACTIVE", "bountyActive": "HUNT ACTIVE",
"bountyActivate": "SET HUNT ACTIVE", "bountyActivate": "SET HUNT ACTIVE",
"escortActive": "ESCORT ACTIVE", "escortActive": "CONVOY LINKED",
"escortActivate": "SET ESCORT ACTIVE", "escortActivate": "SET ESCORT ACTIVE",
"complete": "CONTRACT COMPLETE", "complete": "CONTRACT COMPLETE",
"claim": "CLAIM REWARD — {reward} CR", "claim": "CLAIM REWARD — {reward} CR",
@ -781,7 +1011,6 @@
"text": "PATROL ROUTE COMPLETE — ITS REWARD IS READY TO CLAIM" "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. escortArrivingToast = the destination reached with the convoy intact (the delivery begins). escortArrivedToast = the touchdown closed (the contract completes). escortFailToast = the transport lost — the reset's call. escortResetToast = the landing's reset (a fresh convoy stands). escortRendezvousToast = the pre-link pointing. escortJumpHoldToast = the jump's flat denial while the convoy is linked but OUT OF VIEW (transport + its surviving escorts + the gate must all be on screen before the player jumps — GameScene.jumpThroughGate's escort guard). escortDeliveringToast = the jump's flat denial while the convoy is on its touchdown (hold until it lands). escortLandingDenyToast = the destination world's door (GameScene.startLanding's escort guard) — the planet/station the convoy is being delivered to holds its landing until the transport has REACHED it (phase 'delivered') or been DESTROYED (phase 'failed' — the landing's reset remedy). Every escort denial (jump hold + landing door) also arms OAC's 'escortDeny' visual comm (data/comms.json → oac-escort-wait — assets/speech/oac/escort-wait.mp3, the denial's voice).", "_commentEscort": "The ESCORT contract's face (js/ui/JobBoardWindow.js + GameScene.updateEscort). escortActive = the row's live badge once the convoy links (the patrol's 'active' slot, re-labelled). escortRouteProgress = the row's progress line: the rendezvous leg, then the lane. escortLinkToast = the convoy forming on the player's wing. escortArrivingToast = the destination reached with the convoy intact (the delivery begins). escortArrivedToast = the touchdown closed (the contract completes). escortFailToast = the transport lost — the reset's call. escortResetToast = the landing's reset (a fresh convoy stands). escortRendezvousToast = the pre-link pointing. escortJumpHoldToast = the jump's flat denial while the convoy is linked but OUT OF VIEW (transport + its surviving escorts + the gate must all be on screen before the player jumps — GameScene.jumpThroughGate's escort guard). escortDeliveringToast = the jump's flat denial while the convoy is on its touchdown (hold until it lands). escortLandingDenyToast = the destination world's door (GameScene.startLanding's escort guard) — the planet/station the convoy is being delivered to holds its landing until the transport has REACHED it (phase 'delivered') or been DESTROYED (phase 'failed' — the landing's reset remedy). Every escort denial (jump hold + landing door) also arms OAC's 'escortDeny' visual comm (data/comms.json → oac-escort-wait — assets/speech/oac/escort-wait.mp3, the denial's voice).",
"escortActive": "CONVOY LINKED",
"escortRouteProgress": "LANE — {visited}/{total} SECTORS CROSSED", "escortRouteProgress": "LANE — {visited}/{total} SECTORS CROSSED",
"escortRendezvousToast": { "escortRendezvousToast": {
"glyph": "◈", "glyph": "◈",
@ -865,6 +1094,20 @@
"glyph": "✓", "glyph": "✓",
"color": "#67e863", "color": "#67e863",
"text": "BOUNTY HUNT COMPLETE — {name} IS DOWN, ITS REWARD IS READY TO CLAIM" "text": "BOUNTY HUNT COMPLETE — {name} IS DOWN, ITS REWARD IS READY TO CLAIM"
},
"bountyGateLockBadge": "LOCKED — GATE ACCESS LEVEL {level}",
"bountyGateLockSub": "THE TARGET SYSTEM BEHIND THE GATE OPENS AT COMBAT LEVEL {level}",
"bountyGateDenyToast": {
"glyph": "✕",
"color": "#ff2d6f",
"text": "GATE LOCKED — THE TARGET SYSTEM OPENS AT COMBAT LEVEL {level}"
},
"escortGateLockBadge": "LOCKED — GATE ACCESS LEVEL {level}",
"escortGateLockSub": "THE TRANSPORT'S GROUND BEHIND THE GATE OPENS AT COMBAT LEVEL {level}",
"escortGateDenyToast": {
"glyph": "✕",
"color": "#ff2d6f",
"text": "GATE LOCKED — THE TRANSPORT'S GROUND OPENS AT COMBAT LEVEL {level}"
} }
} }
} }

View File

@ -80,17 +80,17 @@ const check = (label, cond, extra = '') => {
const section = (t) => console.log(`\n== ${t} ==`); const section = (t) => console.log(`\n== ${t} ==`);
// ========================================================================== // ==========================================================================
section('THE CATEGORY — BOUNTY HUNT is live, level 2+, Neutral boards only'); section('THE CATEGORY — BOUNTY HUNT is live, level 2+, Neutral + Faction boards (the war model)');
{ {
const meta = S.sideQuestCategoryMeta('bounty'); const meta = S.sideQuestCategoryMeta('bounty');
check('the category is enabled with its own label + accent', meta?.enabled === true && meta.label === 'BOUNTY HUNT' && /^#/.test(meta.accent)); check('the category is enabled with its own label + accent', meta?.enabled === true && meta.label === 'BOUNTY HUNT' && /^#/.test(meta.accent));
check('the gate is combat level 2', meta?.minLevel === 2 && S.bountyMinLevel() === 2, `minLevel=${meta?.minLevel}`); check('the gate is combat level 2', meta?.minLevel === 2 && S.bountyMinLevel() === 2, `minLevel=${meta?.minLevel}`);
check('it posts in Neutral systems only', JSON.stringify(meta?.contexts) === JSON.stringify(['neutral'])); check('it posts on Neutral + FACTION boards (the war model)', JSON.stringify(meta?.contexts) === JSON.stringify(['neutral','faction']));
const neutral = S.enabledSideQuestCategories('neutral'); const neutral = S.enabledSideQuestCategories('neutral');
const faction = S.enabledSideQuestCategories('faction'); const faction = S.enabledSideQuestCategories('faction');
check('a Neutral board draws it', neutral.includes('bounty'), neutral.join(',')); check('a Neutral board draws it', neutral.includes('bounty'), neutral.join(','));
check('a FACTION board never does', !faction.includes('bounty'), faction.join(',')); check('a FACTION board draws it (the war model)', faction.includes('bounty'), faction.join(','));
check("the faction wanted-list stays the documented empty stub", JSON.stringify(SQ.descriptions.faction.bounty) === '[]'); check('the faction wanted-list is the generic war-voice (3+ dossiers, {enemy} rivals)', Array.isArray(SQ.descriptions.faction.bounty) && SQ.descriptions.faction.bounty.length >= 3 && SQ.descriptions.faction.bounty.every((v) => v.name && v.body && v.body.includes('{enemy}')), JSON.stringify(SQ.descriptions.faction.bounty).slice(0, 120));
check('the hunt carries no contact-red beacon color (patrol law, deliberately)', SQ.bounty?.marker?.contactColor === undefined); check('the hunt carries no contact-red beacon color (patrol law, deliberately)', SQ.bounty?.marker?.contactColor === undefined);
const bandIds = SQ.bounty.encounters.bands.map((b) => b.id); const bandIds = SQ.bounty.encounters.bands.map((b) => b.id);
check('the bands stand: hunter (2–5) + the tuned marksman (6–10), deadeye (11–14), apex (15+)', JSON.stringify(bandIds) === JSON.stringify(['hunter', 'marksman', 'deadeye', 'apex']) && SQ.bounty.encounters.fallbackBand === 'hunter'); check('the bands stand: hunter (2–5) + the tuned marksman (6–10), deadeye (11–14), apex (15+)', JSON.stringify(bandIds) === JSON.stringify(['hunter', 'marksman', 'deadeye', 'apex']) && SQ.bounty.encounters.fallbackBand === 'hunter');
@ -166,15 +166,49 @@ section('THE BOARD — rows on Neutral boards, LOCKED below level 2');
} }
check('the hunt flies its OWN system (no DESTINATION line on the board)', huntHigh.every((p) => p.targetSystemId === p.systemId && p.targetSystemName === p.systemName)); check('the hunt flies its OWN system (no DESTINATION line on the board)', huntHigh.every((p) => p.targetSystemId === p.systemId && p.targetSystemName === p.systemName));
// The faction boards. // The faction boards — THE WAR MODEL: a faction board posts a hunt only
// when a rival holds a gate-reachable NEIGHBOR (the mark's home); with no
// galaxy (no neighbor data) those slots stand EMPTY.
for (const f of ['apex', 'mindweave', 'zenith']) { for (const f of ['apex', 'mindweave', 'zenith']) {
const rec = { id: `sys-${f}`, name: `${f} Reach`, faction: f, d: 30, zone: 'mid', type: 'void', capital: false }; const rec = { id: `sys-${f}`, name: `${f} Reach`, faction: f, d: 30, zone: 'mid', type: 'void', capital: false };
for (const lvl of [1, 2, 5]) { for (const lvl of [1, 2, 5]) {
const posts = S.sideQuestPostingsFor(99, rec, 'p1', null, lvl); const posts = S.sideQuestPostingsFor(99, rec, 'p1', null, lvl);
check(`${f} boards never post a hunt (level ${lvl})`, posts.every((p) => p.category !== 'bounty'), posts.map((p) => p.category).join(',')); check(`${f} boards post no hunt without hostile ground (level ${lvl})`, posts.every((p) => p.category !== 'bounty'), posts.map((p) => p.category).join(','));
}
// With a rival's gate-reachable neighbor (hostile ground), the board posts
// the hunt: the mark's home is that rival's system, its faction's frame.
const rf = f === 'apex' ? 'zenith' : 'apex'; // ONE of the two rivals
const rival = { id: `sys-rival-${f}`, name: `Rival ${f}`, faction: rf, d: 30, zone: 'mid', type: 'void', capital: false };
const g = { byId: new Map([[rec.id, rec], [rival.id, rival]]), jumpGatesFor: () => [rival] };
// The bounty occupies a SEEDED slot (the order is a seeded shuffle), so
// pool a few worlds to stand it (any of them is the same contract shape).
let fb = null;
for (const wk of ['p1', 'p2', 'p3', 'p4', 'p5']) {
fb = S.sideQuestPostingsFor(99, rec, wk, g, 5).find((p) => p.category === 'bounty') ?? null;
if (fb) break;
}
check(`${f} board posts a hunt with hostile ground (level 5)`, !!fb, fposts_dbg(f, g).join(','));
if (fb) {
check(`${f} board's hunt targets the rival's system`, fb.targetSystemId === rival.id, `target=${fb.targetSystemId}`);
check(`${f} board's hunt marks a member of that rival`, fb.enemyFaction === rival.faction, `enemy=${fb.enemyFaction}`);
// THE GATE LOCK — the war-model hunt is LOCKED until the player's
// jump-gate capability opens the hop into that hostile faction system
// (the existing factionLock's minLevel — the posting's gateLevel).
const gl = S.gateAccessLevel(rec, rival);
check(`${f} board's hunt carries a GATE lock (the target is faction-held)`, typeof fb.gateLevel === 'number' && fb.gateLevel >= gl && gl >= 1, `gateLevel=${fb.gateLevel} rule=${gl}`);
const below = S.sideQuestAcceptable(fb, gl - 1);
const at = S.sideQuestAcceptable(fb, gl);
check(`${f} board's hunt is LOCKED below the gate level`, below.locked === true, `lvl=${gl - 1} locked=${below.locked}`);
check(`${f} board's hunt OPENS at the gate level`, at.ok === true, `lvl=${gl} ok=${at.ok}`);
} }
} }
} }
function fposts_dbg(f, g) {
const rec = { id: `sys-${f}`, name: `${f} Reach`, faction: f, d: 30, zone: 'mid', type: 'void', capital: false };
const out = [];
for (const wk of ['p1', 'p2', 'p3', 'p4', 'p5']) out.push(...S.sideQuestPostingsFor(99, rec, wk, g, 5).map((p) => p.category));
return out;
}
// ========================================================================== // ==========================================================================
section('THE CHAIN — seeded distribution reads 20% / 16% / 64%'); section('THE CHAIN — seeded distribution reads 20% / 16% / 64%');

View File

@ -129,7 +129,9 @@ const fixed = {
const ok = { ship: { x: 0, y: 0 }, isHomeSystem: false, faction: null, waveRunLive: false, playerDown: false }; const ok = { ship: { x: 0, y: 0 }, isHomeSystem: false, faction: null, waveRunLive: false, playerDown: false };
check('gate: neutral wild space, flying, no run, upright — ALLOWED', encounterAllowed(ok) === true); check('gate: neutral wild space, flying, no run, upright — ALLOWED', encounterAllowed(ok) === true);
check('gate: the HOME system is safe space (never)', encounterAllowed({ ...ok, isHomeSystem: true }) === false); check('gate: the HOME system is safe space (never)', encounterAllowed({ ...ok, isHomeSystem: true }) === false);
check('gate: a FACTION system is off-limits (neutral only)', encounterAllowed({ ...ok, faction: 'apex' }) === false); check('gate: a FACTION system without a war context is off-limits (the roster is unknown)', encounterAllowed({ ...ok, faction: 'apex', factionContext: null }) === false);
check('gate: a FACTION system WITH a war context is allowed (the war-zones)', encounterAllowed({ ...ok, faction: 'apex', factionContext: { mode: 'faction', faction: 'apex', enemies: [{ id: 'mindweave', types: ['x'] }] } }) === true);
check('gate: the HOME system is safe even in the war context', encounterAllowed({ ...ok, isHomeSystem: true, faction: 'apex', factionContext: { mode: 'faction', faction: 'apex', enemies: [{ id: 'mindweave', types: ['x'] }] } }) === false);
check('gate: a wave run IN FLIGHT holds the rolls', encounterAllowed({ ...ok, waveRunLive: true }) === false); check('gate: a wave run IN FLIGHT holds the rolls', encounterAllowed({ ...ok, waveRunLive: true }) === false);
check('gate: a run that is already DONE holds no roll (its ships are spent)', encounterAllowed({ ...ok, waveRunLive: false }) === true); check('gate: a run that is already DONE holds no roll (its ships are spent)', encounterAllowed({ ...ok, waveRunLive: false }) === true);
check('gate: no spawn on a downed ship', encounterAllowed({ ...ok, playerDown: true }) === false); check('gate: no spawn on a downed ship', encounterAllowed({ ...ok, playerDown: true }) === false);
@ -303,6 +305,47 @@ const fixed = {
check('size: a config without bands falls back to 1 ship', bare.kind === 'pirates' && bare.pirates === 1); check('size: a config without bands falls back to 1 ship', bare.kind === 'pirates' && bare.pirates === 1);
} }
// ------------------------------------------------------------- faction space
{
const e = cfg();
const ctx = (faction) => ({
mode: 'faction',
faction,
enemies: [
{ id: 'mindweave', types: ['mw_a', 'mw_b'] },
{ id: 'zenith', types: ['zn_a'] },
].filter((x) => x.id !== faction),
});
const c1 = compose(e, 1, () => 0, ctx('apex'));
check('faction: the roll is ALWAYS a faction kind (never pirates/neutrals)', c1.kind === 'faction' && c1.neutrals === 0);
check('faction: the enemy is an OPPOSING faction (never the controller)', c1.faction !== 'apex' && ['mindweave', 'zenith'].includes(c1.faction));
check('faction: the types come from the ENEMY faction\u2019s roster', c1.types.every((t) => (c1.faction === 'mindweave' ? ['mw_a', 'mw_b'] : ['zn_a']).includes(t)));
check('faction: the count sits in the level band (level 1: 1\u20132)', compose(e, 1, () => 0, ctx('apex')).count === 1 && compose(e, 1, () => 0.999, ctx('apex')).count === 2);
check('faction: level 8 sizes off the band\u2019s pirate count (5\u20137)', compose(e, 8, () => 0, ctx('apex')).count === 5 && compose(e, 8, () => 0.999, ctx('apex')).count === 7);
// Both rivals are reachable (the roll walks the enemy list).
const seen = new Set(Array.from({ length: 50 }, (_, i) => compose(e, 1, () => i / 50, ctx('apex')).faction));
check('faction: BOTH opposing factions are reachable', seen.has('mindweave') && seen.has('zenith'), [...seen].join(','));
// Determinism: the same seed walks the same enemy + the same types.
const d1 = compose(e, 3, () => 0.37, ctx('zenith'));
const d2 = compose(e, 3, () => 0.37, ctx('zenith'));
check('faction: a fixed seed reproduces the roll (enemy + types + count)', d1.faction === d2.faction && d1.count === d2.count && JSON.stringify(d1.types) === JSON.stringify(d2.types));
// A rival without a roster is skipped (never deployed).
const ctxNoRoster = { mode: 'faction', faction: 'apex', enemies: [{ id: 'mindweave', types: [] }, { id: 'zenith', types: ['zn_a'] }] };
const c3 = compose(e, 1, () => 0, ctxNoRoster);
check('faction: a roster-less rival is skipped (the rostered one stands)', c3.faction === 'zenith');
// No enemies at all (a roster-less world) degrades to the neutral roll.
const c4 = compose(e, 1, () => 0, { mode: 'faction', faction: 'apex', enemies: [] });
check('faction: no enemies \u2192 the neutral roll stands (defensive)', c4.kind !== 'faction');
// The director composes the faction roll when the context rides the ctx.
const dd = new EncounterDirector(fixed, { rng: () => 0 });
dd.update(0, { allowed: true, live: false, xpLevel: 1, context: ctx('apex') });
const s = dd.update(50000, { allowed: true, live: false, xpLevel: 1, context: ctx('apex') });
check('faction (director): the hit deploys a faction enemy (not pirates/neutrals)', !!s && s.kind === 'faction' && s.faction !== 'apex', JSON.stringify(s));
// Neutral space is UNCHANGED: no context \u2192 the classic roll.
const n1 = compose(e, 1, () => 0);
check('neutral: without a context the classic roll stands (unchanged)', n1.kind !== 'faction');
}
// --------------------------------------------------------------------- done // --------------------------------------------------------------------- done
if (failures) { if (failures) {
console.log(`\n✘ ${failures} ENCOUNTER-DIRECTOR CHECK(S) FAILED`); console.log(`\n✘ ${failures} ENCOUNTER-DIRECTOR CHECK(S) FAILED`);

View File

@ -1,20 +1,20 @@
// dev/escort-flow.test.mjs — the ESCORT contract's SCENE flow, verified // dev/escort-flow.test.mjs — the ESCORT contract’s SCENE flow, verified
// headless (node dev/escort-flow.test.mjs — no browser, no Phaser render). // headless (node dev/escort-flow.test.mjs — no browser, no Phaser render).
// //
// The model (gate / destination / wave / ledger) is pinned in // The model (gate / destination / wave / ledger) is pinned in
// dev/escort.test.mjs. This pins the SCENE's frame law — the // dev/escort.test.mjs. This pins the SCENE’s frame law — the
// Object.create(GameScene.prototype) seam (the delivery-launch / bounty // Object.create(GameScene.prototype) seam (the delivery-launch / bounty
// precedent): the escort's methods are driven with a mocked combat // precedent): the escort’s methods are driven with a mocked combat
// system, ship, and lane director, so the CONTRACT's sequence stands: // system, ship, and lane director, so the CONTRACT’s sequence stands:
// //
// THE RUN — accept → a RENDEZVOUS waypoint stands in the current // THE RUN — accept → a RENDEZVOUS waypoint stands in the current
// system; the player reaches it → the convoy FORMS (the transport + // system; the player reaches it → the convoy FORMS (the transport +
// 1–2 escort fighters, persistent) and the lane goes hot; a wave // 1–2 escort fighters, persistent) and the lane goes hot; a wave
// fires (mixed fighters, the transport's own guns live); the convoy // fires (mixed fighters, the transport’s own guns live); the convoy
// RE-FORMS across a jump's scene restart; the destination world ON // RE-FORMS across a jump’s scene restart; the destination world ON
// SCREEN with the sky clear → the convoy breaks off, lands beside the // SCREEN with the sky clear → the convoy breaks off, lands beside the
// world and fades out (the touchdown), and the contract DELIVERS // world and fades out (the touchdown), and the contract DELIVERS
// (complete + the claim's payout). // (complete + the claim’s payout).
// //
// THE FAILURE — the transport is LOST → the contract FAILS (the convoy // THE FAILURE — the transport is LOST → the contract FAILS (the convoy
// breaks off cleanly); the player LANDS → the contract RESETS (a fresh // breaks off cleanly); the player LANDS → the contract RESETS (a fresh
@ -22,8 +22,8 @@
// the SAME destination — the retry starts where they are). // the SAME destination — the retry starts where they are).
// //
// THE BOARD — all TWENTY escort dossiers stand: unique titles/bodies, // THE BOARD — all TWENTY escort dossiers stand: unique titles/bodies,
// and every one of them reachable on a neutral board's postings // and every one of them reachable on a neutral board’s postings
// (the row's title + issuer + description all render). // (the row’s title + issuer + description all render).
process.env.NODE_ENV = 'dev'; process.env.NODE_ENV = 'dev';
@ -56,7 +56,7 @@ const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/G
const SEED = 77031; const SEED = 77031;
const JOB = 'sq-sys-home-p1-escort-0'; const JOB = 'sq-sys-home-p1-escort-0';
// A gate graph: sys-home (neutral, the posting's system) — sys-a (neutral) // 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 (neutral); sys-zenith (faction) is excluded. sys-a holds a planet,
// sys-b a free-space station. // sys-b a free-space station.
const RECS = { const RECS = {
@ -88,12 +88,12 @@ const galaxy = {
// ------------------------------------------------------------------ mocks // ------------------------------------------------------------------ mocks
/** A transport/ally craft the fake combat spawns (the scene's contract). */ /** A transport/ally craft the fake combat spawns (the scene’s contract). */
function makeCraft(kind) { function makeCraft(kind) {
const c = { const c = {
kind, kind,
x: 0, y: 0, x: 0, y: 0,
// Render face (the delivery's landing glide reads these — the real // Render face (the delivery’s landing glide reads these — the real
// craft is a Phaser sprite; the fake carries the same surface). // craft is a Phaser sprite; the fake carries the same surface).
scale: 1, alpha: 1, rotation: 0, artOffset: 0, scale: 1, alpha: 1, rotation: 0, artOffset: 0,
body: { setVelocity(x, y) { this.vx = x; this.vy = y; } }, body: { setVelocity(x, y) { this.vx = x; this.vy = y; } },
@ -102,9 +102,9 @@ function makeCraft(kind) {
hull: kind === 'transport' ? 1500 : 60, hull: kind === 'transport' ? 1500 : 60,
maxShields: kind === 'transport' ? 650 : 0, maxShields: kind === 'transport' ? 650 : 0,
shields: kind === 'transport' ? 650 : 0, shields: kind === 'transport' ? 650 : 0,
// The craft's OWN top speed (the real TransportShip/NeutralShip read their // The craft’s OWN top speed (the real TransportShip/NeutralShip read their
// data into this; the speed-match law reads it off the craft). The fighters // data into this; the speed-match law reads it off the craft). The fighters
// carry their roster speed (180) — distinct from the transport's 245, so // carry their roster speed (180) — distinct from the transport’s 245, so
// the match and the restore are both observable. // the match and the restore are both observable.
speed: kind === 'transport' ? 245 : 180, speed: kind === 'transport' ? 245 : 180,
dead: false, dead: false,
@ -117,7 +117,7 @@ function makeCraft(kind) {
return c; return c;
} }
/** The fake COMBAT SYSTEM the escort's scene law reads (the seam's edge). */ /** The fake COMBAT SYSTEM the escort’s scene law reads (the seam’s edge). */
function makeCombat() { function makeCombat() {
const c = { const c = {
allies: [], allies: [],
@ -132,19 +132,20 @@ function makeCombat() {
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; }, 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 } = {}) { startWaves(script, { onComplete } = {}) {
c.waveRun = { state: 'live' }; c.waveRun = { state: 'live' };
c._lastScript = script; // the test reads the wave’s roster (the lane’s law)
const total = script?.waves?.[0]?.total ?? 3; const total = script?.waves?.[0]?.total ?? 3;
for (let i = 0; i < total; i++) c.enemies.push({ dead: false }); for (let i = 0; i < total; i++) c.enemies.push({ dead: false });
c.active = true; c.active = true;
if (typeof onComplete === 'function') c._onWaveComplete = onComplete; if (typeof onComplete === 'function') c._onWaveComplete = onComplete;
}, },
// Test driver: the wave's last pirate goes down (the run is done). // Test driver: the wave’s last pirate goes down (the run is done).
_clearWave() { _clearWave() {
for (const e of c.enemies) e.dead = true; for (const e of c.enemies) e.dead = true;
c.waveRun = { state: 'done' }; c.waveRun = { state: 'done' };
c.active = false; c.active = false;
if (typeof c._onWaveComplete === 'function') { c._onWaveComplete(); c._onWaveComplete = null; } if (typeof c._onWaveComplete === 'function') { c._onWaveComplete(); c._onWaveComplete = null; }
}, },
// Test driver: a JUMP's scene restart tears the scene-local sprites. // Test driver: a JUMP’s scene restart tears the scene-local sprites.
_teardownConvoy() { _teardownConvoy() {
for (const a of c.allies) a.destroy(); for (const a of c.allies) a.destroy();
c.allies.length = 0; c.allies.length = 0;
@ -162,7 +163,7 @@ function makeDirector() {
}; };
} }
/** A GAME SCENE with the escort's dependencies mocked (the seam's host). */ /** A GAME SCENE with the escort’s dependencies mocked (the seam’s host). */
function makeScene(systemId) { function makeScene(systemId) {
const rec = RECS[systemId]; const rec = RECS[systemId];
const scene = Object.create(GameScene.prototype); const scene = Object.create(GameScene.prototype);
@ -182,7 +183,7 @@ function makeScene(systemId) {
scene.isHomeSystem = false; scene.isHomeSystem = false;
scene.patrolG = { clear() {}, lineStyle() {}, circle() {}, strokeCircle() {}, fillStyle() {}, fillCircle() {} }; scene.patrolG = { clear() {}, lineStyle() {}, circle() {}, strokeCircle() {}, fillStyle() {}, fillCircle() {} };
scene.ship = { x: 0, y: 0, addCredits() {} }; scene.ship = { x: 0, y: 0, addCredits() {} };
// The camera's world rect (the delivery's witness — the destination world // The camera’s world rect (the delivery’s witness — the destination world
// being on screen). Defaults center on the origin; the delivery test // being on screen). Defaults center on the origin; the delivery test
// re-centers it on the ship before the delivery frame. // re-centers it on the ship before the delivery frame.
scene.scale = { width: 1280, height: 720 }; scene.scale = { width: 1280, height: 720 };
@ -215,7 +216,7 @@ console.log('== THE RUN — accept → rendezvous → link → wave → jump →
// ACCEPT — the scene takes the contract and stands the rendezvous. // ACCEPT — the scene takes the contract and stands the rendezvous.
st.accept(JOB); st.accept(JOB);
st.setActive(JOB); // the ACTIVE slot (the contract in flight — the board's ACTIVATE door) st.setActive(JOB); // the ACTIVE slot (the contract in flight — the board’s ACTIVATE door)
scene._establishEscort(JOB, posting); scene._establishEscort(JOB, posting);
const route0 = st.routeFor(JOB); const route0 = st.routeFor(JOB);
assert.equal(route0.kind, 'escort', 'the route is typed escort'); assert.equal(route0.kind, 'escort', 'the route is typed escort');
@ -228,7 +229,7 @@ console.log('== THE RUN — accept → rendezvous → link → wave → jump →
assert.ok(route0.escort.escortCount >= 1 && route0.escort.escortCount <= 2, '1–2 escort fighters'); 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'); 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. // RENDEZVOUS LINK — the ship reaches the convoy’s standing point.
const wp = route0.waypoints[0]; const wp = route0.waypoints[0];
scene.ship.x = wp[0]; scene.ship.y = wp[1]; scene.ship.x = wp[0]; scene.ship.y = wp[1];
scene.escortDirector.nextComp = null; // no contact while standing by scene.escortDirector.nextComp = null; // no contact while standing by
@ -240,14 +241,14 @@ console.log('== THE RUN — accept → rendezvous → link → wave → jump →
assert.ok(scene._escortTransport.persistent, 'the convoy is persistent (it never holds the jump gate)'); 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'); 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). // 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.escortDirector.nextComp = { kind: 'pirates', pirates: 3, neutrals: 0 }; // the director’s hit
scene.updateEscort(); scene.updateEscort();
assert.equal(scene.combat.waveRun?.state, 'live', 'a LANE CONTACT fires the escort wave'); 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'); 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). // 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'); assert.ok(scene._escortTransport && !scene._escortTransport.dead, 'the transport holds its wing through the fight');
// The wave's last pirate goes down. // The wave’s last pirate goes down.
scene.combat._clearWave(); scene.combat._clearWave();
assert.equal(scene.combat.waveRun.state, 'done', 'the wave is spent'); 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)'); assert.equal(scene.combat.active, false, '…the sky is clear (the convoy is persistent — it does not hold active)');
@ -276,7 +277,7 @@ console.log('== THE RUN — accept → rendezvous → link → wave → jump →
scene.combat.active = false; scene.combat.active = false;
scene.combat.waveRun = { state: 'done' }; scene.combat.waveRun = { state: 'done' };
scene.escortDirector.nextComp = null; scene.escortDirector.nextComp = null;
// Bring the destination world into the camera's world rect (the ship is // Bring the destination world into the camera’s world rect (the ship is
// on it — centre the view on the ship). // on it — centre the view on the ship).
scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2; scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2;
scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2; scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2;
@ -288,9 +289,9 @@ console.log('== THE RUN — accept → rendezvous → link → wave → jump →
// THE TOUCHDOWN — the convoy glides in beside the world and fades out // THE TOUCHDOWN — the convoy glides in beside the world and fades out
// (a fixed approach + fade). The first landing frame stamps the start; // (a fixed approach + fade). The first landing frame stamps the start;
// a later frame is mid-fade (alpha dropping); on the fade's close the // a later frame is mid-fade (alpha dropping); on the fade’s close the
// contract seals (complete + the claim's payout). // contract seals (complete + the claim’s payout).
scene.time.now = 1000; // the delivery's first landing frame scene.time.now = 1000; // the delivery’s first landing frame
scene.updateEscort(); scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', '…mid-touchdown, still landing'); assert.equal(st.routeFor(JOB).escort.phase, 'delivering', '…mid-touchdown, still landing');
scene.time.now = 2600; // approach settled + the fade is running scene.time.now = 2600; // approach settled + the fade is running
@ -325,11 +326,11 @@ console.log('== THE FAILURE — transport lost → land → reset (the retry) ==
scene.escortDirector.nextComp = null; scene.escortDirector.nextComp = null;
scene.updateEscort(); scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'linked'); assert.equal(st.routeFor(JOB).escort.phase, 'transit', 'linked');
assert.ok(scene._fired.includes('escortLink'), "the TRANSPORT CAPTAIN's link greeting arms (the convoy stands at the first waypoint)"); assert.ok(scene._fired.includes('escortLink'), "the TRANSPORT CAPTAIN’s link greeting arms (the convoy stands at the first waypoint)");
const transport = scene._escortTransport; const transport = scene._escortTransport;
assert.ok(transport, 'the transport is flying'); assert.ok(transport, 'the transport is flying');
// THE TRANSPORT IS LOST (its hull goes to zero — CombatSystem's kill). // THE TRANSPORT IS LOST (its hull goes to zero — CombatSystem’s kill).
transport.hull = 0; transport.hull = 0;
transport.dead = true; transport.dead = true;
scene.escortDirector.nextComp = null; scene.escortDirector.nextComp = null;
@ -340,7 +341,7 @@ console.log('== THE FAILURE — transport lost → land → reset (the retry) ==
assert.ok(scene.combat.allies.every((a) => a.destroyed), '…cleanly (every convoy craft stands down)'); assert.ok(scene.combat.allies.every((a) => a.destroyed), '…cleanly (every convoy craft stands down)');
assert.ok(!scene._fired.includes('escortDelivered'), '…and the lost convoy never arms the delivery thanks (no thanks for a lost run)'); assert.ok(!scene._fired.includes('escortDelivered'), '…and the lost convoy never arms the delivery thanks (no thanks for a lost run)');
// THE REMEDY — the player LANDS (startLanding's hook → _escortResetForLanding). // THE REMEDY — the player LANDS (startLanding’s hook → _escortResetForLanding).
scene._escortResetForLanding(); scene._escortResetForLanding();
const routeReset = st.routeFor(JOB); const routeReset = st.routeFor(JOB);
assert.equal(routeReset.escort.phase, 'rendezvous', 'the landing RESETS the contract (a fresh rendezvous)'); assert.equal(routeReset.escort.phase, 'rendezvous', 'the landing RESETS the contract (a fresh rendezvous)');
@ -400,8 +401,8 @@ console.log('== THE WITNESS GATES — the convoy + gate on screen before the jum
assert.equal(scene._escortJumpHold(GATE), null, 'the LOST escort is out of the witness (only the survivors stand)'); assert.equal(scene._escortJumpHold(GATE), null, 'the LOST escort is out of the witness (only the survivors stand)');
crew[0].dead = false; crew[0].dead = false;
// THE DELIVERY GATE — a jump's scene restart (tear the sprites, the // THE DELIVERY GATE — a jump’s scene restart (tear the sprites, the
// convoy re-forms on the wing at the ship's new position), then the // convoy re-forms on the wing at the ship’s new position), then the
// destination world in view + the sky clear, but the convoy OFF SCREEN: // destination world in view + the sky clear, but the convoy OFF SCREEN:
// the delivery HOLDS (it witnesses its own landing). // the delivery HOLDS (it witnesses its own landing).
scene.combat._teardownConvoy(); scene.combat._teardownConvoy();
@ -424,11 +425,11 @@ console.log('== THE WITNESS GATES — the convoy + gate on screen before the jum
for (const a of scene._escortConvoyCrafts()) { a.x -= 5000; a.y += 5000; } // the convoy back in view for (const a of scene._escortConvoyCrafts()) { a.x -= 5000; a.y += 5000; } // the convoy back in view
scene.updateEscort(); scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', 'world + convoy on screen + sky clear → the DELIVERY begins'); assert.equal(st.routeFor(JOB).escort.phase, 'delivering', 'world + convoy on screen + sky clear → the DELIVERY begins');
assert.ok(scene._fired.includes('escortDelivered'), "the TRANSPORT CAPTAIN's delivery thanks arms (the convoy breaks off to the surface)"); assert.ok(scene._fired.includes('escortDelivered'), "the TRANSPORT CAPTAIN’s delivery thanks arms (the convoy breaks off to the surface)");
// …and the jump holds for the whole touchdown (no skipping the landing). // …and the jump holds for the whole touchdown (no skipping the landing).
assert.equal(scene._escortJumpHold(GATE), 'delivering', 'delivering → the jump HOLDS until the touchdown closes'); assert.equal(scene._escortJumpHold(GATE), 'delivering', 'delivering → the jump HOLDS until the touchdown closes');
scene.time.now = 5000; // the delivery's first landing frame (stamps the start) scene.time.now = 5000; // the delivery’s first landing frame (stamps the start)
scene.updateEscort(); scene.updateEscort();
scene.time.now = 9000; // approach + fade complete scene.time.now = 9000; // approach + fade complete
scene.updateEscort(); scene.updateEscort();
@ -441,7 +442,7 @@ console.log('== THE DESTINATION’S DOOR — the landing holds until the transpo
{ {
const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } }; const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } };
// RENDEZVOUS (the convoy hasn't even linked — the transport is still at // RENDEZVOUS (the convoy hasn’t even linked — the transport is still at
// the origin) — the player ahead of the job in the destination system. // the origin) — the player ahead of the job in the destination system.
{ {
const scene = makeScene('sys-home'); const scene = makeScene('sys-home');
@ -506,13 +507,13 @@ console.log('== THE SPEED MATCH — escort visible + out of combat: hull + fight
assert.ok(tr && !tr.dead, 'the transport is standing'); assert.ok(tr && !tr.dead, 'the transport is standing');
// TRANSPORT ON SCREEN + OUT OF COMBAT — the convoy flies as one: the hull // TRANSPORT ON SCREEN + OUT OF COMBAT — the convoy flies as one: the hull
// takes the transport's cap, and the escort fighters match its speed. // takes the transport’s cap, and the escort fighters match its speed.
tr.x = 0; tr.y = 0; // the camera's home view (scroll 0,0 — the craft is in view) tr.x = 0; tr.y = 0; // the camera’s home view (scroll 0,0 — the craft is in view)
scene.combat.active = false; scene.combat.active = false;
const crew = scene.combat.allies.filter((a) => a._escortCrew); const crew = scene.combat.allies.filter((a) => a._escortCrew);
assert.ok(crew.length >= 1, 'the escort fighters stand on the wing'); assert.ok(crew.length >= 1, 'the escort fighters stand on the wing');
scene._syncEscortSpeed(); scene._syncEscortSpeed();
assert.equal(scene.ship.maxSpeed, tr.speed, "the player's top speed MATCHES the transport's"); assert.equal(scene.ship.maxSpeed, tr.speed, "the player’s top speed MATCHES the transport’s");
for (const f of crew) assert.equal(f.speed, tr.speed, 'the escort fighters match the transport\u2019s speed'); for (const f of crew) assert.equal(f.speed, tr.speed, 'the escort fighters match the transport\u2019s speed');
// COMBAT OPENS — everyone falls back to their normal top speed. // COMBAT OPENS — everyone falls back to their normal top speed.
@ -548,7 +549,7 @@ console.log('== THE BOARD — all TWENTY escort dossiers stand and render ==');
assert.equal(new Set(pool.map((e) => e.title)).size, 20, '…with unique titles'); 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)'); 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 / // Every dossier is REACHABLE on a neutral board (the posting’s title /
// issuer / description all render for the row the UI paints). // issuer / description all render for the row the UI paints).
const neutral = RECS['sys-home']; const neutral = RECS['sys-home'];
const seen = new Map(); // title → posting const seen = new Map(); // title → posting
@ -556,7 +557,7 @@ console.log('== THE BOARD — all TWENTY escort dossiers stand and render ==');
const posts = sideQuestPostingsFor(SEED, neutral, `p${i}`, galaxy, 99); const posts = sideQuestPostingsFor(SEED, neutral, `p${i}`, galaxy, 99);
for (const p of posts) { for (const p of posts) {
if (p.category !== 'escort') continue; if (p.category !== 'escort') continue;
assert.ok(p.title && p.title.trim().length > 0, `the row's title renders (${p.title})`); 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.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.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.label && p.accent, '…the category label + accent stand');
@ -570,4 +571,127 @@ console.log('== THE BOARD — all TWENTY escort dossiers stand and render ==');
assert.equal(seen.size, 20, '…all twenty distinct (no two collapse to one row)'); assert.equal(seen.size, 20, '…all twenty distinct (no two collapse to one row)');
} }
// ===========================================================================
console.log('== THE WAR MODEL — a faction rescue: link in hostile space, deliver back home ==');
{
// A FACTION galaxy: sys-zenith (the board’s own friendly system, zenith)
// holds a gate to sys-apex (the hostile neighbor, apex — the transport’s
// captors' ground). Both have a delivery/rendezvous world.
const FRECS = {
'sys-zenith': { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 55, zone: 'mid', type: 'void' },
'sys-apex': { id: 'sys-apex', name: 'Apex Verge', faction: 'apex', d: 60, zone: 'mid', type: 'void' },
};
const FADJ = { 'sys-zenith': ['sys-apex'], 'sys-apex': ['sys-zenith'] };
const FCONTENT = {
'sys-zenith': { planets: [{ id: 'sys-zenith-p1', name: 'Verge One', ordinal: 1, x: 10, y: 20 }], settlements: [] },
'sys-apex': { planets: [{ id: 'sys-apex-p1', name: 'Apex One', ordinal: 1, x: 300, y: 400 }], settlements: [] },
};
const fgalaxy = {
seed: SEED,
byId: new Map(Object.entries(FRECS).map(([k, v]) => [k, v])),
jumpGatesFor: (id) => (FADJ[id] ?? []).map((t) => FRECS[t]).filter(Boolean),
ensureContent: (id) => { if (!FCONTENT[id]) throw new Error(`no content for ${id}`); return FCONTENT[id]; },
contentOf: (id) => fgalaxy.ensureContent(id),
};
const FRIENDLY = FRECS['sys-zenith'];
const HOSTILE = FRECS['sys-apex'];
const FJOB = 'sq-sys-zenith-p1-escort-0';
// A scene standing in the FRIENDLY system (the board is here — the player
// accepts the rescue, then must fly OUT to the hostile ground to link).
const fscene = Object.create(GameScene.prototype);
fscene.playSfx = () => {};
fscene.consoleToast = () => {};
fscene.comms = { trigger: () => {} };
fscene.time = { now: 0 };
fscene.galaxy = fgalaxy;
fscene.systemRecord = { ...FRIENDLY };
fscene.systemContent = fgalaxy.ensureContent('sys-zenith');
fscene.systemPlanets = []; fscene.systemStations = []; fscene.systemGates = []; fscene.planet = null;
fscene.tetherField = null;
fscene.isHomeSystem = false;
fscene.patrolG = { clear() {}, lineStyle() {}, circle() {}, strokeCircle() {}, fillStyle() {}, fillCircle() {} };
fscene.ship = { x: 0, y: 0, addCredits() {} };
fscene.refreshCreditsHud = () => {};
fscene._routeFlightEnvelope = (id) => ({ region: [], anchors: [] });
fscene.sideQuestState = new SideQuestState();
fscene.combat = makeCombat();
fscene.escortDirector = makeDirector();
fscene._escortTransport = null;
fscene.scale = { width: 1280, height: 720 };
fscene.cameras = { main: { scrollX: 0, scrollY: 0 } };
const fpost = {
id: FJOB, systemId: 'sys-zenith', systemName: 'Zenith Verge', category: 'escort',
enemySystemId: 'sys-apex', enemyFaction: 'apex',
title: 'Stranded Freight', label: 'ESCORT', reward: { credits: 220 },
};
const st = fscene.sideQuestState;
st.accept(FJOB); st.setActive(FJOB);
fscene._establishEscort(FJOB, fpost);
const r0 = st.routeFor(FJOB);
assert.equal(r0.kind, 'escort', 'the route is typed escort');
assert.equal(r0.escort.phase, 'rendezvous', '…standing at its rendezvous');
assert.equal(r0.escort.originSystemId, 'sys-apex', 'the RENDEZVOUS is in the HOSTILE neighbor (the transport stands there)');
assert.equal(r0.escort.destSystemId, 'sys-zenith', '…and the DELIVERY is the board’s own friendly system');
assert.equal(r0.escort.enemyFaction, 'apex', '…hunted by its captors (the faction’s roster)');
assert.ok(Number.isFinite(r0.credits) && r0.credits > 0, 'the credits payout is fixed');
assert.ok(r0.xp > 0, 'the XP payout is fixed');
// The player flies OUT to the hostile ground and reaches the rendezvous —
// the convoy LINKS (the transport + its escort fighters form on the wing).
fscene.systemRecord = { ...HOSTILE };
fscene.systemContent = fgalaxy.ensureContent('sys-apex');
const wp = r0.waypoints[0];
fscene.ship.x = wp[0]; fscene.ship.y = wp[1];
fscene.escortDirector.nextComp = null;
fscene.updateEscort();
const r1 = st.routeFor(FJOB);
assert.equal(r1.escort.phase, 'transit', 'reaching the rendezvous in hostile space LINKS the convoy');
assert.ok(fscene._escortTransport && !fscene._escortTransport.dead, 'the TRANSPORT forms on the wing');
// THE LANE — the escort’s contact fires a wave of the CAPTORS' OWN fighters
// (the faction roster, not the pirate pool), even though this is faction space.
fscene.escortDirector.nextComp = { kind: 'pirates', pirates: 3, neutrals: 0 };
fscene.updateEscort();
const script = fscene.combat._lastScript;
assert.ok(fscene.combat.waveRun?.state === 'live', 'a LANE contact fires the escort wave in HOSTILE faction space');
const roster = script?.waves?.[0]?.roster ?? {};
const rkeys = Object.keys(roster);
assert.ok(rkeys.length >= 1, 'the lane’s roster stands');
assert.ok(rkeys.every((t) => t.startsWith('apex_')), `the lane is the CAPTORS' own fighters (got ${rkeys.join(',')})`);
assert.ok(!rkeys.includes('pirate_fighter'), '…and no pirate type (the war-model lane)');
// THE GATE FLY-THROUGH — the player carries the convoy back into the friendly
// system (the scene restart tears the sprites); the convoy RE-FORMS and the
// delivery is the friendly world (the sky-clear witness seals it).
fscene.combat._clearWave();
fscene.combat._teardownConvoy();
fscene._escortTransport = null;
fscene.systemRecord = { ...FRIENDLY };
fscene.systemContent = fgalaxy.ensureContent('sys-zenith');
const destObj = fgalaxy.ensureContent('sys-zenith').planets[0];
fscene.ship.x = destObj.x; fscene.ship.y = destObj.y;
fscene.combat.enemies.length = 0; fscene.combat.active = false; fscene.combat.waveRun = { state: 'done' };
fscene.escortDirector.nextComp = null;
fscene.cameras.main.scrollX = fscene.ship.x - fscene.scale.width / 2;
fscene.cameras.main.scrollY = fscene.ship.y - fscene.scale.height / 2;
fscene.time.now = 0;
fscene.updateEscort();
assert.equal(st.routeFor(FJOB).escort.phase, 'delivering', 'back in friendly space, world on screen + sky clear → the DELIVERY begins');
assert.ok(fscene._escortTransport && !fscene._escortTransport.dead, '…the convoy re-forms after the gate’s scene restart');
// THE TOUCHDOWN — a fixed approach + fade (deliverStart is stamped on the
// first delivering frame, so the seal lands ~2.3s after that frame).
fscene.time.now = 1000;
fscene.updateEscort();
assert.equal(st.routeFor(FJOB).escort.phase, 'delivering', '…mid-touchdown, still landing');
fscene.time.now = 3600;
fscene.updateEscort();
assert.equal(st.routeFor(FJOB).escort.phase, 'delivered', 'the touchdown closes → DELIVERED');
assert.equal(st.isCompleted(FJOB), true, '…and the contract is COMPLETE (the convoy made it home through the gate)');
const claim = fscene.claimSideQuest(FJOB);
assert.equal(claim.ok, true, 'the claim pays');
assert.ok(claim.credits > 0 && claim.xp > 0, '…the credits + the lane’s worth');
}
console.log('\nescort-flow: OK — the run (rendezvous→link→wave→jump→delivery), the witness gates (convoy + gate on screen before the jump; the convoy on screen before the landing), the destination’s door (no landing until the transport reaches it or is lost), the speed match (transport in view + out of combat: the hull and the escort fighters fly at the transport’s top speed; combat / off-screen / contract-end restore everyone), the failure (loss→land→reset→retry), the board (20 dossiers)'); console.log('\nescort-flow: OK — the run (rendezvous→link→wave→jump→delivery), the witness gates (convoy + gate on screen before the jump; the convoy on screen before the landing), the destination’s door (no landing until the transport reaches it or is lost), the speed match (transport in view + out of combat: the hull and the escort fighters fly at the transport’s top speed; combat / off-screen / contract-end restore everyone), the failure (loss→land→reset→retry), the board (20 dossiers)');

View File

@ -1,18 +1,18 @@
// dev/escort.test.mjs — the ESCORT contract's model, verified headless // dev/escort.test.mjs — the ESCORT contract’s model, verified headless
// (node dev/escort.test.mjs — no browser, no Phaser render). // (node dev/escort.test.mjs — no browser, no Phaser render).
// //
// What this pins (the escort's contract — data/sidequests.json → escort): // What this pins (the escort’s contract — data/sidequests.json → escort):
// - The GATE — escort is LIVE by default, neutral-only (a faction board // - The GATE — escort is LIVE by default, neutral-only (a faction board
// never posts it), and carries a minLevel. // never posts it), and carries a minLevel.
// - The DESTINATION — escortDestinationFor's seeded 1–2 hop walk: a // - The DESTINATION — escortDestinationFor’s seeded 1–2 hop walk: a
// neutral, gate-reachable system, a real planet/station object, the // neutral, gate-reachable system, a real planet/station object, the
// same world across calls (a signed contract is a signed contract), a // 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. // different seed draws its own, and a dead-end origin degrades to null.
// - The WAVE ROSTER — escortWaveRoster's mixed fighters (valid types, // - The WAVE ROSTER — escortWaveRoster’s mixed fighters (valid types,
// finite wave shape, a sensible concurrent/replace band). // finite wave shape, a sensible concurrent/replace band).
// - The XP — escortEncounterXp's positive payout. // - The XP — escortEncounterXp’s positive payout.
// - The LEDGER — SideQuestState.setRoute's escort record (phase, // - The LEDGER — SideQuestState.setRoute’s escort record (phase,
// destination, the transport's armor) + the save round-trip (the // destination, the transport’s armor) + the save round-trip (the
// phase + armor ride the save; a malformed record degrades to a fresh // phase + armor ride the save; a malformed record degrades to a fresh
// 'rendezvous' on the same destination). // 'rendezvous' on the same destination).
@ -44,6 +44,7 @@ const {
enabledSideQuestCategories, enabledSideQuestCategories,
sideQuestAcceptable, sideQuestAcceptable,
sideQuestPostingsFor, sideQuestPostingsFor,
gateAccessLevel,
escortDestinationFor, escortDestinationFor,
escortWaveRoster, escortWaveRoster,
escortEncounterXp, escortEncounterXp,
@ -54,7 +55,7 @@ const SEED = 77031;
const JOB = 'sq-sys-home-p1-escort-0'; const JOB = 'sq-sys-home-p1-escort-0';
// A small gate graph (the neutral-only scope): // A small gate graph (the neutral-only scope):
// sys-home (neutral) — the posting's system (where the convoy stands) // sys-home (neutral) — the posting’s system (where the convoy stands)
// ├─ sys-a (neutral) — one hop away // ├─ sys-a (neutral) — one hop away
// │ └─ sys-b (neutral) — two hops away // │ └─ sys-b (neutral) — two hops away
// └─ sys-zenith (zenith) — a FACTION neighbor (must be excluded) // └─ sys-zenith (zenith) — a FACTION neighbor (must be excluded)
@ -95,15 +96,15 @@ const makeGalaxy = () => ({
const galaxy = makeGalaxy(); const galaxy = makeGalaxy();
// =========================================================================== // ===========================================================================
console.log('== the gate — escort is LIVE, neutral-only, level-gated =='); console.log('== the gate — escort is LIVE, neutral + faction boards, level-gated ==');
{ {
const meta = sideQuestCategoryMeta('escort'); const meta = sideQuestCategoryMeta('escort');
assert.ok(meta, 'the escort meta stands'); assert.ok(meta, 'the escort meta stands');
assert.equal(meta.enabled, true, 'escort is LIVE by default (the data’s enabled flag)'); 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.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.deepEqual(meta.contexts, ['neutral', 'faction'], 'escort posts on NEUTRAL boards (the classic contract) AND FACTION boards (the war-model rescue)');
assert.ok(enabledSideQuestCategories('neutral').includes('escort'), 'a NEUTRAL board posts escort'); assert.ok(enabledSideQuestCategories('neutral').includes('escort'), 'a NEUTRAL board posts escort');
assert.ok(!enabledSideQuestCategories('faction').includes('escort'), 'a FACTION board never posts escort'); assert.ok(enabledSideQuestCategories('faction').includes('escort'), 'a FACTION board posts escort (the war-model rescue)');
// The level gate: below the minLevel the contract is locked; at/above it, open. // The level gate: below the minLevel the contract is locked; at/above it, open.
const locked = sideQuestAcceptable({ category: 'escort' }, meta.minLevel - 1); const locked = sideQuestAcceptable({ category: 'escort' }, meta.minLevel - 1);
@ -114,7 +115,7 @@ console.log('== the gate — escort is LIVE, neutral-only, level-gated ==');
} }
// =========================================================================== // ===========================================================================
console.log('== the posting — the neutral board stands an escort; the faction board does not =='); console.log('== the posting — neutral boards stand the classic escort; faction boards stand the war-model rescue ==');
{ {
const neutral = { id: 'sys-home', name: 'Hearth', faction: null, d: 5, zone: 'near', type: 'main' }; 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' }; const faction = { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 55, zone: 'mid', type: 'void' };
@ -125,9 +126,40 @@ console.log('== the posting — the neutral board stands an escort; the faction
assert.equal(e.faction, null, 'the escort posting carries no faction'); 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.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'); assert.ok(e.description.length > 20, 'the escort posting’s body reads');
// Faction board: no escort stands (neutral-only). // Faction board: no escort stands WHEN NO RIVAL HOLDS GATE-REACHABLE GROUND
// (sys-zenith’s only neighbor here is the neutral sys-home — the rescue
// needs a hostile system to stand in). A hostile neighbor flips it on.
const fPosts = sideQuestPostingsFor(SEED, faction, 'p1', galaxy, 99); const fPosts = sideQuestPostingsFor(SEED, faction, 'p1', galaxy, 99);
assert.ok(!fPosts.some((p) => p.category === 'escort'), 'a FACTION board never stands an escort'); assert.ok(!fPosts.some((p) => p.category === 'escort'), 'a FACTION board stands no escort without a hostile neighbor (the slot stands empty)');
// THE WAR-MODEL RESCUE — with a rival’s gate-reachable neighbor, the
// faction board posts the escort: the transport is trapped in that hostile
// system (the rendezvous), the DELIVERY is the board’s own (friendly) system.
const rival = { id: 'sys-rival', name: 'Apex Verge', faction: 'apex', d: 60, zone: 'mid', type: 'void' };
const wgalaxy = {
...galaxy,
byId: new Map([...galaxy.byId, [rival.id, rival]]),
jumpGatesFor: (id) => (id === 'sys-zenith' ? [rival, RECS['sys-home']] : (ADJ[id] ?? []).map((t) => RECS[t]).filter(Boolean)),
};
// The escort occupies a SEEDED slot (a seeded shuffle), so pool a few
// worlds to stand it (any of them is the same contract shape).
let fe = null;
for (const wk of ['p1', 'p2', 'p3', 'p4', 'p5', 'p6']) {
fe = sideQuestPostingsFor(SEED, faction, wk, wgalaxy, 99).find((p) => p.category === 'escort') ?? null;
if (fe) break;
}
assert.ok(fe, 'a FACTION board stands the war-model escort WITH a hostile neighbor');
assert.equal(fe.enemySystemId, 'sys-rival', 'the transport is trapped in the hostile neighbor (the rendezvous ground)');
assert.equal(fe.destSystemId, 'sys-zenith', '…and the DELIVERY is the board’s own friendly system');
assert.equal(fe.enemyFaction, 'apex', '…hunted by its captors (that rival)');
assert.ok(/Apex/.test(fe.description), 'the rescue copy names the captors (the {enemy} resolved)');
// THE GATE LOCK — the rescue is LOCKED until the player’s jump-gate
// capability opens the hop into that hostile faction system (the
// existing factionLock’s minLevel — the posting’s gateLevel).
const gl = gateAccessLevel(faction, rival);
assert.ok(typeof fe.gateLevel === 'number' && fe.gateLevel >= gl && gl >= 1, `the rescue carries a GATE lock (target is faction-held) — gateLevel=${fe.gateLevel} rule=${gl}`);
assert.equal(sideQuestAcceptable(fe, gl - 1).locked, true, 'the rescue is LOCKED below the gate level');
assert.equal(sideQuestAcceptable(fe, gl).ok, true, 'the rescue OPENS at the gate level');
} }
// =========================================================================== // ===========================================================================
@ -183,6 +215,19 @@ console.log('== the WAVE ROSTER — mixed fighters, a sane wave shape ==');
const low = escortWaveRoster(0); const low = escortWaveRoster(0);
const high = escortWaveRoster(99); 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)'); 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)');
// THE WAR-MODEL ROSTER — a faction escort’s lane is that faction’s OWN
// fighters (the captors' escort detail), not the pirate pool.
const zf = escortWaveRoster(5, 'zenith');
const ztypes = Object.keys(zf.roster);
assert.ok(ztypes.length >= 1, 'a faction escort’s roster stands');
assert.ok(ztypes.every((t) => t.startsWith('zenith_')), `a faction escort’s lane is the faction’s own fighters (got ${ztypes.join(',')})`);
assert.ok(!ztypes.includes('pirate_fighter'), '…and no pirate type (the war-model lane is faction, not pirate)');
const af = escortWaveRoster(5, 'apex');
assert.ok(Object.keys(af.roster).every((t) => t.startsWith('apex_')), `a different faction draws ITS OWN fighters (got ${Object.keys(af.roster).join(',')})`);
// The neutral roster (no faction) still stands (unchanged classic contract).
const neutral = escortWaveRoster(5);
assert.ok(Object.keys(neutral.roster).length >= 1, 'the neutral (no-faction) roster still stands');
} }
// =========================================================================== // ===========================================================================

View File

@ -8,9 +8,10 @@
* clamping of every write (set/change); * clamping of every write (set/change);
* - the HOME WORLD exception: pinned at +20 (read, set, change, and * - the HOME WORLD exception: pinned at +20 (read, set, change, and
* across a save round-trip) — nothing ever moves it; * across a save round-trip) — nothing ever moves it;
* - the FACTION CHECK placeholder: standingFor() resolves * - the FACTION CHECK: standingFor() resolves
* home → stored → owner/faction → neutral, with factionStanding() * home → stored → owner/faction → neutral, with the per-faction
* stubbed (factions land later) and settlements' `owner` null today; * standing (setFaction/changeFaction — a new player is NEUTRAL with
* every faction) and settlements' `owner` carrying the faction id;
* - SAVE round-trips: toJSON/fromJSON (including corrupt and * - SAVE round-trips: toJSON/fromJSON (including corrupt and
* pre-reputation records — they load all-neutral); * pre-reputation records — they load all-neutral);
* - the SAVE INTEGRATION (js/save/SaveData.js): captureState carries * - the SAVE INTEGRATION (js/save/SaveData.js): captureState carries
@ -114,7 +115,7 @@ const HOME = config.get('reputation.home', 20);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// 4. The faction check (placeholder) — standingFor()'s resolution order // 4. The faction check — standingFor()'s resolution order, per-faction standing
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
{ {
const rep = new Reputation(); const rep = new Reputation();
@ -124,26 +125,41 @@ const HOME = config.get('reputation.home', 20);
rep.set('S000007-s1', 9); rep.set('S000007-s1', 9);
check('a stored standing wins', rep.standingFor(colony) === 9); check('a stored standing wins', rep.standingFor(colony) === 9);
// Owner set (the factions layer will populate this): the check consults // The FACTION CHECK, for real: a faction-owned place reads the standing
// factionStanding() — stubbed to null today, so neutral again… // WITH the owning faction (setFaction) — unset = neutral (a new player's
const owned = { id: 'S000008-s2', kind: 'deepSpaceStation', owner: 'pirates' }; // standing with every faction).
check('…an owned place with no stored standing still reads neutral (stub)', check('a new player is NEUTRAL with every faction (unset → null → 0)',
rep.standingFor(owned) === NEUTRAL); rep.factionStanding('apex') === null
check('…but a STORED standing beats the (stub) faction check — stored first', && rep.standingFor({ id: 'S000008-s2', owner: 'apex' }) === NEUTRAL
(rep.set('S000008-s2', -3), rep.standingFor(owned)) === -3); && rep.standingFor({ id: 'S000008-s2', owner: 'mindweave' }) === NEUTRAL
&& rep.standingFor({ id: 'S000008-s2', owner: 'zenith' }) === NEUTRAL);
// And a stub that "has" standing (simulating the factions layer landing) const owned = { id: 'S000008-s2', kind: 'deepSpaceStation', owner: 'apex' };
// would hand it straight to standingFor() — the seam is real: check('setFaction() stores a standing with the faction',
const rep2 = new Reputation(); rep.setFaction('apex', 7) === true && rep.factionStanding('apex') === 7);
const standIn = (id) => (id === 'pirates' ? 15 : null); check('…and a faction-owned place reads it through standingFor()',
rep2.factionStanding = standIn; // monkey-patch: pretend the factions exist rep.standingFor(owned) === 7);
check('when factionStanding() answers, standingFor() uses it for owned places', check('…but an unowned place (or another faction) is untouched',
rep2.standingFor({ id: 'S000009-s1', owner: 'pirates' }) === 15 rep.standingFor(colony) === 9 && rep.standingFor({ id: 'x-s1', owner: 'zenith' }) === NEUTRAL);
&& rep2.standingFor({ id: 'S000010-s1', owner: null }) === 0 check('setFaction() clamps to the scale',
&& rep2.standingFor({ id: 'S000011-s1' }) === 0); rep.setFaction('zenith', 99) && rep.factionStanding('zenith') === MAX
&& rep.setFaction('zenith', -99) && rep.factionStanding('zenith') === MIN);
check('setFaction() ignores bad input',
rep.setFaction('', 5) === false && rep.setFaction('apex', 'famous') === false
&& rep.factionStanding('apex') === 7);
check('changeFaction() shifts the faction standing, clamped',
rep.changeFaction('apex', -100) && rep.factionStanding('apex') === MIN
&& rep.changeFaction('apex', 1000) && rep.factionStanding('apex') === MAX);
check('changeFaction() starts from neutral (0) when unset',
rep.changeFaction('mindweave', 3) && rep.factionStanding('mindweave') === 3);
// Resolution order: home > stored place standing > faction > neutral.
const owned2 = { id: 'S000009-s2', kind: 'colony', owner: 'apex' };
check('a STORED place standing beats the faction check — stored first',
(rep.set('S000009-s2', -3), rep.standingFor(owned2)) === -3);
check('the home world beats everything (stored, owned, faction)', check('the home world beats everything (stored, owned, faction)',
rep2.standingFor({ id: HOME_KEY, owner: 'pirates' }) === rep2.home); rep.standingFor({ id: HOME_KEY, owner: 'apex' }) === rep.home
&& rep.standingFor(HOME_KEY) === rep.home);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -153,12 +169,17 @@ const HOME = config.get('reputation.home', 20);
const rep = new Reputation(); const rep = new Reputation();
rep.set('S000001-p2', 12); rep.set('S000001-p2', 12);
rep.set('S000002-s3', -8); rep.set('S000002-s3', -8);
rep.setFaction('apex', 11);
rep.setFaction('zenith', -4);
const json = rep.toJSON(); const json = rep.toJSON();
check('toJSON() keeps the scale + the stored standings', check('toJSON() keeps the scale + the stored standings + the faction standings',
json.min === MIN && json.max === MAX && json.places['S000001-p2'] === 12 && json.places['S000002-s3'] === -8); json.min === MIN && json.max === MAX && json.places['S000001-p2'] === 12 && json.places['S000002-s3'] === -8
&& json.factions.apex === 11 && json.factions.zenith === -4);
const back = Reputation.fromJSON(JSON.parse(JSON.stringify(json))); const back = Reputation.fromJSON(JSON.parse(JSON.stringify(json)));
check('fromJSON() round-trips every standing', check('fromJSON() round-trips every standing (places + factions)',
back.get('S000001-p2') === 12 && back.get('S000002-s3') === -8 && back.get('S000003-p1') === NEUTRAL); back.get('S000001-p2') === 12 && back.get('S000002-s3') === -8 && back.get('S000003-p1') === NEUTRAL
&& back.factionStanding('apex') === 11 && back.factionStanding('zenith') === -4
&& back.factionStanding('mindweave') === null);
check('out-of-range values in a save are clamped back onto the scale', check('out-of-range values in a save are clamped back onto the scale',
Reputation.fromJSON({ min: MIN, max: MAX, neutral: NEUTRAL, home: HOME, places: { a: 999, b: -999 } }) Reputation.fromJSON({ min: MIN, max: MAX, neutral: NEUTRAL, home: HOME, places: { a: 999, b: -999 } })
@ -166,9 +187,16 @@ const HOME = config.get('reputation.home', 20);
check('garbage entries are dropped, good ones kept', check('garbage entries are dropped, good ones kept',
Reputation.fromJSON({ places: { a: 'famous', b: 5, 0: 3 } }).get('b') === 5 Reputation.fromJSON({ places: { a: 'famous', b: 5, 0: 3 } }).get('b') === 5
&& Reputation.fromJSON({ places: { a: 'famous' } }).get('a') === NEUTRAL); && Reputation.fromJSON({ places: { a: 'famous' } }).get('a') === NEUTRAL);
check('faction garbage is dropped, good ones kept (clamped)',
Reputation.fromJSON({ factions: { apex: 'famous', zenith: 5, 0: 3, mindweave: 999 } })
.factionStanding('zenith') === 5 && Reputation.fromJSON({ factions: { apex: 'famous' } })
.factionStanding('apex') === null);
check('a record from BEFORE the faction check (no `factions` field) loads all-neutral',
Reputation.fromJSON({ places: { a: 5 } }).factionStanding('apex') === null);
check('null / missing records load as a fresh, all-neutral standing', check('null / missing records load as a fresh, all-neutral standing',
Reputation.fromJSON(null).get('anywhere') === NEUTRAL Reputation.fromJSON(null).get('anywhere') === NEUTRAL
&& Reputation.fromJSON(undefined).home === HOME); && Reputation.fromJSON(undefined).home === HOME
&& Reputation.fromJSON(undefined).factionStanding('apex') === null);
check('a scale snapshot travels with the save (old save, old rules)', check('a scale snapshot travels with the save (old save, old rules)',
Reputation.fromJSON({ min: -5, max: 5, neutral: 0, home: 5 }).get(HOME_KEY) === 5); Reputation.fromJSON({ min: -5, max: 5, neutral: 0, home: 5 }).get(HOME_KEY) === 5);
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 605 KiB

View File

@ -331,8 +331,8 @@
"maxPoints": 5, "maxPoints": 5,
"energy": 60, "energy": 60,
"cooldown": 6, "cooldown": 6,
"cooldownLeft": 5.883349999999997, "cooldownLeft": 5.808349999999999,
"cooldownFrac": 0.019441666666667134, "cooldownFrac": 0.03194166666666687,
"affordable": false, "affordable": false,
"ready": false, "ready": false,
"inert": false "inert": false
@ -528,7 +528,7 @@
"secs": "" "secs": ""
} }
], ],
"energy": 40.69990000000001, "energy": 41.14989999999999,
"maxEnergy": 100, "maxEnergy": 100,
"hull": 6000, "hull": 6000,
"maxHull": 6000, "maxHull": 6000,
@ -536,8 +536,8 @@
"dashing": true, "dashing": true,
"down": false, "down": false,
"ship": { "ship": {
"x": 223.94929622945534, "x": 314.1771078433544,
"y": -217.1997635687758 "y": -378.59362049985026
}, },
"ledgerSeats": [ "ledgerSeats": [
"ramming_charge", "ramming_charge",
@ -782,16 +782,16 @@
"secs": "" "secs": ""
} }
], ],
"energy": 59.13998, "energy": 59.22998,
"maxEnergy": 100, "maxEnergy": 100,
"hull": 13.939580000000014, "hull": 129,
"maxHull": 129, "maxHull": 129,
"level": 1, "level": 1,
"dashing": false, "dashing": false,
"down": false, "down": false,
"ship": { "ship": {
"x": 478.2479835925921, "x": 333.4301399006624,
"y": -463.83422815995596 "y": -401.79414953333486
}, },
"ledgerSeats": [ "ledgerSeats": [
"ramming_charge", "ramming_charge",
@ -1036,16 +1036,16 @@
"secs": "" "secs": ""
} }
], ],
"energy": 62.21954000000003, "energy": 64.05955999999995,
"maxEnergy": 100, "maxEnergy": 100,
"hull": 10.389580000000013, "hull": 91,
"maxHull": 129, "maxHull": 129,
"level": 1, "level": 1,
"dashing": false, "dashing": false,
"down": false, "down": false,
"ship": { "ship": {
"x": 478.2479835925921, "x": 333.4301399006624,
"y": -463.83422815995596 "y": -401.79414953333486
}, },
"ledgerSeats": [ "ledgerSeats": [
null, null,
@ -1290,16 +1290,16 @@
"secs": "" "secs": ""
} }
], ],
"energy": 63.44930000000004, "energy": 65.89939999999993,
"maxEnergy": 100, "maxEnergy": 100,
"hull": 10.389580000000013, "hull": 81,
"maxHull": 129, "maxHull": 129,
"level": 1, "level": 1,
"dashing": false, "dashing": false,
"down": false, "down": false,
"ship": { "ship": {
"x": 478.2479835925921, "x": 333.4301399006624,
"y": -463.83422815995596 "y": -401.79414953333486
}, },
"ledgerSeats": [ "ledgerSeats": [
null, null,
@ -1544,16 +1544,16 @@
"secs": "" "secs": ""
} }
], ],
"energy": 65.97902000000003, "energy": 67.50925999999991,
"maxEnergy": 100, "maxEnergy": 100,
"hull": 10.389580000000013, "hull": 81,
"maxHull": 129, "maxHull": 129,
"level": 1, "level": 1,
"dashing": false, "dashing": false,
"down": false, "down": false,
"ship": { "ship": {
"x": 478.2479835925921, "x": 333.4301399006624,
"y": -463.83422815995596 "y": -401.79414953333486
}, },
"ledgerSeats": [ "ledgerSeats": [
"ramming_charge", "ramming_charge",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 481 KiB

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 201 KiB

View File

@ -5,15 +5,15 @@
// - the CATEGORY GATE — the four types (patrol / delivery / bounty / // - the CATEGORY GATE — the four types (patrol / delivery / bounty /
// escort) are all accounted for; PATROL is the first LIVE category // escort) are all accounted for; PATROL is the first LIVE category
// (its rules land with this pass) while the other three stay // (its rules land with this pass) while the other three stay
// OFFLINE, and a category's jobs appear on the board ONLY when its // OFFLINE, and a category’s jobs appear on the board ONLY when its
// `enabled` flag flips (flip the rest → the per-world count // `enabled` flag flips (flip the rest → the per-world count
// spreads; flip the feature's master switch → an empty board). // spreads; flip the feature’s master switch → an empty board).
// - DETERMINISM — the same contract stands on the same WORLD across // - DETERMINISM — the same contract stands on the same WORLD across
// calls (seed + record + world → the same postings — visits, scene // calls (seed + record + world → the same postings — visits, scene
// restarts, saves), a different seed draws its own board, and // restarts, saves), a different seed draws its own board, and
// different worlds (another planet in the same system, another // different worlds (another planet in the same system, another
// system) draw different boards (no global call-order dependence). // system) draw different boards (no global call-order dependence).
// - The per-world COUNT (1–3 by design, within the data's range), // - The per-world COUNT (1–3 by design, within the data’s range),
// unique ids, and the slots spreading across the live types. // unique ids, and the slots spreading across the live types.
// - The CONTEXT voice — the NEUTRAL pools are the pirate-safety voice // - The CONTEXT voice — the NEUTRAL pools are the pirate-safety voice
// (the data’s contract: 2 of the 3 variants per category name the // (the data’s contract: 2 of the 3 variants per category name the
@ -82,6 +82,8 @@ const {
SideQuestState, SideQuestState,
patrolTargetSystem, patrolTargetSystem,
buildPatrolRoute, buildPatrolRoute,
seededOpposingFaction,
hostileNeighborSystem,
} = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href); } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href); const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
@ -117,7 +119,7 @@ console.log('== the category gate — patrol + bounty hunt + escort LIVE by defa
assert.deepEqual(SIDE_QUEST_CATEGORIES, ['patrol', 'delivery', 'bounty', 'escort'], 'the four types, canonical slot order'); 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.equal(sideQuestsEnabled(), true, 'the feature’s master switch is ON (the data’s enabled flag)');
assert.deepEqual(enabledSideQuestCategories(), ['patrol', 'bounty', 'escort'], 'PATROL, the BOUNTY HUNT, and ESCORT are the live categories (the gates that landed)'); 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.deepEqual(enabledSideQuestCategories('faction'), ['patrol', 'bounty', 'escort'], 'a FACTION board draws the war model (patrol at home, the bounty + escort on hostile ground)');
assert.equal(sideQuestCategoryMeta('huntr'), null, 'an unknown category → null (no fabrication)'); assert.equal(sideQuestCategoryMeta('huntr'), null, 'an unknown category → null (no fabrication)');
for (const cat of SIDE_QUEST_CATEGORIES) { for (const cat of SIDE_QUEST_CATEGORIES) {
@ -131,10 +133,19 @@ console.log('== the category gate — patrol + bounty hunt + escort LIVE by defa
for (const ctx of ['neutral', 'faction']) { for (const ctx of ['neutral', 'faction']) {
const pool = DATA.sidequests.descriptions[ctx][cat]; const pool = DATA.sidequests.descriptions[ctx][cat];
if (cat === 'bounty' && ctx === 'faction') { if (cat === 'bounty' && ctx === 'faction') {
// The documented stub: the hunt’s dossiers are the neutral pirate-hunt // THE WAR MODEL — the faction wanted-list is the generic war-voice
// voice, so the faction wanted-list stays EMPTY (and its category’s // (three dossiers, {enemy}-resolved, the mark’s frame + pickets drawn
// contexts keep it off a faction board regardless). // from the mark’s OWN faction roster — deriveBountyPlan’s opts.faction).
assert.deepEqual(pool, [], `bounty.faction: the empty stub the data documents`); assert.ok(Array.isArray(pool) && pool.length >= 3, 'bounty.faction: the war-model wanted-list stands (3+ dossiers)');
const names = pool.map((v) => v.name);
assert.equal(new Set(names).size, names.length, 'bounty.faction: every dossier names a UNIQUE leader (the one-leader-once rule needs raw-unique names)');
for (const v of pool) {
assert.ok(typeof v.name === 'string' && v.name.length > 0, 'bounty.faction: a leader name per dossier');
assert.ok(typeof v.epithet === 'string' && v.epithet.length > 0, 'bounty.faction: an epithet per dossier');
assert.ok(typeof v.crime === 'string' && v.crime.length > 0, 'bounty.faction: a charge per dossier');
assert.ok(v.body.includes('{enemy}'), 'bounty.faction: the body names the sighted rival ({enemy})');
assert.ok(!/\{[a-z]+\}/.test(v.name), 'bounty.faction: the leader NAME is placeholder-free (the usedDossier dedup keys on the raw name)');
}
continue; continue;
} }
assert.ok(Array.isArray(pool) && pool.length >= 3, `${cat}.${ctx}: a variant pool of 3+ (the boards don’t read as one repeated ad)`); assert.ok(Array.isArray(pool) && pool.length >= 3, `${cat}.${ctx}: a variant pool of 3+ (the boards don’t read as one repeated ad)`);
@ -181,8 +192,8 @@ console.log('== the gate flips — one live type → only it posts; all live →
assert.deepEqual(p.objective, { type: 'patrol' }, 'the completion socket stands typed (the type’s rules fill it in)'); assert.deepEqual(p.objective, { type: 'patrol' }, 'the completion socket stands typed (the type’s rules fill it in)');
} }
// The id's split (parseSideQuestId — the scene’s posting lookups + // The id’s split (parseSideQuestId — the scene’s posting lookups +
// the claim's reward read parse the ledger’s id back to its board + // the claim’s reward read parse the ledger’s id back to its board +
// slot, then regenerate the board): planet AND station worlds stand, // slot, then regenerate the board): planet AND station worlds stand,
// and the OLD per-system shape (no world key) stands no world — null. // and the OLD per-system shape (no world key) stands no world — null.
assert.deepEqual(parseSideQuestId('sq-sys-free-p1-patrol-2'), { systemId: 'sys-free', worldKey: 'p1', category: 'patrol', slot: 2 }, 'a planet board’s id splits back apart'); assert.deepEqual(parseSideQuestId('sq-sys-free-p1-patrol-2'), { systemId: 'sys-free', worldKey: 'p1', category: 'patrol', slot: 2 }, 'a planet board’s id splits back apart');
@ -201,12 +212,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}]`); 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)); const ids = new Set(posts.map((p) => p.id));
assert.equal(ids.size, posts.length, `${rec.id}: the ids are unique (category + slot)`); assert.equal(ids.size, posts.length, `${rec.id}: the ids are unique (category + slot)`);
// The slots can't spread past the board's LIVE categories for that // The slots can’t spread past the board’s LIVE categories for that
// context (a faction board never draws a neutral-only type — bounty // context (a faction board never draws a neutral-only type — bounty
// and escort stand off it), nor past the posting count itself. // and escort stand off it), nor past the posting count itself.
const ctx = rec.faction ? 'faction' : 'neutral'; const ctx = rec.faction ? 'faction' : 'neutral';
const liveInCtx = enabledSideQuestCategories(ctx).length; 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})`); 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 // DETERMINISM — same seed + record + world → the same board across
@ -217,7 +228,7 @@ console.log('== the gate flips — one live type → only it posts; all live →
const b = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'); const b = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1');
assert.deepEqual(b, a, 'the same contract stands across calls'); assert.deepEqual(b, a, 'the same contract stands across calls');
assert.notDeepEqual(sideQuestPostingsFor(SEED + 7, REC_ZENITH, 'p1'), a, 'a different seed → a different board'); assert.notDeepEqual(sideQuestPostingsFor(SEED + 7, REC_ZENITH, 'p1'), a, 'a different seed → a different board');
// …and the worlds don't share one ad (the variant pools + the // …and the worlds don’t share one ad (the variant pools + the
// per-posting draws stand per world). // per-posting draws stand per world).
assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_ZENITH, 'p2'), a, 'a different WORLD in the same system draws its own board'); assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_ZENITH, 'p2'), a, 'a different WORLD in the same system draws its own board');
assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX, 'p1'), a, 'a different system draws its own board'); assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX, 'p1'), a, 'a different system draws its own board');
@ -235,20 +246,19 @@ console.log('== the gate flips — one live type → only it posts; all live →
assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'the neutral copy is placeholder-free'); assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'the neutral copy is placeholder-free');
} }
// FACTION voice — pushing the OTHER factions out (every faction // FACTION voice — THE WAR MODEL (every patrol/bounty/escort variant
// variant carries {enemies}; the resolved copy names both the // names the ONE sighted rival via {enemy}; the resolved copy names the
// system’s own faction and at least one of the other two). // board’s own faction’s ISSUER + at least one of the two rivals).
for (const cat of SIDE_QUEST_CATEGORIES) { for (const cat of ['patrol', 'bounty', 'escort']) {
const pool = DATA.sidequests.descriptions.faction[cat]; const pool = DATA.sidequests.descriptions.faction[cat];
assert.ok(pool.every((v) => v.body.includes('{enemies}')), `faction ${cat}: every variant carries {enemies}`); assert.ok(pool.every((v) => v.body.includes('{enemy}')), `faction ${cat}: every variant carries {enemy} (the ONE sighted rival)`);
} }
const z = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'); const z = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1');
for (const p of z) { for (const p of z) {
assert.equal(p.faction, 'zenith', 'the posting carries its system’s faction'); assert.equal(p.faction, 'zenith', 'the posting carries its system’s faction');
assert.match(p.issuer, /Zenith/, 'the faction’s ISSUER names the faction (the data’s factions table)'); assert.match(p.issuer, /Zenith/, 'the faction’s ISSUER names the faction (the data’s factions table)');
const others = (p.description.match(/Apex|MindWeave/g) ?? []).length; assert.ok(/Apex|MindWeave/.test(p.description), 'a rival stands in the war copy (the {enemy} resolved)');
assert.ok(others >= 1, 'the other factions stand in the push-out copy'); assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}|\{enemy\}/.test(p.title + p.issuer + p.description), 'the faction copy is placeholder-free');
assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'the faction copy is placeholder-free');
} }
// The REWARDS — inside the band (scaled by d), in 5 CR steps. // The REWARDS — inside the band (scaled by d), in 5 CR steps.
@ -287,12 +297,12 @@ console.log('== the placeholder resolution (the pool’s {…} keys → the cont
} }
// =========================================================================== // ===========================================================================
console.log('== the patrol’s TARGET rule (the posting’s system, or a NEIGHBOR’s) =='); console.log('== the patrol’s WAR MODEL — the target is always the posting’s OWN system + the sighted rival is ONE seeded faction ==');
{ {
// A stand-in GALAXY (the model’s interface: byId Map + jumpGatesFor): // A stand-in GALAXY (the model’s interface: byId Map + jumpGatesFor):
// sys-zenith (zenith, d=0.6) holds a gate to sys-apex (apex) — the // sys-zenith (zenith) holds a gate to sys-apex (apex) — the HOSTILE GROUND
// push-out neighbor — and a gate to sys-neutral (neutral: never a // (the bounty’s mark’s home, the escort’s trapped transport) — and a gate to
// patrol target) and to itself (same faction: never a target). // sys-neutral2 (neutral: never hostile ground for a faction board).
const galaxy = { const galaxy = {
byId: new Map([ byId: new Map([
[REC_ZENITH.id, { ...REC_ZENITH, d: 0.6 }], [REC_ZENITH.id, { ...REC_ZENITH, d: 0.6 }],
@ -301,45 +311,86 @@ console.log('== the patrol’s TARGET rule (the posting’s system, or a NEIGHBO
]), ]),
jumpGatesFor: (id) => { jumpGatesFor: (id) => {
const links = { const links = {
[REC_ZENITH.id]: [REC_APEX.id, 'sys-neutral2', REC_ZENITH.id], [REC_ZENITH.id]: [REC_APEX.id, 'sys-neutral2'],
[REC_APEX.id]: [REC_ZENITH.id],
}; };
return (links[id] ?? []).map((to) => galaxy.byId.get(to)).filter(Boolean); return (links[id] ?? []).map((to) => galaxy.byId.get(to)).filter(Boolean);
}, },
}; };
const rec = galaxy.byId.get(REC_ZENITH.id); const rec = galaxy.byId.get(REC_ZENITH.id);
assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 1), null), null, 'no galaxy → null (the posting’s own system stands — the fallback)'); // THE TARGET RULE — the patrol is ALWAYS the posting’s own system (the old
assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 2), { byId: new Map(), jumpGatesFor: () => [] }), null, 'no links → null (the posting’s own system stands)'); // neighbor draw is gone — the sighted rival is the enemy, the posting’s
// system is the ground; patrolTargetSystem stands home, always null).
assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 1), null), null, 'no galaxy → null (the posting’s own system stands — ALWAYS, the rule is fixed)');
assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 2), galaxy), null, 'a galaxy with a hostile neighbor → still null (the patrol stands home — the neighbor rule is gone)');
const neutral = { id: 'sys-neutral2', name: 'Drift Line', faction: null, d: 0.7 }; const neutral = { id: 'sys-neutral2', name: 'Drift Line', faction: null, d: 0.7 };
assert.equal(patrolTargetSystem(neutral, Rng.derive(SEED, 't', 3), galaxy), null, 'a NEUTRAL system never pushes (the pirate-voice posts stand local)'); assert.equal(patrolTargetSystem(neutral, Rng.derive(SEED, 't', 3), galaxy), null, 'a NEUTRAL system’s patrol stands home too');
const close = { id: 'sys-close', name: 'Close Post', faction: 'zenith', d: 0.1 }; // THE SIGHTED RIVAL — ONE seeded opposing faction, never both, never a
assert.equal(patrolTargetSystem(close, Rng.derive(SEED, 't', 4), galaxy), null, 'too close to home (< neighborMinD) → the posting’s own system stands'); // pirate: across seeds both rivals stand (the draw is real, not pinned),
// each posting’s pick is stable across calls (the same posting holds the
// A far FACTION system with a different-faction gate: the seeded draw // same enemy for the whole run — accepts, restarts, saves).
// (neighborChance) splits between the two — over many seeds BOTH const seen = new Set();
// outcomes must stand (the draw is real, not pinned), and the for (let i = 0; i < 128; i++) {
// neighbor, when chosen, is always a DIFFERENT non-neutral faction. const f = seededOpposingFaction('zenith', Rng.derive(SEED, 't', 100 + i));
const picks = new Set(); assert.ok(f === 'apex' || f === 'mindweave', `seed ${100 + i}: the sighted rival is one of the two rivals (never zenith, never a pirate)`);
for (let i = 0; i < 64; i++) { seen.add(f);
const out = patrolTargetSystem(rec, Rng.derive(SEED, 't', 100 + i), galaxy);
assert.ok(out === null || out.id === REC_APEX.id, `seed ${100 + i}: the target is null (the posting’s system) or the different-faction neighbor`);
if (out) picks.add(out.id);
else picks.add(rec.id);
} }
assert.deepEqual([...picks].sort(), [REC_APEX.id, rec.id].sort(), 'the neighbor draw is live: both the posting’s system and the neighbor stand across seeds'); assert.deepEqual([...seen].sort(), ['apex', 'mindweave'], 'both rivals stand across seeds (the draw is live — not pinned to one)');
const a1 = seededOpposingFaction('zenith', Rng.derive(SEED, 't', 42));
const a2 = seededOpposingFaction('zenith', Rng.derive(SEED, 't', 42));
assert.equal(a1, a2, 'a fixed stream → the same rival (deterministic per posting)');
// The POSTING’s fields carry the target (the board’s DESTINATION line // THE POSTING’S FIELDS — the patrol’s target = its OWN system, and its
// + the scene’s route establishment read them). // enemyFaction names the sighted rival (the board’s DESTINATION line + the
// scene’s roster read them).
const posts = sideQuestPostingsFor(SEED, rec, 'p1', galaxy).filter((p) => p.category === 'patrol'); const posts = sideQuestPostingsFor(SEED, rec, 'p1', galaxy).filter((p) => p.category === 'patrol');
assert.ok(posts.length >= 1, 'a faction system posts patrols'); assert.ok(posts.length >= 1, 'a faction system posts patrols');
for (const p of posts) { for (const p of posts) {
assert.equal(typeof p.targetSystemId, 'string', 'the patrol’s target system id stands'); assert.equal(p.targetSystemId, rec.id, 'the patrol’s target is the POSTING’S OWN system (the war model — in-system)');
assert.ok(p.targetSystemId === rec.id || p.targetSystemId === REC_APEX.id, 'the target is a real system (the posting’s own or the neighbor’s)'); assert.equal(p.targetSystemName, rec.name, '…on the posting’s own name');
assert.equal(typeof p.targetSystemName, 'string', 'the patrol’s target system name stands'); assert.ok(p.enemyFaction === 'apex' || p.enemyFaction === 'mindweave', 'the sighted rival is ONE of the two rivals (enemyFaction stands)');
assert.equal(p.targetSystemName, galaxy.byId.get(p.targetSystemId).name, 'the target name resolves to the target id');
} }
// Determinism — the same posting holds the same rival + target across calls.
const p1 = sideQuestPostingsFor(SEED, rec, 'p1', galaxy).find((p) => p.category === 'patrol');
const p2 = sideQuestPostingsFor(SEED, rec, 'p1', galaxy).find((p) => p.category === 'patrol');
assert.deepEqual(p2, p1, 'the patrol posting (target + enemyFaction) is stable across calls');
// THE HOSTILE GROUND — the bounty’s mark’s home + the escort’s transport’s
// ground: a NEIGHBORING system one of the two rivals holds (sys-apex here;
// sys-neutral2 is never a rival). A faction board with hostile ground posts
// the bounty + escort; without it, those slots stand EMPTY.
for (const f of ['bounty', 'escort']) {
const g = hostileNeighborSystem(rec, Rng.derive(SEED, 'g', f), galaxy);
assert.ok(g, `a hostile ground stands for a ${f} (a rival’s gate-reachable neighbor)`);
assert.equal(g.id, REC_APEX.id, `the ${f}'s ground is the rival’s system (sys-apex — the neutral neighbor is excluded)`);
assert.equal(g.faction, 'apex', '…held by that rival');
}
// A board whose only neighbor is neutral: the bounty + escort slots stand EMPTY.
const lonely = {
byId: new Map([
[REC_ZENITH.id, { ...REC_ZENITH, d: 0.6 }],
['sys-neutral2', { id: 'sys-neutral2', name: 'Drift Line', faction: null, d: 0.7 }],
]),
jumpGatesFor: () => [galaxy.byId.get('sys-neutral2')],
};
assert.equal(hostileNeighborSystem(rec, Rng.derive(SEED, 'g', 'lonely'), lonely), null, 'no rival holds a gate-reachable neighbor → no ground (the slot is empty)');
const lposts = sideQuestPostingsFor(SEED, rec, 'p1', lonely);
assert.ok(!lposts.some((p) => p.category === 'bounty' || p.category === 'escort'), '…and the board posts NEITHER a bounty NOR an escort (the slots stand empty — the patrol stands alone)');
assert.ok(lposts.some((p) => p.category === 'patrol'), '…the patrol still stands (its ground is the posting’s own system)');
// A board WITH hostile ground posts all three (the war model’s full set).
const full = sideQuestPostingsFor(SEED, rec, 'p1', galaxy);
for (const cat of ['patrol', 'bounty', 'escort']) {
assert.ok(full.some((p) => p.category === cat), `a hostile-ground board posts a ${cat}`);
}
const fb = full.find((p) => p.category === 'bounty');
assert.equal(fb.targetSystemId, REC_APEX.id, 'the bounty’s target = the mark’s home (the hostile neighbor)');
assert.equal(fb.enemyFaction, 'apex', '…its mark is a member of that rival');
const fe = full.find((p) => p.category === 'escort');
assert.equal(fe.enemySystemId, REC_APEX.id, 'the escort’s transport is trapped in the hostile neighbor');
assert.equal(fe.destSystemId, rec.id, '…and its DELIVERY is the board’s own system (the friendly territory)');
assert.equal(fe.enemyFaction, 'apex', '…hunted by its captors');
// The non-patrol categories (the stubs) carry no target fields. // The non-patrol categories (the stubs) carry no target fields.
setEnabled({ patrol: true, delivery: true }); setEnabled({ patrol: true, delivery: true });
try { try {
@ -504,6 +555,20 @@ console.log('== the patrol’s ENCOUNTERS — derivePatrolEncounters (the contac
const a6 = derivePatrolEncounters(SEED, 'sq-x-patrol-6', 'sys-x', wps5, 6, 1); const a6 = derivePatrolEncounters(SEED, 'sq-x-patrol-6', 'sys-x', wps5, 6, 1);
assert.equal(a6.xpScale, 0.7, 'a level-6 spec fixes the tier’s scale on the route (1, never re-drawn)'); assert.equal(a6.xpScale, 0.7, 'a level-6 spec fixes the tier’s scale on the route (1, never re-drawn)');
// --- THE WAR MODEL — the sighted rival’s OWN roster (the posting’s
// enemyFaction) — not the pirate pool, not both rivals, never neutral. ---
const zc = derivePatrolEncounters(SEED, 'sq-x-patrol-z', 'sys-x', wps5, 6, 1, 'zenith');
const zkeys = Object.keys(zc.roster);
assert.ok(zkeys.length >= 1, 'a sighted-rival contact’s roster stands');
assert.ok(zkeys.every((t) => t.startsWith('zenith_')), `the contact’s roster is the SIGHTED rival’s own fighters (got ${zkeys.join(',')})`);
assert.ok(!zkeys.includes('raider') && !zkeys.includes('bomber'), '…and NO pirate type (the war-model contact is faction, not pirate)');
const ac = derivePatrolEncounters(SEED, 'sq-x-patrol-a', 'sys-x', wps5, 6, 1, 'apex');
assert.ok(Object.keys(ac.roster).every((t) => t.startsWith('apex_')), `a different rival draws ITS OWN roster (got ${Object.keys(ac.roster).join(',')})`);
assert.equal(zc.enemyFaction, 'zenith', 'the spec records the sighted rival (the roster’s law rides the save)');
// A neutral patrol (no enemyFaction) keeps the pirate roster (unchanged).
const nc = derivePatrolEncounters(SEED, 'sq-x-patrol-n', 'sys-x', wps5, 6, 1, null);
assert.ok(!Object.keys(nc.roster).some((t) => /^(apex|zenith|mindweave)_/.test(t)), 'a neutral patrol’s roster carries NO faction type (the classic pirate mix)');
// --- THE POSITIONING (the contacts spread across the route) --------- // --- THE POSITIONING (the contacts spread across the route) ---------
const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]]; const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]];
assert.deepEqual(patrolEncounterPositions(line, 2), [0, 4], 'two contacts on a five-point line: the ENDS (the most separated pair — never a cluster)'); assert.deepEqual(patrolEncounterPositions(line, 2), [0, 4], 'two contacts on a five-point line: the ENDS (the most separated pair — never a cluster)');
@ -550,8 +615,8 @@ console.log('== the patrol’s ENCOUNTERS — derivePatrolEncounters (the contac
console.log('== the XP REWARD — patrolEncounterXp (the claim’s second payout) =='); console.log('== the XP REWARD — patrolEncounterXp (the claim’s second payout) ==');
{ {
const { patrolEncounterXp } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href); const { patrolEncounterXp } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
// The payout's XP = the XP WORTH of the patrol’s defeated contacts — // The payout’s XP = the XP WORTH of the patrol’s defeated contacts —
// the contacts × the wave's total × the roster’s weighted-average xp. // the contacts × the wave’s total × the roster’s weighted-average xp.
// The per-type XP is read from data/combat.json rather than pinned here // The per-type XP is read from data/combat.json rather than pinned here
// (the tuning breathes — a re-balance moves the numbers, not the rule). // (the tuning breathes — a re-balance moves the numbers, not the rule).
const { enemyTypeConfig } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href); const { enemyTypeConfig } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href);
@ -674,8 +739,8 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl
assert.deepEqual(backEnc.routeFor('sq-e-patrol-0').encounters, enc, 'the route’s encounters (at/cleared/roster/wave) round-trip the save'); assert.deepEqual(backEnc.routeFor('sq-e-patrol-0').encounters, enc, 'the route’s encounters (at/cleared/roster/wave) round-trip the save');
assert.equal(backEnc.encounterCleared('sq-e-patrol-0', 2), false, '…and the cleared mark is still idempotent after the restore'); assert.equal(backEnc.encounterCleared('sq-e-patrol-0', 2), false, '…and the cleared mark is still idempotent after the restore');
// The route's XP PAYOUT (the claim’s second reward — beside the // The route’s XP PAYOUT (the claim’s second reward — beside the
// credits): an explicit one (the accept's fix) stands on the record; // credits): an explicit one (the accept’s fix) stands on the record;
// a route without one (an old save) derives it from its own spec; // a route without one (an old save) derives it from its own spec;
// a route without encounters (the old shape) degrades to 0 (the // a route without encounters (the old shape) degrades to 0 (the
// credits stand alone). // credits stand alone).
@ -736,10 +801,10 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl
assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'], claimed: ['sq-a-patrol-0'], activeId: 'sq-a-patrol-0' }).activeId, null, 'a CLAIMED saved-active drops out — nothing stands'); assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'], claimed: ['sq-a-patrol-0'], activeId: 'sq-a-patrol-0' }).activeId, null, 'a CLAIMED saved-active drops out — nothing stands');
assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], activeId: 'sq-nowhere-0' }).activeId, 'sq-a-patrol-0', 'a foreign saved-active drops — the top accepted stands'); assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], activeId: 'sq-nowhere-0' }).activeId, 'sq-a-patrol-0', 'a foreign saved-active drops — the top accepted stands');
// The job-space guard — ids the save's galaxy never posted are dropped // The job-space guard — ids the save’s galaxy never posted are dropped
// (a hand-edited or foreign ledger degrades instead of sticking), // (a hand-edited or foreign ledger degrades instead of sticking),
// routes included. The space is PER-WORLD now: each system's SURFACE // routes included. The space is PER-WORLD now: each system’s SURFACE
// WORLDS (the fake galaxy's planets + free-space stations) × the slot // WORLDS (the fake galaxy’s planets + free-space stations) × the slot
// range × the four types. // range × the four types.
const fakeGalaxy = { const fakeGalaxy = {
records: RECORDS, records: RECORDS,
@ -756,7 +821,7 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl
}; };
const known = allSideQuestJobIds(fakeGalaxy); const known = allSideQuestJobIds(fakeGalaxy);
const [lo2, hi2] = sideQuestCountRange(); const [lo2, hi2] = sideQuestCountRange();
const WORLDS_PER_SYSTEM = 3; // 2 planets + 1 free-space station (the fake galaxy's content) const WORLDS_PER_SYSTEM = 3; // 2 planets + 1 free-space station (the fake galaxy’s content)
assert.equal(known.size, RECORDS.length * WORLDS_PER_SYSTEM * hi2 * SIDE_QUEST_CATEGORIES.length, 'the space = worlds × the slot range × the four types'); assert.equal(known.size, RECORDS.length * WORLDS_PER_SYSTEM * hi2 * SIDE_QUEST_CATEGORIES.length, 'the space = worlds × the slot range × the four types');
assert.ok(known.has('sq-sys-zenith-p1-patrol-0'), 'a real job id stands in the space'); assert.ok(known.has('sq-sys-zenith-p1-patrol-0'), 'a real job id stands in the space');
assert.ok(known.has('sq-sys-zenith-s1-patrol-0'), 'a free-space STATION’s job id stands in the space'); assert.ok(known.has('sq-sys-zenith-s1-patrol-0'), 'a free-space STATION’s job id stands in the space');
@ -781,12 +846,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 =='); console.log('== the scene’s seams — the QUESTS console + tracker see the accepted contracts ==');
{ {
// This section pins the PATROL's seams (setRoute / updatePatrol / // This section pins the PATROL’s seams (setRoute / updatePatrol /
// patrolProgress / the checklist). Stand PATROL alone so the board's // patrolProgress / the checklist). Stand PATROL alone so the board’s
// seeded single slot on the neutral record is guaranteed to be a patrol // 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). // (with the live set wider, that one slot could land on bounty/escort).
setEnabled({ patrol: true }); setEnabled({ patrol: true });
// The scene's seams (GameScene.questSnapshot / claimQuest / // The scene’s seams (GameScene.questSnapshot / claimQuest /
// setPriorityQuest / claimSideQuest) run over the SAME ledger + postings // setPriorityQuest / claimSideQuest) run over the SAME ledger + postings
// this file pins — called headless on a stub `this` (the jobboard’s // this file pins — called headless on a stub `this` (the jobboard’s
// makeScene pattern): the ledger is the real SideQuestState, the story // makeScene pattern): the ledger is the real SideQuestState, the story
@ -812,9 +877,9 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
questWindow: { refresh: () => state.refreshes++ }, questWindow: { refresh: () => state.refreshes++ },
state, state,
}; };
// The scene's seam methods under test — the REAL prototype functions, // The scene’s seam methods under test — the REAL prototype functions,
// bound onto the stub `this` (their internal `this.*` calls land on // bound onto the stub `this` (their internal `this.*` calls land on
// the stub's ledger/galaxy/ship seams). // the stub’s ledger/galaxy/ship seams).
for (const k of ['questSnapshot', '_sideQuestView', '_sideQuestPosting', 'claimQuest', 'claimSideQuest', 'setPriorityQuest', 'updatePatrol', '_firePatrolContact', 'patrolProgress']) { for (const k of ['questSnapshot', '_sideQuestView', '_sideQuestPosting', 'claimQuest', 'claimSideQuest', 'setPriorityQuest', 'updatePatrol', '_firePatrolContact', 'patrolProgress']) {
scene[k] = GameScene.prototype[k]; scene[k] = GameScene.prototype[k];
} }
@ -889,9 +954,9 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
call(GameScene.prototype.claimQuest, scene, job.id); call(GameScene.prototype.claimQuest, scene, job.id);
assert.equal(scene.state.credits, after, 'a second claim pays NOTHING (idempotent — the ledger holds the mark)'); assert.equal(scene.state.credits, after, 'a second claim pays NOTHING (idempotent — the ledger holds the mark)');
// The uncomplete refusal — the console's voice, no payout. (job2 comes // The uncomplete refusal — the console’s voice, no payout. (job2 comes
// from a DIFFERENT world's board — the per-world count is 1–3, so this // from a DIFFERENT world’s board — the per-world count is 1–3, so this
// world's single patrol isn't guaranteed a sibling; another world's // world’s single patrol isn’t guaranteed a sibling; another world’s
// board always stands its own patrols.) // board always stands its own patrols.)
let job2 = null; let job2 = null;
for (const wk of ['p2', 'p3', 'p4']) { for (const wk of ['p2', 'p3', 'p4']) {
@ -926,11 +991,11 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
assert.equal(sceneP.questState.priority, null, 'the claimed contract drops off the featured slot (the priority re-resolves)'); assert.equal(sceneP.questState.priority, null, 'the claimed contract drops off the featured slot (the priority re-resolves)');
assert.equal(sceneP.questState.priorityCleared, false, '…with the auto-assignment RE-ARMED (the claim re-arms)'); assert.equal(sceneP.questState.priorityCleared, false, '…with the auto-assignment RE-ARMED (the claim re-arms)');
// --- SET PRIORITY: the popup/console's door on a side contract -------- // --- SET PRIORITY: the popup/console’s door on a side contract --------
const sceneQ = makeScene(); const sceneQ = makeScene();
// The per-world count is 1–3, so one world may not stand three distinct // The per-world count is 1–3, so one world may not stand three distinct
// contracts — pool the world's own boards in order (p1, p2, …). The // contracts — pool the world’s own boards in order (p1, p2, …). The
// ledger doesn't care which world posted a contract (the id is the key). // ledger doesn’t care which world posted a contract (the id is the key).
const pool = []; const pool = [];
for (const wk of ['p1', 'p2', 'p3', 'p4']) { for (const wk of ['p1', 'p2', 'p3', 'p4']) {
for (const p of sideQuestPostingsFor(SEED, REC_NEUTRAL, wk)) { for (const p of sideQuestPostingsFor(SEED, REC_NEUTRAL, wk)) {
@ -975,7 +1040,7 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
sceneC.ship = { x: 0, y: 0, addCredits: (n) => { sceneC.state.credits += n; } }; sceneC.ship = { x: 0, y: 0, addCredits: (n) => { sceneC.state.credits += n; } };
sceneC.systemRecord = REC_NEUTRAL; sceneC.systemRecord = REC_NEUTRAL;
sceneC.combat = combat; sceneC.combat = combat;
const xpGrants = []; // the run's XP bank’s witness (the claim’s XP payout) const xpGrants = []; // the run’s XP bank’s witness (the claim’s XP payout)
combat.xp = { combat.xp = {
grant(n, src = 'kill') { grant(n, src = 'kill') {
xpGrants.push({ n: Math.max(0, Math.floor(Number(n) || 0)), src }); xpGrants.push({ n: Math.max(0, Math.floor(Number(n) || 0)), src });

View File

@ -420,7 +420,7 @@ export class CombatSystem {
* @returns {NeutralShip} * @returns {NeutralShip}
*/ */
spawnAlly(x, y, index, type = null, o = {}) { spawnAlly(x, y, index, type = null, o = {}) {
const cfg = neutralTypeConfig(type); const cfg = o && o.cfg && typeof o.cfg === 'object' ? o.cfg : neutralTypeConfig(type);
const base = config.section('combat.neutral', {}); const base = config.section('combat.neutral', {});
const emin = Math.max(1, Number(base.escortMin) || 10); const emin = Math.max(1, Number(base.escortMin) || 10);
const emax = Math.max(emin, Number(base.escortMax) || 15); const emax = Math.max(emin, Number(base.escortMax) || 15);

View File

@ -7,7 +7,9 @@
* WHAT IT DECIDES (GameScene does the spawning): * WHAT IT DECIDES (GameScene does the spawning):
* *
* WHEN — while the system allows encounters (NEUTRAL — faction null —, * WHEN — while the system allows encounters (NEUTRAL — faction null —,
* not home, no wave run in flight, the player upright; the scene * or a FACTION system with a war context (the rivals' rosters — the
* roll is an opposing faction, never pirates/neutrals)), not home,
* no wave run in flight, the player upright; the scene
* passes `ctx.allowed`, `encounterAllowed` is the pure gate) AND the * passes `ctx.allowed`, `encounterAllowed` is the pure gate) AND the
* scene's own quest hold stands off — GameScene ANDs in * scene's own quest hold stands off — GameScene ANDs in
* `_encountersLockedByQuest`, the 'Special Delivery' gate: the rolls * `_encountersLockedByQuest`, the 'Special Delivery' gate: the rolls
@ -47,7 +49,7 @@ function randInt(rng, lo, hi) {
return Math.min(b, a + Math.floor(rng() * (b - a + 1))); return Math.min(b, a + Math.floor(rng() * (b - a + 1)));
} }
const KINDS = ['pirates', 'neutrals', 'mixed']; const KINDS = ['pirates', 'neutrals', 'mixed', 'faction'];
/** /**
* The encounter kind at `weights` (data/combat.json → encounters.weights; * The encounter kind at `weights` (data/combat.json → encounters.weights;
@ -147,17 +149,43 @@ function drawRoster(band, n, rng) {
* — the band's weighted ROSTER drawn out to the pirate count (the tiers * — the band's weighted ROSTER drawn out to the pirate count (the tiers
* mix their types — a band without a roster leaves it unset and the scene * mix their types — a band without a roster leaves it unset and the scene
* picks as it always did). * picks as it always did).
*
* FACTION SPACE (`context`, new — the war-zones): `context` of
* `{ mode: 'faction', faction, enemies: [{ id, types: [...] }] }` (the
* scene builds it — this module stays roster-free): the roll is always a
* FACTION kind — one of the OTHER factions (never the controller, never
* pirates or neutrals), sized off the band's pirate count, its types
* drawn from THAT faction's roster (repeated for the count — the scene
* spawns `types[i % types.length]`). A context with no enemies (a
* roster-less world) degrades to the neutral roll (defensive).
*
* @param {object} cfg the encounters section (data/combat.json) * @param {object} cfg the encounters section (data/combat.json)
* @param {number} xpLevel the player's combat level (a fresh run: 0) * @param {number} xpLevel the player's combat level (a fresh run: 0)
* @param {() => number} rng * @param {() => number} rng
* @returns {{kind:string, pirates:number, neutrals:number, pirateTypes?:string[]}} * @param {object} [context] the space's encounter context (see above)
* @returns {{kind:string, pirates:number, neutrals:number, pirateTypes?:string[],
* faction?:string, count?:number, types?:string[]}}
*/ */
export function compose(cfg, xpLevel, rng) { export function compose(cfg, xpLevel, rng, context = null) {
cfg = cfg && typeof cfg === 'object' ? cfg : {}; if (
const band = bandFor(cfg.bands, xpLevel) || DEFAULT_BAND; context &&
context.mode === 'faction' &&
Array.isArray(context.enemies) &&
context.enemies.some((e) => e && Array.isArray(e.types) && e.types.length > 0)
) {
const live = context.enemies.filter((e) => e && Array.isArray(e.types) && e.types.length > 0);
const pick = live[Math.floor(rng() * live.length) % live.length];
const band = bandFor(cfg && cfg.bands, xpLevel);
const n = band && Array.isArray(band.pirates) ? randInt(rng, band.pirates[0], band.pirates[1]) : 1;
const types = [];
for (let i = 0; i < n; i++) types.push(pick.types[i % pick.types.length]);
return { kind: 'faction', faction: String(pick.id), count: n, types, pirates: n, neutrals: 0 };
}
const cfg2 = cfg && typeof cfg === 'object' ? cfg : {};
const band = bandFor(cfg2.bands, xpLevel) || DEFAULT_BAND;
const ri = (key, fb) => const ri = (key, fb) =>
Array.isArray(band[key]) ? randInt(rng, band[key][0], band[key][1]) : randInt(rng, fb[0], fb[1]); Array.isArray(band[key]) ? randInt(rng, band[key][0], band[key][1]) : randInt(rng, fb[0], fb[1]);
const kind = rollKind(cfg.weights, rng); const kind = rollKind(cfg2.weights, rng);
const types = (n) => drawRoster(band, n, rng); const types = (n) => drawRoster(band, n, rng);
if (kind === 'pirates') { if (kind === 'pirates') {
const n = ri('pirates', [1, 2]); const n = ri('pirates', [1, 2]);
@ -173,18 +201,21 @@ export function compose(cfg, xpLevel, rng) {
/** /**
* The pure gate (GameScene passes its state; the test passes fixtures): * The pure gate (GameScene passes its state; the test passes fixtures):
* encounters host in NEUTRAL (faction == null) non-HOME space, with no * encounters host in NEUTRAL (faction == null) non-HOME space — or in a
* FACTION system that carries a war context (the scene's
* `_encounterContext` — the controller's rivals with rosters; the roll
* there is always an opposing faction, never pirates/neutrals) — with no
* wave run in flight (a run that is already done/cancelled is over — * wave run in flight (a run that is already done/cancelled is over —
* its ships are spent), and the player upright (no spawn on a downed * its ships are spent), and the player upright (no spawn on a downed
* ship). `ship` must stand — there is no flying without one. * ship). `ship` must stand — there is no flying without one.
* @param {object} gate { ship, isHomeSystem, faction, waveRunLive, playerDown } * @param {object} gate { ship, isHomeSystem, faction, factionContext, waveRunLive, playerDown }
* @returns {boolean} * @returns {boolean}
*/ */
export function encounterAllowed(gate = {}) { export function encounterAllowed(gate = {}) {
return ( return (
!!gate.ship && !!gate.ship &&
gate.isHomeSystem !== true && gate.isHomeSystem !== true &&
gate.faction == null && (gate.faction == null || gate.factionContext != null) &&
gate.waveRunLive !== true && gate.waveRunLive !== true &&
gate.playerDown !== true gate.playerDown !== true
); );
@ -268,7 +299,7 @@ export class EncounterDirector {
this.nextRollAt = time + this._intervalMs(); this.nextRollAt = time + this._intervalMs();
if (this.rng() < this._chance()) { if (this.rng() < this._chance()) {
this.state = 'engaged'; this.state = 'engaged';
return compose(this.cfg, ctx.xpLevel ?? 0, this.rng); return compose(this.cfg, ctx.xpLevel ?? 0, this.rng, ctx.context ?? null);
} }
return null; return null;
} }

View File

@ -101,7 +101,7 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js'; import { Rng } from '../utils/Rng.js';
import { Tether } from '../tether/Tether.js'; import { Tether } from '../tether/Tether.js';
import { enemyTypeConfig, enemyTypeIds } from '../combat/EnemyTypes.js'; import { enemyTypeConfig, enemyTypeIds, factionTypeIds } from '../combat/EnemyTypes.js';
/** The four job types, canonical slot order (the data's category keys). */ /** The four job types, canonical slot order (the data's category keys). */
export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort']; export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort'];
@ -203,17 +203,32 @@ export function enabledSideQuestCategories(context = null) {
* @param {number|null} level the player's combat level * @param {number|null} level the player's combat level
* @returns {{ok:boolean, locked:boolean, minLevel:number}} * @returns {{ok:boolean, locked:boolean, minLevel:number}}
*/ */
/**
* Can this posting be ACCEPTED at this combat level? Two gates: the
* category's `minLevel` (the board's own demand), and the posting's
* `gateLevel` — the GATE ACCESS level for the contract's target system
* (bounty/escort in a hostile neighbor: the player's jump-gate capability
* must be unlocked INTO that system before the contract stands open; the
* pure rule is gateAccessLevel above). The scene's accept refuses with
* the data's deny toast, and the board renders the row LOCKED; a posting
* without a known level (a test, an old call) stands open.
* @param {object} posting sideQuestPostingsFor's shape
* @param {number|null} level the player's combat level
* @returns {{ok:boolean, locked:boolean, minLevel:number, gateLevel:number}}
*/
export function sideQuestAcceptable(posting, level = null) { export function sideQuestAcceptable(posting, level = null) {
const meta = sideQuestCategoryMeta(posting?.category); const meta = sideQuestCategoryMeta(posting?.category);
const minLevel = meta ? meta.minLevel : 0; const minLevel = meta ? meta.minLevel : 0;
const gateLevel = Math.max(0, Math.floor(Number(posting?.gateLevel) || 0));
const floor = Math.max(minLevel, gateLevel);
// UNKNOWN is not LEVEL 0. `null` / `undefined` (no XP bank in the boot, an // UNKNOWN is not LEVEL 0. `null` / `undefined` (no XP bank in the boot, an
// older caller that never reads one) stands the contract OPEN — Number(null) // older caller that never reads one) stands the contract OPEN — Number(null)
// is 0, which would otherwise lock every gated category on a missing read. // is 0, which would otherwise lock every gated category on a missing read.
if (level === null || level === undefined || !Number.isFinite(Number(level))) { if (level === null || level === undefined || !Number.isFinite(Number(level))) {
return { ok: true, locked: false, minLevel }; return { ok: true, locked: false, minLevel, gateLevel };
} }
const lvl = Math.floor(Number(level)); const lvl = Math.floor(Number(level));
return { ok: lvl >= minLevel, locked: lvl < minLevel, minLevel }; return { ok: lvl >= floor, locked: lvl < floor, minLevel, gateLevel };
} }
/** /**
@ -272,6 +287,7 @@ export function resolveSideQuestText(text, ctx = null) {
faction: ctx.faction ?? '', faction: ctx.faction ?? '',
issuer: ctx.issuer ?? '', issuer: ctx.issuer ?? '',
enemies: ctx.enemies ?? '', enemies: ctx.enemies ?? '',
enemy: ctx.enemy ?? '',
}; };
let out = text; let out = text;
for (const [key, value] of Object.entries(map)) { for (const [key, value] of Object.entries(map)) {
@ -280,7 +296,7 @@ export function resolveSideQuestText(text, ctx = null) {
} }
// Any placeholder that still stands (no value for this context) is // Any placeholder that still stands (no value for this context) is
// blanked — the board never ships a brace pair to the player. // blanked — the board never ships a brace pair to the player.
out = out.replace(/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/g, ''); out = out.replace(/\{system\}|\{faction\}|\{issuer\}|\{enemies\}|\{enemy\}/g, '');
return out; return out;
} }
@ -310,45 +326,117 @@ export function rollSideQuestReward(cat, d, r) {
/** /**
* The patrol's TARGET system — the system the route will be built in. * The patrol's TARGET system — the system the route will be built in.
* Default (returns null): the posting's own system. The NEIGHBOR rule * WAR MODEL (current): the patrol is ALWAYS the posting's own system —
* (data/sidequests.json → patrol): in a FACTION system at or beyond the * the board's own space, cleaned of the sighted rival's fighters (the
* diagonal mark (neighborMinD — the design's '40% from home'), a seeded * enemy is ONE seeded opposing faction — the posting's `enemyFaction`,
* draw (neighborChance) may stand a system the posting's jump gates * never both, never a pirate). Returns null = the posting system stands.
* reach (galaxy.jumpGatesFor) controlled by ANOTHER faction — the * (The old neighbor rule — a seeded draw could stand a gate-reachable
* push-them-out voice made a route. The draw rides the posting's own * other-faction system — is gone: the sighted faction is the enemy, the
* seeded stream, so the same posting stands with the same target for * posting's system is the ground.)
* the whole run (visits, scene restarts, saves); no galaxy, or no
* other-faction link, falls back to the posting system — never a null
* target.
* *
* @param {object} rec the posting's system record ({ id, faction, d, … }) * @param {object} rec the posting's system record ({ id, faction, d, … })
* @param {Rng} r the posting's seeded stream * @param {Rng} r the posting's seeded stream (unused — the rule is fixed)
* @param {object|null} [galaxy] the galaxy (jumpGatesFor + byId) — null in tests * @param {object|null} [galaxy] the galaxy (unused — kept for the call sites)
* @returns {{id:string, name:string}|null} null = the posting system stands * @returns {{id:string, name:string}|null} null = the posting system stands (always)
*/ */
export function patrolTargetSystem(rec, r, galaxy = null) { export function patrolTargetSystem(rec, r, galaxy = null) {
return null;
}
// ------------------------------------------------------------ the war model
//
// The faction-board's three contracts share the same geography: the
// board's own system (friendly) and the NEIGHBORING systems it controls
// through its jump gates — the hostile ground (the two opposing
// factions' systems a gate-hop away). Patrol fights the sighted rival
// at home; the bounty and the escort stand on that hostile ground.
// Everything here is seeded on the posting's own stream, so the same
// posting holds the same enemy / ground for the whole run (accepts,
// scene restarts, saves).
/** The two OPPOSING factions (the FACTIONS roster minus the controller). */
export function opposingFactions(faction) {
const f = String(faction ?? '').trim();
if (f === '') return [];
return FACTIONS.filter((x) => x !== f);
}
/**
* One SEEDED opposing faction (the patrol's sighted enemy, the bounty's
* target's faction when the board names it, …): a draw over the two
* rivals on the posting's own stream — deterministic per posting.
* @param {string} faction the controller (a roster id)
* @param {Rng} r the posting's seeded stream
* @returns {string|null} a rival id, or null (a non-roster controller / no stream)
*/
export function seededOpposingFaction(faction, r) {
const opp = opposingFactions(faction);
if (opp.length === 0) return null;
if (!r || typeof r.int !== 'function') return opp[0];
return opp[r.int(0, opp.length - 1) % opp.length];
}
/**
* The posting's HOSTILE GROUND — a NEIGHBORING system (a jump-gate link,
* galaxy.jumpGatesFor) controlled by ONE of the two opposing factions
* (the bounty's mark's home, the escort's trapped transport's ground),
* picked seeded on the posting's stream. Null when the controller is
* unknown, there is no galaxy (a test), or no rival holds a gate-reachable
* neighbor — the board then leaves that slot EMPTY (a contract with no
* ground is never offered).
* @param {object} rec the posting's system record
* @param {Rng} r the posting's seeded stream
* @param {object|null} [galaxy] the galaxy (jumpGatesFor) — null in tests
* @returns {?{id:string, name:string, faction:string}}
*/
export function hostileNeighborSystem(rec, r, galaxy = null) {
const faction = typeof rec?.faction === 'string' ? rec.faction : ''; const faction = typeof rec?.faction === 'string' ? rec.faction : '';
if (faction === '' || faction === 'neutral') return null; // neutral space patrols its own system if (faction === '' || faction === 'neutral') return null;
const minD = Number(config.get('sidequests.patrol.neighborMinD', 0.4)); if (!galaxy || typeof galaxy.jumpGatesFor !== 'function') return null;
if (!Number.isFinite(minD) || clamp01(Number(rec?.d)) < minD) return null; // the '40% from home' mark const opp = new Set(opposingFactions(faction));
const chance = Number(config.get('sidequests.patrol.neighborChance', 0.5));
if (!r.chance(clamp01(Number.isFinite(chance) ? chance : 0.5))) return null; // the seeded draw (the posting system stands)
if (!galaxy || typeof galaxy.jumpGatesFor !== 'function') return null; // no galaxy (a test) — the posting system stands
const cands = [];
let links = null; let links = null;
try { try {
links = galaxy.jumpGatesFor(rec.id); links = galaxy.jumpGatesFor(rec.id);
} catch { } catch {
links = null; // an unknown id (a hand-built test record) — the posting system stands links = null;
} }
const cands = [];
for (const nb of links ?? []) { for (const nb of links ?? []) {
if (!nb || typeof nb.id !== 'string' || nb.id === rec.id) continue; if (!nb || typeof nb.id !== 'string' || nb.id === rec.id) continue;
const nf = typeof nb.faction === 'string' ? nb.faction : ''; const nf = typeof nb.faction === 'string' ? nb.faction : '';
if (nf !== '' && nf !== 'neutral' && nf !== faction) cands.push(nb); // a NEIGHBOR controlled by ANOTHER faction if (opp.has(nf)) cands.push({ id: nb.id, name: String(nb.name ?? nb.id), faction: nf });
} }
if (cands.length === 0) return null; // no other-faction link — the posting system stands if (cands.length === 0) return null; // no rival holds a gate-reachable neighbor
const nb = r.pick(cands); if (!r || typeof r.int !== 'function') return cands[0];
return nb ? { id: nb.id, name: String(nb.name ?? nb.id) } : null; return cands[r.int(0, cands.length - 1) % cands.length];
}
/**
* The GATE ACCESS LEVEL — the combat level the player must hold before a
* contract whose TARGET is a faction-controlled system can be accepted
* (data/gates.json → jump.factionLock). The rule is TARGET-DRIVEN: any
* faction-controlled target demands the lock's minLevel, because reaching
* it is a gate hop the player must be capable of — whether the board that
* posts it stands in neutral space (the classic neutral→faction hop) or in
* a rival faction's space (the war-model bounty/escort, whose mark's home /
* transport's ground is a hostile faction system). A NEUTRAL target is never
* locked. This is the bounty/escort lock: the posting stands greyed until
* the player's gate capability reaches the target's system.
* @param {object} rec the ISSUING system record
* @param {object} target the target system record
* @returns {number} the level that opens the gate (0 = no lock stands)
*/
export function gateAccessLevel(rec, target) {
const neutral = (x) => {
const f = x?.faction;
return f == null || f === '' || f === 'neutral';
};
if (target == null || typeof target !== 'object') return 0;
if (neutral(target)) return 0; // a NEUTRAL target — no gate lock stands
const enabled = config.get('gates.jump.factionLock.enabled', true);
if (enabled === false) return 0;
const minLevel = Number(config.get('gates.jump.factionLock.minLevel', 5));
return Number.isFinite(minLevel) ? minLevel : 5;
} }
/** /**
@ -641,9 +729,23 @@ export function patrolXpScale(level = 0, hops = 0) {
* *
* @param {number} level the player's combat level (data/progression.json — js/combat/XpManager.js) * @param {number} level the player's combat level (data/progression.json — js/combat/XpManager.js)
* @param {number} hops the target system's jump-network depth from home (0 = home) * @param {number} hops the target system's jump-network depth from home (0 = home)
* @param {string|null} [enemyFaction] the WAR MODEL's sighted rival (the
* posting's enemyFaction): its OWN roster (combat.factions' type ids,
* equal weight, validated) stands for the data band — the patrol fights
* the faction it sighted, not a pirate
* @returns {Record<string, number>} the roster's type → weight table (never empty) * @returns {Record<string, number>} the roster's type → weight table (never empty)
*/ */
export function patrolEnemyRoster(level, hops) { export function patrolEnemyRoster(level, hops, enemyFaction = null) {
const opp = String(enemyFaction ?? '').trim();
if (opp !== '') {
const valid = new Set(enemyTypeIds());
const types = (factionTypeIds(opp) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t));
if (types.length > 0) {
const mix = {};
for (const t of types) mix[t] = 1;
return mix;
}
}
const band = patrolEncounterBand(level, hops); const band = patrolEncounterBand(level, hops);
const fallback = rosterWeights(config.get('sidequests.patrol.encounters.fallbackEnemyTypes', null), null) ?? establishedRoster(); const fallback = rosterWeights(config.get('sidequests.patrol.encounters.fallbackEnemyTypes', null), null) ?? establishedRoster();
if (!band) return fallback; if (!band) return fallback;
@ -766,9 +868,12 @@ export function patrolEncounterPositions(waypoints, count) {
* @param {Array<[number, number]>} waypoints the route's waypoints * @param {Array<[number, number]>} waypoints the route's waypoints
* @param {number} level the player's combat level at the accept * @param {number} level the player's combat level at the accept
* @param {number} hops the target system's jump-network depth from home * @param {number} hops the target system's jump-network depth from home
* @param {string|null} [enemyFaction] the WAR MODEL's sighted rival (the
* posting's enemyFaction) — its roster stands for the data band
* (patrolEnemyRoster's first rule)
* @returns {?{at:number[], cleared:number[], roster:string[], wave:object}} null when the feature is off or the route is empty * @returns {?{at:number[], cleared:number[], roster:string[], wave:object}} null when the feature is off or the route is empty
*/ */
export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, level, hops) { export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, level, hops, enemyFaction = null) {
if (config.get('sidequests.patrol.encounters.enabled', true) === false) return null; if (config.get('sidequests.patrol.encounters.enabled', true) === false) return null;
if (!Array.isArray(waypoints) || waypoints.length === 0) return null; if (!Array.isArray(waypoints) || waypoints.length === 0) return null;
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', String(jobId), 'encounters'); const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', String(jobId), 'encounters');
@ -776,7 +881,8 @@ export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, l
return { return {
at: patrolEncounterPositions(waypoints, count), at: patrolEncounterPositions(waypoints, count),
cleared: [], cleared: [],
roster: patrolEnemyRoster(level, hops), roster: patrolEnemyRoster(level, hops, enemyFaction),
enemyFaction: enemyFaction || null,
wave: patrolEncounterWave(level, hops), wave: patrolEncounterWave(level, hops),
xpScale: patrolXpScale(level, hops), xpScale: patrolXpScale(level, hops),
}; };
@ -1112,28 +1218,43 @@ export function deriveBountyPlan(seed, jobId, targetSystemId, level = 0, opts =
} }
const valid = new Set(enemyTypeIds()); const valid = new Set(enemyTypeIds());
const declared = String(opts?.craft ?? '').trim(); const declared = String(opts?.craft ?? '').trim();
// THE WAR MODEL — a FACTION hunt (opts.faction = the mark's own
// faction): the escorts + the mark's frame draw from THAT faction's
// roster (combat.factions' type ids, validated) — the mark is a member
// of the faction that holds the target system, and its pickets are its
// own fighters. A faction without a registered roster (or a neutral
// hunt) keeps the data's pirate frames.
const factionTypes = (String(opts?.faction ?? '').trim() !== '' ? (factionTypeIds(opts.faction) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t)) : []);
const frames = (Array.isArray(rules.bountyWave.eliteFrames) ? rules.bountyWave.eliteFrames : BOUNTY_FALLBACK_FRAMES) const frames = (Array.isArray(rules.bountyWave.eliteFrames) ? rules.bountyWave.eliteFrames : BOUNTY_FALLBACK_FRAMES)
.map((t) => String(t).trim()) .map((t) => String(t).trim())
.filter((t) => valid.has(t)); .filter((t) => valid.has(t));
const framePool = factionTypes.length > 0 ? factionTypes : frames;
let eliteType = declared !== '' && valid.has(declared) ? declared : null; let eliteType = declared !== '' && valid.has(declared) ? declared : null;
if (!eliteType) { if (!eliteType) {
const pool = frames.length > 0 ? frames : BOUNTY_FALLBACK_FRAMES.filter((t) => valid.has(t)); const pool = framePool.length > 0 ? framePool : BOUNTY_FALLBACK_FRAMES.filter((t) => valid.has(t));
eliteType = pool.length > 0 ? String(r.pick(pool)) : ESTABLISHED_ENEMY_TYPES[0]; eliteType = pool.length > 0 ? String(r.pick(pool)) : ESTABLISHED_ENEMY_TYPES[0];
} }
const raiderType = valid.has(String(rules.bountyWave.raiderType ?? '')) const raiderType = factionTypes.length > 0 ? String(factionTypes[0]) : (valid.has(String(rules.bountyWave.raiderType ?? ''))
? String(rules.bountyWave.raiderType) ? String(rules.bountyWave.raiderType)
: ESTABLISHED_ENEMY_TYPES[0]; : ESTABLISHED_ENEMY_TYPES[0]);
const raiders = Math.max(0, Math.floor(numOr(rules.bountyWave.raiders, rules.bountyWave.total - 1))); const raiders = Math.max(0, Math.floor(numOr(rules.bountyWave.raiders, rules.bountyWave.total - 1)));
// The picket waves ride the mark's OWN faction (the war model) — its
// fighters screen the lanes, equal weight over the roster.
const skirmishWave =
factionTypes.length > 0
? { ...rules.skirmishWave, roster: Object.fromEntries(factionTypes.map((t) => [t, 1])) }
: { ...rules.skirmishWave };
return { return {
legs, legs,
bountyAt, bountyAt,
pickets, pickets,
band: rules.id, band: rules.id,
faction: factionTypes.length > 0 ? String(opts.faction) : null,
raiders, raiders,
raiderType, raiderType,
eliteType, eliteType,
mark: bountyMark(opts?.mark), mark: bountyMark(opts?.mark),
skirmishWave: { ...rules.skirmishWave }, skirmishWave,
bountyWave: { ...rules.bountyWave, total: Math.max(1, raiders + 1) }, bountyWave: { ...rules.bountyWave, total: Math.max(1, raiders + 1) },
xpScale: Math.max(0, numOr(rules.xpScale, 1)), xpScale: Math.max(0, numOr(rules.xpScale, 1)),
visitRadius: rules.visitRadius, visitRadius: rules.visitRadius,
@ -1548,16 +1669,36 @@ export function parseSideQuestId(id) {
function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier = null, world = 'p1') { function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier = null, world = 'p1') {
const meta = sideQuestCategoryMeta(cat); const meta = sideQuestCategoryMeta(cat);
if (!meta) return null; if (!meta) return null;
const factionCtx = ctx.context === 'faction';
// ESCORT FEASIBILITY — the board only posts an escort contract that is // THE WAR MODEL (a faction board — the contract's ground):
// actually COMPLETABLE: a NEUTRAL destination 1–2 gate hops away (the // patrol — the sighted enemy: ONE seeded opposing faction (the posting's
// contract's signature, fixed at the accept). When the system has no // own system is the ground — patrolTargetSystem stands home);
// gate-reachable neutral world the escort slot stands EMPTY (a contract // bounty — the mark's home: a NEIGHBORING system a gate-hop away held by
// that can't be delivered is never offered — the player can't accept a // one of the two rivals (hostileNeighborSystem); no rival ground
// broken escort). The check is per-contract (seeded on the posting's id), // ⇒ the slot stands EMPTY;
// so it agrees EXACTLY with the destination the accept will fix (a signed // escort — the trapped transport's ground: the same hostile neighbor;
// contract is a signed contract). // the convoy's destination is the board's OWN system (the
if (cat === 'escort' && galaxy) { // friendly territory the contract is issued from).
// The draw rides the posting's seeded stream, so the same posting holds
// the same enemy / ground for the whole run (accepts, restarts, saves).
let enemyFaction = null;
let ground = null;
if (factionCtx && cat === 'patrol') {
enemyFaction = seededOpposingFaction(rec.faction, r);
} else if (factionCtx && (cat === 'bounty' || cat === 'escort')) {
ground = hostileNeighborSystem(rec, r, galaxy);
if (!ground) return null; // no rival holds gate-reachable ground — the slot is empty
enemyFaction = ground.faction;
}
// ESCORT FEASIBILITY (the NEUTRAL board — the classic contract): only an
// escort 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. (The FACTION escort's feasibility is the hostile ground above
// — its destination is the board's own system, always deliverable.)
if (cat === 'escort' && !factionCtx && galaxy) {
const postingId = `sq-${rec.id}-${world}-${cat}-${slot}`; const postingId = `sq-${rec.id}-${world}-${cat}-${slot}`;
if (!escortDestinationFor(seed, postingId, rec.id, galaxy)) return null; if (!escortDestinationFor(seed, postingId, rec.id, galaxy)) return null;
} }
@ -1582,14 +1723,26 @@ function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier =
} }
} }
const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, ctx); // The posting's COPY context: the board's own (system/faction/issuer/
// enemies) plus `enemy` — the ONE named rival this posting is about
// (the sighted patrol faction, the mark's faction, the transport's
// captors — the pools' {enemy} placeholder resolves against it).
const labels = sideQuestFactionLabels();
const pctx = { ...ctx, enemy: enemyFaction ? (labels[enemyFaction] ?? enemyFaction) : '' };
const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, pctx);
// The row's issuer line: the pool's own name (the neutral pools' — // The row's issuer line: the pool's own name (the neutral pools' —
// “The Harbormaster”), or the faction's board name (the faction pools' // “The Harbormaster”), or the faction's board name (the faction pools'
// {issuer} placeholder — the data's sidequests.factions table), so a // {issuer} placeholder — the data's sidequests.factions table), so a
// faction's postings read “ISSUER — ZENITH CONCORD”, not “The Board”. // faction's postings read “ISSUER — ZENITH CONCORD”, not “The Board”.
const issuerRaw = v?.issuer ?? (ctx.issuer || 'The Board'); const issuerRaw = v?.issuer ?? (ctx.issuer || 'The Board');
const issuer = resolveSideQuestText(issuerRaw, ctx) || 'The Board'; const issuer = resolveSideQuestText(issuerRaw, pctx) || 'The Board';
const description = resolveSideQuestText(v?.body ?? '', ctx); const description = resolveSideQuestText(v?.body ?? '', pctx);
// The GATE ACCESS lock — the contract's target system must be
// gate-reachable at the player's level (bounty/escort in hostile ground;
// 0 = the hop is never locked — the scene's gateAccessLevel pure rule).
const gateLevel = ground ? gateAccessLevel(rec, ground) : 0;
return { return {
id: `sq-${rec.id}-${world}-${cat}-${slot}`, // stable for the run (save-safe ledger key) id: `sq-${rec.id}-${world}-${cat}-${slot}`, // stable for the run (save-safe ledger key)
@ -1608,19 +1761,36 @@ function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier =
// witness, its payout rule) is built WITH the type and rides here — // witness, its payout rule) is built WITH the type and rides here —
// the UI already renders objective.text when one stands. // the UI already renders objective.text when one stands.
objective: { type: cat }, objective: { type: cat },
// The PATROL's target system — the route's system (patrolTargetSystem: // THE WAR MODEL — the contract's named rival + hostile ground (a
// the posting system, or a NEIGHBOR the jump gates reach when the // faction board's patrol/bounty/escort; neutral postings carry none):
// system's faction, diagonal, and seeded draw agree). Patrol-only — // enemyFaction — the sighted / target / captor faction (one of the
// the other categories' routes (when they land) carry their own // board's two rivals; the roster the combat draws);
// shape. // gateLevel — the jump-gate level that opens the hop to the
// target system (the accept's lock, beside minLevel);
// targetSystem* — the PATROL's ground (its own system —
// patrolTargetSystem) or the BOUNTY's mark's home
// (the hostile neighbor);
// enemySystem* — the ESCORT's trapped transport's ground (the
// hostile neighbor — the convoy's RENDEZVOUS system;
// its destination is the board's own system, here).
...(enemyFaction ? { enemyFaction } : {}),
...(gateLevel > 0 ? { gateLevel } : {}),
...(cat === 'patrol' ? patrolTargetFields(rec, r, galaxy) : {}), ...(cat === 'patrol' ? patrolTargetFields(rec, r, galaxy) : {}),
// The BOUNTY HUNT's DOSSIER (data → descriptions.neutral.bounty[]) — the ...(cat === 'bounty'
// wanted leader's name/epithet/charges and the CRAFT its own story names ? {
// (`craft`, validated against combat.enemyTypes; null on a pool entry that dossier: buildBountyDossier(v, pctx),
// doesn't declare one, where the plan picks a frame seeded). The hunt's targetSystemId: ground ? ground.id : rec.id,
// plan (which leg holds the mark) is NOT here — it rolls at the accept and targetSystemName: ground ? ground.name : String(rec.name ?? rec.id),
// rides the route record, never the board. }
...(cat === 'bounty' ? { dossier: buildBountyDossier(v, ctx), targetSystemId: rec.id, targetSystemName: String(rec.name ?? rec.id) } : {}), : {}),
...(cat === 'escort' && factionCtx && ground
? {
enemySystemId: ground.id,
enemySystemName: ground.name,
destSystemId: rec.id, // the friendly territory: the board's own system
destSystemName: String(rec.name ?? rec.id),
}
: {}),
}; };
} }
@ -1871,16 +2041,82 @@ export function escortDestinationFor(seed, jobId, originSystemId, galaxy = null)
}; };
} }
/**
* The WAR-MODEL ESCORT's DESTINATION OBJECT — the delivery world INSIDE the
* board's OWN (friendly) system: a planet first, then a free-space station,
* then any laid-down object, picked seeded on its own labelled fork (seed,
* 'sidequests', <friendly>, 'escort', <jobId>, 'friend-dest') so the same
* contract always points at the same world across re-accepts, jumps, and
* saves. (The convoy's RENDEZVOUS stands in the hostile neighbor; its
* DELIVERY is this friendly world — 0 hops, the board's own territory.)
* @param {string|number} seed the galaxy's seed
* @param {string} jobId the accepted contract's id
* @param {string} friendlySystemId the board's own (friendly) system
* @param {object|null} [galaxy] the galaxy (ensureContent)
* @returns {?{hops:number, path:string[], destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string}}
*/
export function escortFriendlyDestination(seed, jobId, friendlySystemId, galaxy = null) {
const id = String(friendlySystemId ?? '').trim();
if (!id || !galaxy || typeof galaxy.ensureContent !== 'function') return null;
const rec = galaxy.byId?.get?.(id) ?? null;
const r = Rng.derive(seed, seedLabel(), id, 'escort', String(jobId), 'friend-dest');
let content = null;
try { content = galaxy.ensureContent(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: 0,
path: [id],
destSystemId: id,
destSystemName: String(rec?.name ?? 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 → * The escort's mixed-fighter ROSTER at the player's combat level (data →
* escort.encounters.bands: a { type: weight } table over combat.enemyTypes * 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 * 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 * 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. * type). A band that doesn't stand falls back to the data's fallbackRoster.
* THE WAR MODEL (a faction escort — `enemyFaction`, the captors): the
* lane's waves are THAT faction's fighters (its registered roster, equal
* weight) — a trapped {faction} transport is hunted by its captors, not
* by pirates; a neutral escort (no faction) keeps the data bands.
* @param {number} level the player's combat level * @param {number} level the player's combat level
* @param {string|null} [enemyFaction] the captors' faction (the posting's enemyFaction)
* @returns {{roster: Record<string,number>, total:number, maxConcurrent:number, replacePerKill:number}} * @returns {{roster: Record<string,number>, total:number, maxConcurrent:number, replacePerKill:number}}
*/ */
export function escortWaveRoster(level = 0) { export function escortWaveRoster(level = 0, enemyFaction = null) {
const valid = new Set(enemyTypeIds());
const factionMix = (String(enemyFaction ?? '').trim() !== '' ? (factionTypeIds(enemyFaction) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t)) : []);
if (factionMix.length > 0) {
const roster = {};
for (const t of factionMix) roster[t] = 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))),
};
}
const bands = config.get('sidequests.escort.encounters.bands', null); const bands = config.get('sidequests.escort.encounters.bands', null);
const lv = Math.max(0, Math.floor(Number(level) || 0)); const lv = Math.max(0, Math.floor(Number(level) || 0));
let band = null; let band = null;
@ -1892,7 +2128,6 @@ export function escortWaveRoster(level = 0) {
} }
} }
const raw = band?.roster ?? config.get('sidequests.escort.encounters.fallbackRoster', { raider: 1 }); const raw = band?.roster ?? config.get('sidequests.escort.encounters.fallbackRoster', { raider: 1 });
const valid = new Set(enemyTypeIds());
const roster = {}; const roster = {};
if (raw && typeof raw === 'object' && !Array.isArray(raw)) { if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
for (const [t, w] of Object.entries(raw)) { for (const [t, w] of Object.entries(raw)) {
@ -1974,6 +2209,9 @@ function normalizeEscort(raw) {
transportShields: Math.min(Math.max(0, Math.floor(num(raw?.transportShields, 500))), Math.max(0, Math.floor(num(raw?.transportMaxShields, 500)))), 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))))), escortCount: Math.min(eHi, Math.max(eLo, Math.floor(num(raw?.escortCount, Math.floor((eLo + eHi) / 2))))),
name: String(raw?.name ?? ''), name: String(raw?.name ?? ''),
// THE WAR MODEL — the captors' faction (the lane's roster; a neutral
// escort / an old save carries none → '' → the classic pirate-lane law).
enemyFaction: (() => { const f = String(raw?.enemyFaction ?? '').trim(); return f; })(),
}; };
} }

View File

@ -7,7 +7,8 @@
* the BEST reputation; every write is clamped to the scale; * the BEST reputation; every write is clamped to the scale;
* - a place the player has no standing with is NEUTRAL (0) — unless it * - a place the player has no standing with is NEUTRAL (0) — unless it
* is controlled by a faction the player has standing with (the * is controlled by a faction the player has standing with (the
* FACTION CHECK below — a placeholder until factions land); * FACTION CHECK below — standing is stored PER FACTION in this
* instance; a player starts NEUTRAL with every faction);
* - the ONE exception: the player's home world is ALWAYS +20 (the * - the ONE exception: the player's home world is ALWAYS +20 (the
* `home` value) — pinned. Nothing sets, changes, or clears it. * `home` value) — pinned. Nothing sets, changes, or clears it.
* *
@ -17,11 +18,11 @@
* js/galaxy/Galaxy.js, data/factions.json). standingFor() resolves it: * js/galaxy/Galaxy.js, data/factions.json). standingFor() resolves it:
* a place owned by a faction falls back to the player's standing WITH * a place owned by a faction falls back to the player's standing WITH
* THAT FACTION (factionStanding()) before settling on neutral. The * THAT FACTION (factionStanding()) before settling on neutral. The
* per-faction standing itself is still a marked `TODO(factions)` stub * per-faction standing lives in this instance (this.factions —
* (factionStanding() returns null — the player's real relations with * setFaction()/changeFaction() are the seams the later influence
* apex/mindweave/zenith land there), so a faction-controlled place * mechanics will drive; nothing sets it yet, so every faction-controlled
* reads neutral today (home world: +20); the resolution order below is * place reads neutral today — home world: +20). The resolution order
* already the final one. * below is the final one.
* *
* PLACE IDENTITY (the reputation keys — stable per seed, from the * PLACE IDENTITY (the reputation keys — stable per seed, from the
* generator): planets `<systemId>-p<ordinal>`, settlements * generator): planets `<systemId>-p<ordinal>`, settlements
@ -43,6 +44,12 @@
* rep.get('S000012-p3'); // → +5 (later mechanics use) * rep.get('S000012-p3'); // → +5 (later mechanics use)
* rep.standingFor(settlement); // {id, owner} — the * rep.standingFor(settlement); // {id, owner} — the
* // faction-check-aware read * // faction-check-aware read
* rep.setFaction('apex', 7); // the FACTION standing seam
* rep.factionStanding('apex'); // → 7 (a faction-owned world
* // reads this standing)
* rep.factionStanding('zenith'); // → null (no standing yet —
* // standingFor() falls
* // through to neutral)
*/ */
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
@ -74,6 +81,12 @@ export class Reputation {
this.home = clamp(this.home, this.min, this.max); this.home = clamp(this.home, this.min, this.max);
/** @type {Map<string, number>} place key → standing (the home world never appears here) */ /** @type {Map<string, number>} place key → standing (the home world never appears here) */
this.places = new Map(); this.places = new Map();
/** @type {Map<string, number>} faction id → standing WITH that faction
* (the FACTION CHECK — standingFor() hands a faction-owned place this
* standing; absent = no standing yet, so a new player starts NEUTRAL
* (0) with every faction; setFaction()/changeFaction() are the seams
* the later influence mechanics will drive) */
this.factions = new Map();
/** @type {Map<string, number>} place key → sub-point CREDIT (0 ≤ v < 1) — /** @type {Map<string, number>} place key → sub-point CREDIT (0 ≤ v < 1) —
* the fraction of a point creditFraction() hasn't paid out yet (the * the fraction of a point creditFraction() hasn't paid out yet (the
* per-sale 2% is +0.4 — five sales' worth credits to a +2) */ * per-sale 2% is +0.4 — five sales' worth credits to a +2) */
@ -107,9 +120,10 @@ export class Reputation {
* 1. the home world → always the pinned +20 (nothing changes it); * 1. the home world → always the pinned +20 (nothing changes it);
* 2. an explicitly stored standing (set()/change() — the seam the * 2. an explicitly stored standing (set()/change() — the seam the
* influence mechanics land on); * influence mechanics land on);
* 3. the FACTION CHECK — the place's `owner` (the settlement's * 3. the FACTION CHECK — the place's `owner` (a faction id, from the
* reserved seam, null until factions exist) → the player's * settlement record) → the player's standing WITH THAT FACTION
* standing with that faction (factionStanding()); * (factionStanding() — stored per faction in this instance;
* neutral (0) when nothing has set it — a new player's case);
* 4. neutral. * 4. neutral.
* *
* @param {object|string} place a place record ({ id|key, owner }) — a * @param {object|string} place a place record ({ id|key, owner }) — a
@ -210,25 +224,65 @@ export class Reputation {
} }
/** /**
* THE FACTION CHECK (placeholder — factions are a later layer). * THE FACTION CHECK — the player's standing WITH A FACTION.
* *
* Returns the player's standing WITH A FACTION, so standingFor() can * Standing is stored PER FACTION (this.factions — setFaction() /
* hand a faction-controlled place that standing instead of neutral. * changeFaction() are the seams the later influence mechanics use; the
* Until factions exist no settlement is owned (`owner: null`) and * "ways to influence reputation" will mostly move these). A faction the
* nothing consults this — it is deliberately a stub, marked here so * player has no standing with answers null and standingFor() settles on
* the factions work knows exactly what to fill in: * neutral (0) — so a NEW PLAYER starts NEUTRAL with every faction
* * (nothing has been set yet). Stored values are clamped to this scale
* TODO(factions): standing per faction (the player's real * (the same one the place standings use).
* relationships — the "ways to influence reputation" will mostly
* move THESE), clamped to this scale; null when the player has no
* standing with `factionId`.
* *
* @param {string} factionId the controlling faction (settlement.owner) * @param {string} factionId the controlling faction (settlement.owner)
* @returns {number|null} the standing with the faction, or null (no * @returns {number|null} the standing with the faction, or null (no
* standing — or factions don't exist yet) * standing yet — standingFor() falls through to neutral)
*/ */
factionStanding(factionId) { factionStanding(factionId) {
return null; // TODO(factions): see above — the placeholder on purpose. if (typeof factionId !== 'string' || factionId.length === 0) return null;
const v = this.factions.get(factionId);
return v === undefined ? null : v;
}
/**
* Explicitly set the standing WITH A FACTION (the seam the later
* influence mechanics can use directly). Same contract as set() —
* clamped to the scale, rounded to the integer step, non-numeric input
* ignored — but keyed by faction id (not a place key); the home world
* is unrelated to faction standings.
*
* @param {string} factionId the faction (apex / mindweave / zenith)
* @param {number} value the new standing (clamped to min…max)
* @returns {boolean} true when the standing changed
*/
setFaction(factionId, value) {
if (typeof factionId !== 'string' || factionId.length === 0) return false;
const next = this._toStanding(value);
if (next === null) return false;
const prev = this.factions.get(factionId);
if (prev === next) return false;
this.factions.set(factionId, next);
return true;
}
/**
* Shift the standing WITH A FACTION by `delta` (positive or negative),
* from the faction's current standing (neutral (0) when unset),
* clamped to the scale. Same contract as change(), keyed by faction id.
*
* @param {string} factionId the faction (apex / mindweave / zenith)
* @param {number} delta the change (e.g. +5 for a good deed, −5 for a war)
* @returns {boolean} true when the standing changed
*/
changeFaction(factionId, delta) {
if (typeof factionId !== 'string' || factionId.length === 0) return false;
const d = Number(delta);
if (!Number.isFinite(d)) return false;
const base = this.factions.get(factionId) ?? this.neutral;
const next = Math.round(clamp(base + d, this.min, this.max));
if (next === base) return false;
this.factions.set(factionId, next);
return true;
} }
/** /**
@ -270,23 +324,37 @@ export class Reputation {
if (k === HOME_KEY) continue; // defensive — it can't be stored anyway if (k === HOME_KEY) continue; // defensive — it can't be stored anyway
places[k] = v; places[k] = v;
} }
const factions = {};
for (const [k, v] of this.factions) factions[k] = v;
const pending = {}; const pending = {};
for (const [k, v] of this.pending) { for (const [k, v] of this.pending) {
if (k === HOME_KEY) continue; // defensive — it never credits if (k === HOME_KEY) continue; // defensive — it never credits
pending[k] = v; pending[k] = v;
} }
return { min: this.min, max: this.max, neutral: this.neutral, home: this.home, places, pending }; return { min: this.min, max: this.max, neutral: this.neutral, home: this.home, places, factions, pending };
} }
/** /**
* Restore state saved with toJSON(). Missing/corrupt pieces fall back * Restore state saved with toJSON(). Missing/corrupt pieces fall back
* to the scale defaults; out-of-range values are clamped; non-numeric * to the scale defaults; out-of-range values are clamped; non-numeric
* or home-world entries are dropped. A record from before reputation * or home-world entries are dropped. A record from before reputation
* existed (no `reputation` field) loads as a fresh, all-neutral state. * existed (no `reputation` field) loads as a fresh, all-neutral state;
* a record from before the FACTION CHECK existed (no `factions` field)
* loads with every faction standing unset (neutral — a new player's
* case).
*/ */
static fromJSON(data) { static fromJSON(data) {
const d = data && typeof data === 'object' ? data : {}; const d = data && typeof data === 'object' ? data : {};
const rep = new Reputation(d.min, d.max, d.neutral, d.home); const rep = new Reputation(d.min, d.max, d.neutral, d.home);
const fac = d.factions;
if (fac && typeof fac === 'object') {
for (const [k, v] of Object.entries(fac)) {
if (typeof k !== 'string' || k.length === 0) continue;
const n = Math.round(Number(v));
if (!Number.isFinite(n)) continue;
rep.factions.set(k, clamp(n, rep.min, rep.max));
}
}
const places = d.places; const places = d.places;
if (places && typeof places === 'object') { if (places && typeof places === 'object') {
for (const [k, v] of Object.entries(places)) { for (const [k, v] of Object.entries(places)) {

View File

@ -13,7 +13,7 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js'; import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js'; import { Ship } from '../entities/Ship.js';
import { EnemyShip } from '../combat/EnemyShip.js'; import { EnemyShip } from '../combat/EnemyShip.js';
import { enemySheetList, enemyTypeIds, neutralTypeIds, factionIds, transportSheet } from '../combat/EnemyTypes.js'; import { enemySheetList, enemyTypeIds, neutralTypeIds, factionIds, factionTypeIds, enemyTypeConfig, transportSheet } from '../combat/EnemyTypes.js';
import { Planet } from '../entities/Planet.js'; import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js'; import { Station } from '../entities/Station.js';
@ -85,6 +85,7 @@ import {
bountyRouteComplete, bountyRouteComplete,
bountyText, bountyText,
escortDestinationFor, escortDestinationFor,
escortFriendlyDestination,
escortEncounterXp, escortEncounterXp,
escortWaveRoster, escortWaveRoster,
} from '../quests/SideQuests.js'; } from '../quests/SideQuests.js';
@ -2895,18 +2896,24 @@ export class GameScene extends Phaser.Scene {
// THE ENCOUNTER DIRECTOR (data/combat.json → encounters): a hit // THE ENCOUNTER DIRECTOR (data/combat.json → encounters): a hit
// returns the composition to spawn — off-screen, streamed in by // returns the composition to spawn — off-screen, streamed in by
// their own AI (the clock is pure; the scene stays the spawner). // their own AI (the clock is pure; the scene stays the spawner).
// FACTION SPACE: the context (the controller's rivals, with their
// rosters) turns the roll into an opposing-faction attack — never
// pirates or neutrals — and the local faction's fighters join us.
const fctx = this._encounterContext();
const comp = this.encounters.update(_time, { const comp = this.encounters.update(_time, {
allowed: allowed:
encounterAllowed({ encounterAllowed({
ship: this.ship, ship: this.ship,
isHomeSystem: this.isHomeSystem, isHomeSystem: this.isHomeSystem,
faction: this.systemRecord ? this.systemRecord.faction : null, faction: this.systemRecord ? this.systemRecord.faction : null,
factionContext: fctx,
waveRunLive: waveRunLive:
!!this.combat.waveRun && !!this.combat.waveRun &&
this.combat.waveRun.state !== 'done' && this.combat.waveRun.state !== 'done' &&
this.combat.waveRun.state !== 'cancelled', this.combat.waveRun.state !== 'cancelled',
playerDown: this.combat.playerDown, playerDown: this.combat.playerDown,
}) && !this._encountersLockedByQuest(), // the 'Special Delivery' hold }) && !this._encountersLockedByQuest(), // the 'Special Delivery' hold
context: fctx,
live: this.combat.active, live: this.combat.active,
xpLevel: this.combat.xp.level, xpLevel: this.combat.xp.level,
}); });
@ -3906,6 +3913,7 @@ export class GameScene extends Phaser.Scene {
commsTargetFor(obj) { commsTargetFor(obj) {
let key = null; let key = null;
let settled = false; let settled = false;
let owner = null; // the place's FACTION (settlement.owner / planet.faction)
let kindLabel = ''; let kindLabel = '';
let isStation = false; let isStation = false;
let frame = null; let frame = null;
@ -3919,6 +3927,7 @@ export class GameScene extends Phaser.Scene {
// A free-space station — settled by definition; key = its id. // A free-space station — settled by definition; key = its id.
key = obj.settlement.id; key = obj.settlement.id;
settled = true; settled = true;
owner = obj.settlement.owner ?? null;
// Deep-space stations wear a spacestations.png variant (the // Deep-space stations wear a spacestations.png variant (the
// galaxy-wide spread pass — js/galaxy/StationFrames.js) and label // galaxy-wide spread pass — js/galaxy/StationFrames.js) and label
// themselves by it (data/stations.json → typeLabels — the fallback // themselves by it (data/stations.json → typeLabels — the fallback
@ -3946,10 +3955,17 @@ export class GameScene extends Phaser.Scene {
settled = true; settled = true;
key = anchored[0].id; key = anchored[0].id;
} }
owner = rec.faction ?? null; // the planet's own faction (the owner seam)
} }
frame = obj.sheetFrame ?? 0; frame = obj.sheetFrame ?? 0;
} }
const rep = settled ? (this.reputation.standingFor(key) ?? 0) : 0; // standingFor() with an {id, owner} record gets the FACTION CHECK
// (a faction-controlled place reads the player's standing with that
// faction — neutral (0) until something sets it); a raw key is fine
// for home (pinned) — it has no owner.
const rep = settled
? (this.reputation.standingFor(owner != null ? { id: key, owner } : key) ?? 0)
: 0;
const isPlanet = !obj.settlement; // home world + system planets vs free-space stations const isPlanet = !obj.settlement; // home world + system planets vs free-space stations
const landingOn = config.get('landing.enabled', true) === true; // landing.json kill-switch const landingOn = config.get('landing.enabled', true) === true; // landing.json kill-switch
return { return {
@ -5730,7 +5746,10 @@ export class GameScene extends Phaser.Scene {
const level = Number(this.combat?.xp?.level) || 0; const level = Number(this.combat?.xp?.level) || 0;
const depth = this.galaxy?.jumpNetwork?.depth; const depth = this.galaxy?.jumpNetwork?.depth;
const hops = depth instanceof Map && Number.isFinite(depth.get(targetId)) ? depth.get(targetId) : 0; // the target's tree depth from home (0 = home) const hops = depth instanceof Map && Number.isFinite(depth.get(targetId)) ? depth.get(targetId) : 0; // the target's tree depth from home (0 = home)
route.encounters = derivePatrolEncounters(this.galaxy?.seed ?? 0, jobId, targetId, route.waypoints, level, hops); // THE WAR MODEL — the sighted rival (the posting's enemyFaction): the
// contact's roster is THAT faction's own fighters (not pirates), one
// seeded rival per posting (a signed contract is a signed contract).
route.encounters = derivePatrolEncounters(this.galaxy?.seed ?? 0, jobId, targetId, route.waypoints, level, hops, posting.enemyFaction ?? null);
// The contract's XP PAYOUT (the claim's second reward — beside the // The contract's XP PAYOUT (the claim's second reward — beside the
// credits): the experience WORTH of the patrol's defeated contacts // credits): the experience WORTH of the patrol's defeated contacts
// (patrolEncounterXp — the contacts × the wave's total × the band's // (patrolEncounterXp — the contacts × the wave's total × the band's
@ -5806,6 +5825,10 @@ export class GameScene extends Phaser.Scene {
// rides the plan so the comms can say who it was once the fight is here. // rides the plan so the comms can say who it was once the fight is here.
const plan = deriveBountyPlan(this.galaxy?.seed ?? 0, jobId, targetId, level, { const plan = deriveBountyPlan(this.galaxy?.seed ?? 0, jobId, targetId, level, {
craft: posting.dossier?.craft ?? null, craft: posting.dossier?.craft ?? null,
// THE WAR MODEL — the mark's own faction (the posting's enemyFaction):
// its elite frame + escorts are drawn from THAT faction's roster (a
// faction's wanted target flies a faction frame, not a pirate craft).
faction: posting.enemyFaction ?? null,
mark: { name: posting.dossier?.name ?? '', epithet: posting.dossier?.epithet ?? '' }, mark: { name: posting.dossier?.name ?? '', epithet: posting.dossier?.epithet ?? '' },
}); });
const { region, anchors } = this._routeFlightEnvelope(targetId); const { region, anchors } = this._routeFlightEnvelope(targetId);
@ -5859,12 +5882,28 @@ export class GameScene extends Phaser.Scene {
// rides the save; setRoute normalizes it). // rides the save; setRoute normalizes it).
*/ */
_establishEscort(jobId, posting) { _establishEscort(jobId, posting) {
const originId = String(posting?.systemId ?? this.systemRecord?.id ?? ''); // THE WAR MODEL — a faction board's escort (the posting's enemySystemId
// stands): the transport is trapped in the HOSTILE neighbor (the
// RENDEZVOUS ground), and the DELIVERY is the board's OWN friendly
// system (the convoy must fly back through the gate). A neutral board's
// escort keeps the classic contract (the convoy stands in the posting's
// own system; the delivery is a 1–2 hop neutral walk).
const isFaction = typeof posting?.enemySystemId === 'string' && posting.enemySystemId !== '';
const originId = isFaction
? String(posting.enemySystemId) // the hostile system (the transport stands here)
: String(posting?.systemId ?? this.systemRecord?.id ?? '');
const friendlyId = isFaction
? String(posting.systemId ?? this.systemRecord?.id ?? '') // the board's own system (the delivery)
: '';
if (!originId || !this.sideQuestState) return; if (!originId || !this.sideQuestState) return;
const level = Number(this.combat?.xp?.level) || 0; const level = Number(this.combat?.xp?.level) || 0;
// DESTINATION — the seeded 1–2 hop neutral walk (degrades: no destination // DESTINATION — the neutral escort's seeded 1–2 hop neutral walk, or the
// stands → the route still flies, the delivery waits at the origin). // faction escort's friendly delivery (the board's own system + a seeded
const dest = escortDestinationFor(this.galaxy?.seed ?? 0, jobId, originId, this.galaxy) ?? null; // destination world inside it). Degrades: no destination stands → the
// route still flies, the delivery waits at the origin.
const dest = isFaction
? (escortFriendlyDestination(this.galaxy?.seed ?? 0, jobId, friendlyId, this.galaxy) ?? null)
: (escortDestinationFor(this.galaxy?.seed ?? 0, jobId, originId, this.galaxy) ?? null);
// RENDEZVOUS — ONE waypoint inside the origin system's flight envelope. // RENDEZVOUS — ONE waypoint inside the origin system's flight envelope.
const { region, anchors } = this._routeFlightEnvelope(originId); const { region, anchors } = this._routeFlightEnvelope(originId);
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, originId, region, anchors, { const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, originId, region, anchors, {
@ -5901,6 +5940,11 @@ export class GameScene extends Phaser.Scene {
transportShields: tshields, transportShields: tshields,
escortCount: eHi >= eLo ? r.int(eLo, eHi) : 1, escortCount: eHi >= eLo ? r.int(eLo, eHi) : 1,
name: String(posting?.title ?? posting?.label ?? 'the Convoy'), // the convoy's name (the posting's title) name: String(posting?.title ?? posting?.label ?? 'the Convoy'), // the convoy's name (the posting's title)
// THE WAR MODEL — the captors' faction (the posting's enemyFaction):
// the lane's waves are THAT faction's fighters, and the delivery is the
// friendly system (the convoy must fly back through the gate). A neutral
// escort carries none (the classic pirate-lane contract).
enemyFaction: posting.enemyFaction ?? null,
}; };
this.sideQuestState.setRoute(jobId, route); this.sideQuestState.setRoute(jobId, route);
// POINTING — the rendezvous beacon is already up (_drawEscortMarkers); // POINTING — the rendezvous beacon is already up (_drawEscortMarkers);
@ -6301,8 +6345,8 @@ export class GameScene extends Phaser.Scene {
} }
/** The escort wave's SCRIPT (a single rolling wave — escortWaveRoster's roster). */ /** The escort wave's SCRIPT (a single rolling wave — escortWaveRoster's roster). */
escortWaveScript(level = 0) { escortWaveScript(level = 0, enemyFaction = null) {
const r = escortWaveRoster(level); const r = escortWaveRoster(level, enemyFaction);
return { return {
waves: [{ waves: [{
kind: 'rolling', kind: 'rolling',
@ -6321,20 +6365,24 @@ export class GameScene extends Phaser.Scene {
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // one at a time if (run && run.state !== 'done' && run.state !== 'cancelled') return; // one at a time
if (!this.escortDirector) return; if (!this.escortDirector) return;
const level = Number(this.combat.xp?.level) || 0; const level = Number(this.combat.xp?.level) || 0;
// THE WAR MODEL — the captors' faction (the contract's enemyFaction): the
// lane's waves are THAT faction's fighters; a neutral escort (no faction)
// keeps the data bands.
const enemyFaction = String(route?.escort?.enemyFaction ?? '').trim() || null;
const comp = this.escortDirector.update(this.time?.now ?? 0, { const comp = this.escortDirector.update(this.time?.now ?? 0, {
allowed: this._escortLaneAllowed(), allowed: this._escortLaneAllowed(),
live: this.combat.active, live: this.combat.active,
xpLevel: level, xpLevel: level,
}); });
if (!comp) return; // the clock hasn't rolled a hit (yet) if (!comp) return; // the clock hasn't rolled a hit (yet)
const total = Number(escortWaveRoster(level).total) || 8; const total = Number(escortWaveRoster(level, enemyFaction).total) || 8;
const t = config.get('sidequests.ui.patrolContactToast', {}); const t = config.get('sidequests.ui.patrolContactToast', {});
this.playSfx('discovery'); this.playSfx('discovery');
this.consoleToast( this.consoleToast(
String(t.text ?? 'HOSTILE CONTACT — {n} ENEMIES INBOUND').replace('{n}', String(total)), String(t.text ?? 'HOSTILE CONTACT — {n} ENEMIES INBOUND').replace('{n}', String(total)),
{ glyph: t.glyph ?? '⚔', glyphColor: t.color ? toCss(t.color) : undefined }, { glyph: t.glyph ?? '⚔', glyphColor: t.color ? toCss(t.color) : undefined },
); );
this.combat.startWaves(this.escortWaveScript(level), { this.combat.startWaves(this.escortWaveScript(level, enemyFaction), {
onComplete: () => { onComplete: () => {
this.playSfx('construct'); // the cleared tick this.playSfx('construct'); // the cleared tick
const tc = config.get('sidequests.ui.patrolContactClearedToast', {}); const tc = config.get('sidequests.ui.patrolContactClearedToast', {});
@ -6351,7 +6399,13 @@ export class GameScene extends Phaser.Scene {
if (config.get('sidequests.escort.encounters.enabled', true) === false) return false; if (config.get('sidequests.escort.encounters.enabled', true) === false) return false;
if (!this.ship || this.combat?.playerDown) return false; if (!this.ship || this.combat?.playerDown) return false;
if (this.isHomeSystem) return false; if (this.isHomeSystem) return false;
if (this.systemRecord?.faction != null) return false; // the lane is neutral space // THE LANE'S SPACE — a neutral escort's lane is NEUTRAL space only; the
// WAR-MODEL escort (the contract's enemyFaction stands) flies its lane in
// the HOSTILE faction system (the captors' own ground), so faction space
// is admitted while such an escort is in flight. A plain neutral escort in
// faction space still stands down (its lane is the pirate-lane contract).
const isFactionEscort = !!(this._escortRoute?.()?.route?.escort?.enemyFaction);
if (this.systemRecord?.faction != null && !isFactionEscort) return false;
const run = this.combat?.waveRun; const run = this.combat?.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return false; // one at a time if (run && run.state !== 'done' && run.state !== 'cancelled') return false; // one at a time
return true; return true;
@ -7679,9 +7733,36 @@ export class GameScene extends Phaser.Scene {
* (neutral.escortMin/escortMax — the break-off is their state machine). * (neutral.escortMin/escortMax — the break-off is their state machine).
* @param {{kind:string, pirates:number, neutrals:number}} comp * @param {{kind:string, pirates:number, neutrals:number}} comp
*/ */
/**
* The ENCOUNTER CONTEXT for the current system (the war-zones): in a
* faction-controlled system, the two OPPOSING factions with their
* fighter rosters (the director rolls one of them — never the
* controller, never pirates/neutrals); null in neutral space (the
* classic roll stands) or where the roster is unknown (a non-roster
* faction — defensive).
* @returns {?{mode:string, faction:string, enemies:Array<{id:string, types:string[]}>}}
*/
_encounterContext() {
const fid = this.systemRecord ? this.systemRecord.faction : null;
if (!fid || fid === 'neutral') return null;
const all = factionIds();
if (!all.includes(fid)) return null;
const enemies = all
.filter((f) => f !== fid)
.map((f) => ({ id: f, types: factionTypeIds(f) }))
.filter((e) => e.types.length > 0);
if (enemies.length === 0) return null;
return { mode: 'faction', faction: fid, enemies };
}
_spawnEncounter(comp) { _spawnEncounter(comp) {
const ship = this.ship; const ship = this.ship;
if (!ship || !this.combat) return; if (!ship || !this.combat) return;
// FACTION SPACE — an opposing faction's fighters attack (hostile on
// sight), and the LOCAL faction's fighters join the player (allies:
// they fight the hostiles, never the player — the NeutralShip 'side:
// player' seam). See _spawnFactionEncounter.
if (comp?.kind === 'faction') { this._spawnFactionEncounter(comp); return; }
const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2); const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2);
const baseA = Math.random() * Math.PI * 2; const baseA = Math.random() * Math.PI * 2;
const eids = enemyTypeIds(); const eids = enemyTypeIds();
@ -7713,6 +7794,63 @@ export class GameScene extends Phaser.Scene {
} }
} }
/**
* THE FACTION ENCOUNTER (the war-zones): an OPPOSING faction's fighters
* attack — hostile on sight, spawned exactly like the neutral-space
* hostiles (their types are roster-registered, spawnEnemy's job) — and
* the LOCAL faction's fighters join the player: player-side crafts (the
* NeutralShip 'side: player' seam — they engage the hostiles, never the
* player, and the player's guns never target them) on a finite escort
* window, so the allies stand for the fight and peel off after. The
* enemy's count/types come from the director's compose (seeded); the
* ally count + window are data (combat.encounters.faction, defaults in
* code).
*/
_spawnFactionEncounter(comp) {
const ship = this.ship;
if (!ship || !this.combat) return;
const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2);
const baseA = Math.random() * Math.PI * 2;
const count = Math.max(1, comp?.count ?? comp?.pirates ?? 1);
const types = Array.isArray(comp?.types) && comp.types.length > 0 ? comp.types : null;
const fan = (i, n) => baseA + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (Math.random() - 0.5) * 0.4;
// HOSTILES — the rival faction's fighters, one-shots like any encounter.
for (let i = 0; i < count; i++) {
const a = fan(i, count);
const e = this.combat.spawnEnemy(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
this._encounterSerial++,
types ? types[i % types.length] : null,
);
e.respawn = false;
}
// ALLIES — the local faction's fighters join the fight: the faction's
// OWN roster craft (its frame, weapon pattern, stats) with the
// player-side seam (side 'player', the rams disarmed) — spawnAlly's
// cfg override does the rest (they hold the wing for the window,
// engage any hostile up, and peel off when it lapses).
const ctx = this._encounterContext();
const myTypes = ctx ? factionTypeIds(ctx.faction) : [];
if (myTypes.length > 0) {
const allyCount = Math.max(1, Math.round(Number(config.get('combat.encounters.faction.allyCount', 2)) || 2));
const windowMs = Math.max(0, Number(config.get('combat.encounters.faction.allyWindowMs', 90000)) || 90000);
for (let i = 0; i < allyCount; i++) {
const a = fan(i, allyCount) + Math.PI; // opposite bearing: they arrive behind the attack
const tid = myTypes[(Math.random() * myTypes.length) | 0];
const base = enemyTypeConfig(tid) || {};
const allyCfg = { ...base, side: 'player', ramDamage: 0, ramDamageSelf: 0 };
this.combat.spawnAlly(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
2000 + this._encounterSerial++,
null,
{ escortMs: windowMs, cfg: allyCfg },
);
}
}
}
_maybeStartPirateAmbush() { _maybeStartPirateAmbush() {
if (config.get('combat.enabled', true) === false) return; if (config.get('combat.enabled', true) === false) return;
if (!this.combat) return; if (!this.combat) return;