Enable patrol side quests with full accept/active/claim flow
- Add patrol category rules: target selection (own system or faction neighbor via jump gates), deterministic waypoint route builder inside tether regions with anchor clearance and separation constraints, completable fallback for degenerate fields - Extend SideQuestState ledger with active slot, claimed marks, and per-contract route records (waypoints + visited indices); save/restore round-trips all new fields with invariant re-establishment - Wire GameScene seams: acceptSideQuest establishes the patrol route on the ledger; setActiveSideQuest / claimSideQuest manage the active slot and pay out exactly once; updatePatrol checks in waypoints per frame and completes the contract; _drawPatrolMarkers paints in-world beacons; patrolCompassTarget drives the yellow off-screen compass arrow (waypoint or gate) - JobBoardWindow walks the full door state machine: ACCEPT → SET PATROL ACTIVE → active plate → CLAIM REWARD → claimed plate, with destination and route-progress lines in the detail readout - Quests console side tab is now live: accepted contracts appear in questSnapshot with waypoint checklist, progress, reward, and claimable from the same CLAIM REWARD / SET PRIORITY doors as story quests - SurfaceScene exposes activateSurfaceJob / claimSurfaceJob and routes side ids through the existing claim/priority seams - Add capital-neutral-shop video + image assets; update landing.json to reference them - Update all headless tests (sidequests, jobboard, quests) to cover patrol target/route/ledger/scene-seam behavior
This commit is contained in:
parent
081a086658
commit
a690ca74a2
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
|
@ -82,7 +82,7 @@
|
|||
{ "land": "arid-land-01.mp4", "surface": "arid-surface-01.mp4", "takeoff": "arid-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
|
||||
{ "land": "arid-land-02.mp4", "surface": "arid-surface-02.mp4", "takeoff": "arid-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
|
||||
{ "land": "arid-land-03.mp4", "surface": "arid-surface-03.mp4", "takeoff": "arid-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
|
||||
{ "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.mp4", "bar": "capital-bar-neutral.mp4" },
|
||||
{ "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "capital-shop-neutral.mp4", "bar": "capital-bar-neutral.mp4" },
|
||||
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
|
||||
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
|
||||
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "The QUESTS console (deck slot right of SHIP, main deck + surface deck). The window mirrors the RESEARCH/MAP consoles (js/ui/QuestWindow.js): a 2:3 archive feed (assets/videos/quests.mp4 — the same aspect as assets/videos/research.mp4; NO SIGNAL plate until the clip is authored) on the left, and on the right the category tabs (Main Story / Side Quests — side quests are not defined yet: the tab is a STANDBY socket), a THIN list of active quests, and the big detail plate below it: title, issuer, description, the requirement checklist, and the reward, with the CLAIM action. A quest's progress is evaluated LIVE from the run (research, lifetime minerals mined from asteroids, the home tether's level, discovery) and, once a step reads DONE, it is recorded on the ledger and stays done (monotonic — the home tether's step keeps standing after the ship leaves the home system). js/quests/QuestState.js tracks who holds which quest, which reward has been claimed, and which steps have stood — and that is what gets saved.",
|
||||
"_comment": "The QUESTS console (deck slot right of SHIP, main deck + surface deck). The window mirrors the RESEARCH/MAP consoles (js/ui/QuestWindow.js): a 2:3 archive feed (assets/videos/quests.mp4 — the same aspect as assets/videos/research.mp4; NO SIGNAL plate until the clip is authored) on the left, and on the right the category tabs (Main Story / Side Quests — side quests are LIVE: the board's accepted contracts, their patrol checkpoints as the checklist, claimed from this console or the tracker — data/sidequests.json), a THIN list of active quests, and the big detail plate below it: title, issuer, description, the requirement checklist, and the reward, with the CLAIM action. A quest's progress is evaluated LIVE from the run (research, lifetime minerals mined from asteroids, the home tether's level, discovery) and, once a step reads DONE, it is recorded on the ledger and stays done (monotonic — the home tether's step keeps standing after the ship leaves the home system). js/quests/QuestState.js tracks who holds which quest, which reward has been claimed, and which steps have stood — and that is what gets saved.",
|
||||
"enabled": true,
|
||||
"title": "OPERATIONS DOSSIER",
|
||||
"meta": "MISSION LOG // ACTIVE CONTRACTS",
|
||||
|
|
@ -32,8 +32,7 @@
|
|||
"id": "side",
|
||||
"label": "Side Quests",
|
||||
"accent": "#ffc94d",
|
||||
"standby": true,
|
||||
"_comment": "The socket for side quests — deferred by design. Pressing it toasts the standby note until side quests are authored (quests.json → quests, category 'side')."
|
||||
"_comment": "LIVE — the board's accepted contracts (data/sidequests.json — patrol is the first live category): the ACCEPTED side quests stand here (GameScene.questSnapshot's category-'side' entries — the patrol's checklist is its route's waypoints, the reward is the contract's payout), with the same CLAIM REWARD / SET PRIORITY doors as the story tab (the console's claim/priority seams dispatch the sq- ids to the side ledger — js/quests/SideQuests.js). Empty until a contract is accepted at the board."
|
||||
}
|
||||
],
|
||||
"sideStandbyNote": "SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET",
|
||||
|
|
@ -65,7 +64,7 @@
|
|||
"text": "REQUEST DENIED. PLEASE CLAIM YOUR QUEST REWARD FIRST."
|
||||
},
|
||||
"tracker": {
|
||||
"_comment": "The QUEST TRACKER HUD (js/ui/QuestTrackerHud.js) — the thin quest list that slides in from offscreen-left right below the system dossier ONCE the dossier has folded (auto-fold or the player's manual fold — first fold arms it; visible ⇔ dossier folded). ACTIVE = held + not claimed. Titles stack main-story-first then side-quests, up to maxVisible, the priority quest's checklist renders under its title and tracks LIVE (GameScene.questSnapshot); more than maxVisible → SHOW MORE (opens the QUESTS console on the first hidden quest). The ONE priority quest (js/quests/QuestState.js): the player sets/clears it (row popup + the console's SET PRIORITY), else the first active story quest is the priority; claiming it auto-advances to the next story/side quest. priorityToast/clearToast fire on set/clear (data colors: green = the tracker accent, dim = the cleared note).",
|
||||
"_comment": "The QUEST TRACKER HUD (js/ui/QuestTrackerHud.js) — the thin quest list that slides in from offscreen-left right below the system dossier ONCE the dossier has folded (auto-fold or the player's manual fold — first fold arms it; visible ⇔ dossier folded). ACTIVE = held + not claimed. Titles stack main-story-first then side-quests (the board's accepted contracts ride the same stack — GameScene.questSnapshot), up to maxVisible, the priority quest's checklist renders under its title and tracks LIVE (GameScene.questSnapshot); more than maxVisible → SHOW MORE (opens the QUESTS console on the first hidden quest). The ONE priority quest (js/quests/QuestState.js): the player sets/clears it (row popup + the console's SET PRIORITY — a side quest's set rides the side ledger's active slot, featured through this seam), else the first active quest is the priority (main story first, then side); claiming it auto-advances to the next active quest. priorityToast/clearToast fire on set/clear (data colors: green = the tracker accent, dim = the cleared note).",
|
||||
"enabled": true,
|
||||
"title": "QUESTS",
|
||||
"noneTracked": "NONE TRACKED",
|
||||
|
|
|
|||
|
|
@ -1,25 +1,56 @@
|
|||
{
|
||||
"_comment": "SIDE QUESTS — the contracts posted on the JOB BOARD (the Explore drawer's Job Board slot — every system EXCEPT the home system, data/actionbar.json → surface.explore.items). The board is a VIEW over js/quests/SideQuests.js: `sideQuestPostingsFor(seed, record)` derives 3–4 deterministic postings from the galaxy seed + the system record (same seed + system ⇒ same contracts, across visits, scene restarts, and saves), and the run's accepted/completed set rides the saves as the SideQuestState ledger (registry 'sidequests', record.sidequests). CATEGORIES — the four job types (→ categories): patrol / delivery / bounty / escort. Every category is ACCOUNTED FOR (its slot machinery, its description pool below, its reward band, the window's row + contract readout, the ledger, the accept seam) behind its OWN `enabled` gate — and all four start OFFLINE (enabled:false), because none of the four objective/completion rule sets is built yet. The gate is the contract: a category's jobs appear on the board ONLY when its rules land and its flag flips (flip enabled:true in this file — the board's standby plate lifts and its postings stand). FLAVOR — the variant pools split by SYSTEM CONTEXT (→ descriptions): NEUTRAL systems post about keeping their system safe from PIRATES; a FACTION's systems post about pushing the OTHER factions out of their space (the pools carry {faction}/{issuer}/{enemies} placeholders — {enemies} resolves to the other two factions' labels; {issuer} to the faction's board name below; {system} to the system's name — resolved at generation, js/quests/SideQuests.js → resolveSideQuestText). Each pool holds 3+ variants so different systems read different contracts.",
|
||||
"_comment": "SIDE QUESTS — the contracts posted on the JOB BOARD (the Explore drawer's Job Board slot — every system EXCEPT the home system, data/actionbar.json → surface.explore.items). The board is a VIEW over js/quests/SideQuests.js: `sideQuestPostingsFor(seed, record)` derives 3–4 deterministic postings from the galaxy seed + the system record (same seed + system ⇒ same contracts, across visits, scene restarts, and saves), and the run's accepted/completed set rides the saves as the SideQuestState ledger (registry 'sidequests', record.sidequests). CATEGORIES — the four job types (→ categories): patrol / delivery / bounty / escort. Every category is ACCOUNTED FOR (its slot machinery, its description pool below, its reward band, the window's row + contract readout, the ledger, the accept seam) behind its OWN `enabled` gate. PATROL is the first live type (enabled:true — its rules: → patrol below, js/quests/SideQuests.js → buildPatrolRoute + patrolTargetSystem, the scene's updatePatrol); the other three stay OFFLINE until their objective/completion rule sets land. The gate is the contract: a category's jobs appear on the board ONLY when its rules land and its flag flips (flip enabled:true in this file — the board's standby plate lifts and its postings stand). FLAVOR — the variant pools split by SYSTEM CONTEXT (→ descriptions): NEUTRAL systems post about keeping their system safe from PIRATES; a FACTION's systems post about pushing the OTHER factions out of their space (the pools carry {faction}/{issuer}/{enemies} placeholders — {enemies} resolves to the other two factions' labels; {issuer} to the faction's board name below; {system} to the system's name — resolved at generation, js/quests/SideQuests.js → resolveSideQuestText). Each pool holds 3+ variants so different systems read different contracts.",
|
||||
"enabled": true,
|
||||
"board": {
|
||||
"_comment": "perSystem = the postings-per-system roll [min,max] (seeded per system — Rng.derive(seed, seedLabel, <sysId>); the roll itself is stable for the run). seedLabel = the Rng fork label under the galaxy seed (the worldgen determinism contract: a labelled fork, independent of call order).",
|
||||
"perSystem": [3, 4],
|
||||
"perSystem": [
|
||||
3,
|
||||
4
|
||||
],
|
||||
"seedLabel": "sidequests"
|
||||
},
|
||||
"categories": {
|
||||
"_comment": "The four job types. ORDER = the canonical slot order (patrol, delivery, bounty, escort). `enabled` is the per-type availability gate — OFFLINE until that type's objective + completion logic is built (all four are stubs for now); flip a category to true and its jobs stand on the board with their pool + reward band already wired. `accent` = the row's category chip + the contract title (data colors, js/quests/SideQuests.js reads them through).",
|
||||
"patrol": { "enabled": false, "label": "PATROL", "accent": "#00e5ff" },
|
||||
"delivery": { "enabled": false, "label": "DELIVERY", "accent": "#ffc94d" },
|
||||
"bounty": { "enabled": false, "label": "BOUNTY", "accent": "#ff2d6f" },
|
||||
"escort": { "enabled": false, "label": "ESCORT", "accent": "#7ce8a4" }
|
||||
"_comment": "The four job types. ORDER = the canonical slot order (patrol, delivery, bounty, escort). `enabled` is the per-type availability gate — OFFLINE until that type's objective + completion logic is built (patrol is live — its rules under → patrol; the other three are stubs); flip a category to true and its jobs stand on the board with their pool + reward band already wired. `accent` = the row's category chip + the contract title (data colors, js/quests/SideQuests.js reads them through).",
|
||||
"patrol": {
|
||||
"enabled": true,
|
||||
"label": "PATROL",
|
||||
"accent": "#00e5ff"
|
||||
},
|
||||
"delivery": {
|
||||
"enabled": false,
|
||||
"label": "DELIVERY",
|
||||
"accent": "#ffc94d"
|
||||
},
|
||||
"bounty": {
|
||||
"enabled": false,
|
||||
"label": "BOUNTY",
|
||||
"accent": "#ff2d6f"
|
||||
},
|
||||
"escort": {
|
||||
"enabled": false,
|
||||
"label": "ESCORT",
|
||||
"accent": "#7ce8a4"
|
||||
}
|
||||
},
|
||||
"rewards": {
|
||||
"_comment": "The per-category CREDITS band [min,max] at the home rim — the base payout; it scales OUT with the system's home→far diagonal d (record.d) as (1 + d * distanceScale) — the economy's 'a hop is a hop, and danger pays' rule (the far systems' boards pay more for the same risk). The band is rolled seeded per posting (round to 5 CR, floor 5 CR). Tune per category as the types' rules land; a category with no band here falls back to [40,80] in code.",
|
||||
"distanceScale": 0.75,
|
||||
"patrol": [40, 70],
|
||||
"delivery": [50, 90],
|
||||
"bounty": [80, 140],
|
||||
"escort": [70, 110]
|
||||
"patrol": [
|
||||
40,
|
||||
70
|
||||
],
|
||||
"delivery": [
|
||||
50,
|
||||
90
|
||||
],
|
||||
"bounty": [
|
||||
80,
|
||||
140
|
||||
],
|
||||
"escort": [
|
||||
70,
|
||||
110
|
||||
]
|
||||
},
|
||||
"factions": {
|
||||
"_comment": "The faction boards' ISSUER names (the {issuer} placeholder in the faction pools — data/factions.json's faction ids: apex / mindweave / zenith).",
|
||||
|
|
@ -27,6 +58,27 @@
|
|||
"mindweave": "MindWeave Chorus",
|
||||
"zenith": "Zenith Concord"
|
||||
},
|
||||
"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 any order). 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).",
|
||||
"waypoints": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"visitRadius": 420,
|
||||
"anchorClearance": 1100,
|
||||
"minSeparation": 1500,
|
||||
"regionInset": 96,
|
||||
"neighborMinD": 0.4,
|
||||
"neighborChance": 0.5,
|
||||
"compass": {
|
||||
"color": "#ffd60a",
|
||||
"typeLabel": "Patrol"
|
||||
},
|
||||
"marker": {
|
||||
"color": "#ffd60a",
|
||||
"doneColor": "#67e863"
|
||||
}
|
||||
},
|
||||
"descriptions": {
|
||||
"_comment": "The variant pools: context (neutral | faction) × category. The generator picks ONE variant per posting, seeded on (galaxy seed, system id, category, slot) — a system's contract is STABLE (the board doesn't reshuffle between visits or saves), and different systems draw different ones (3+ variants per pool keeps the galaxy's boards from reading as one repeated ad). Placeholders (resolved at generation, never stored): {system} = the system's name; in the faction pools {faction} = the system's faction label (data/factions.json), {issuer} = that faction's board name (→ factions above), {enemies} = the OTHER two factions ('MindWeave and Zenith'). The NEUTRAL pools are the pirate-safety voice; the FACTION pools are the push-the-other-factions-out voice.",
|
||||
"neutral": {
|
||||
|
|
@ -170,8 +222,47 @@
|
|||
"payout": "PAYOUT — {reward} CR",
|
||||
"accept": "ACCEPT CONTRACT",
|
||||
"accepted": "CONTRACT ACCEPTED",
|
||||
"completed": "CONTRACT COMPLETE",
|
||||
"acceptToast": { "glyph": "◆", "color": "#ffc94d", "text": "CONTRACT ACCEPTED — {title}" },
|
||||
"acceptDenyToast": { "glyph": "✗", "color": "#ff5c77", "text": "COMMISSION DENIED — CONTRACT NOT ON FILE" }
|
||||
"active": "PATROL ACTIVE",
|
||||
"activate": "SET PATROL ACTIVE",
|
||||
"complete": "CONTRACT COMPLETE",
|
||||
"claim": "CLAIM REWARD — {reward} CR",
|
||||
"claimed": "REWARD CLAIMED",
|
||||
"destination": "DESTINATION — {name}",
|
||||
"routeProgress": "ROUTE — {visited}/{total} WAYPOINTS LOGGED",
|
||||
"acceptToast": {
|
||||
"glyph": "◆",
|
||||
"color": "#ffc94d",
|
||||
"text": "CONTRACT ACCEPTED — {title}"
|
||||
},
|
||||
"acceptDenyToast": {
|
||||
"glyph": "✗",
|
||||
"color": "#ff5c77",
|
||||
"text": "COMMISSION DENIED — CONTRACT NOT ON FILE"
|
||||
},
|
||||
"activateToast": {
|
||||
"glyph": "▸",
|
||||
"color": "#ffd60a",
|
||||
"text": "PATROL ACTIVE — {title}"
|
||||
},
|
||||
"claimToast": {
|
||||
"glyph": "✓",
|
||||
"color": "#67e863",
|
||||
"text": "REWARD CLAIMED — +{reward} CR"
|
||||
},
|
||||
"claimDenyToast": {
|
||||
"glyph": "✗",
|
||||
"color": "#ff5c77",
|
||||
"text": "NOTHING TO CLAIM — ROUTE NOT COMPLETE"
|
||||
},
|
||||
"patrolWaypointToast": {
|
||||
"glyph": "◈",
|
||||
"color": "#ffd60a",
|
||||
"text": "WAYPOINT {i} OF {n} LOGGED"
|
||||
},
|
||||
"patrolCompleteToast": {
|
||||
"glyph": "✓",
|
||||
"color": "#67e863",
|
||||
"text": "PATROL ROUTE COMPLETE — ITS REWARD IS READY TO CLAIM"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@
|
|||
// postings stand).
|
||||
// - LIVE — the postings seam renders the AVAILABLE JOBS rows (chip +
|
||||
// title + issuer line + the payout), a row’s press selects it (the
|
||||
// JOB DETAILS readout stands: title + issuer + the contract’s copy
|
||||
// + the payout + the ACCEPT door), the ACCEPT press fires the
|
||||
// onAccept seam, and the ledger’s seams repaint the states (the row
|
||||
// shows CONTRACT ACCEPTED / CONTRACT COMPLETE; the door’s label
|
||||
// follows, and its door closes once the contract is taken).
|
||||
// JOB DETAILS readout stands: title + issuer + the contract’s lines
|
||||
// (destination / route) + the copy + the payout + the ACTION door),
|
||||
// and the door walks the ledger’s states (ACCEPT → SET PATROL
|
||||
// ACTIVE → the active plate → CLAIM REWARD → the claimed plate —
|
||||
// each live door firing its seam, each plate standing closed), the
|
||||
// row’s mark following (CONTRACT ACCEPTED → PATROL ACTIVE →
|
||||
// CONTRACT COMPLETE → REWARD CLAIMED).
|
||||
// - GEOMETRY — the right column sits right of the feed; the body runs
|
||||
// to the window’s bottom padding (nearly full-screen, like the build
|
||||
// screen — the window is depth 80, above the HUD (60) + deck (50) +
|
||||
|
|
@ -172,8 +174,8 @@ console.log('== BUILD — the console constructs (top bar, feed, right column) =
|
|||
|
||||
// the right column: the title (decoded on open) + the two live
|
||||
// sections (the postings list + the contract readout) — the standby
|
||||
// plate stands while no postings seam is wired (all categories
|
||||
// OFFLINE â data/sidequests.json’s gate).
|
||||
// plate stands while no postings stand (the window's postings seam is
|
||||
// not wired in this section — the LIVE section drives it).
|
||||
assert.equal(win.rightTitle.text, '', 'the title decodes in (empty until open)');
|
||||
assert.equal(win.jobsSection.headTxt.text, 'AVAILABLE JOBS');
|
||||
assert.ok(
|
||||
|
|
@ -234,15 +236,20 @@ console.log('== BUILD — the console constructs (top bar, feed, right column) =
|
|||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== LIVE — the postings stand, the selection reads, the ACCEPT fires ==');
|
||||
console.log('== LIVE — the postings stand, the selection reads, the door walks the ledger ==');
|
||||
{
|
||||
const scene = makeScene();
|
||||
// The board’s seams â the GameScene/SurfaceScene shape (the ledger’s
|
||||
// The board’s seams — the GameScene/SurfaceScene shape (the ledger’s
|
||||
// truth lives in the seams; the window just reads it back on repaint).
|
||||
const accepted = new Set();
|
||||
const active = new Set();
|
||||
const completed = new Set();
|
||||
const claimed = new Set();
|
||||
const accepts = [];
|
||||
const mkPost = (cat, title, n) => ({
|
||||
const activates = [];
|
||||
const claims = [];
|
||||
const routeState = { visited: 0, total: 3 };
|
||||
const mkPost = (cat, title, n, extra = {}) => ({
|
||||
id: `sq-test-${cat}-${n}`,
|
||||
systemId: 'sys-free',
|
||||
systemName: 'Free Reach',
|
||||
|
|
@ -255,15 +262,28 @@ console.log('== LIVE — the postings stand, the selection reads, the ACCEPT fir
|
|||
description: `The ${cat} contract for the test board — the body stands in the readout.`,
|
||||
reward: { credits: 40 + n * 10 },
|
||||
objective: { type: cat },
|
||||
...extra,
|
||||
});
|
||||
const posts = [mkPost('patrol', 'Lane Watch', 1), mkPost('delivery', 'Dead Freight', 2), mkPost('bounty', 'Salt the Raiders', 3)];
|
||||
// The patrol posts for a NEIGHBOR system (the push-out rule) — the
|
||||
// detail’s DESTINATION line has something to say; the delivery/bounty
|
||||
// stubs stand local (no target fields — their rules land later).
|
||||
const posts = [
|
||||
mkPost('patrol', 'Lane Watch', 1, { targetSystemId: 'sys-apex', targetSystemName: 'Apex Threshold' }),
|
||||
mkPost('delivery', 'Dead Freight', 2),
|
||||
mkPost('bounty', 'Salt the Raiders', 3),
|
||||
];
|
||||
const win = new JobBoardWindow(scene, {
|
||||
planetName: 'Free Reach',
|
||||
onClose: () => {},
|
||||
postings: () => posts,
|
||||
isAccepted: (id) => accepted.has(id),
|
||||
isCompleted: (id) => completed.has(id),
|
||||
isActive: (id) => active.has(id),
|
||||
isClaimed: (id) => claimed.has(id),
|
||||
routeProgress: (id) => (id === posts[0].id && accepted.has(id) ? { visited: routeState.visited, total: routeState.total } : null),
|
||||
onAccept: (id) => accepts.push(id),
|
||||
onActivate: (id) => activates.push(id),
|
||||
onClaim: (id) => claims.push(id),
|
||||
});
|
||||
|
||||
// the list: three rows stand (three postings off the seam), and the
|
||||
|
|
@ -275,7 +295,7 @@ console.log('== LIVE — the postings stand, the selection reads, the ACCEPT fir
|
|||
);
|
||||
assert.ok(
|
||||
win.jobsSection.content.some((t) => t.text && /PATROL — THE TALLY/.test(t.text)),
|
||||
'the first row’s issuer line stands (label â issuer)',
|
||||
'the first row’s issuer line stands (label — issuer)',
|
||||
);
|
||||
assert.ok(
|
||||
win.jobsSection.content.some((t) => t.text && /^50 CR$/.test(t.text)),
|
||||
|
|
@ -289,8 +309,9 @@ console.log('== LIVE — the postings stand, the selection reads, the ACCEPT fir
|
|||
);
|
||||
|
||||
// a row’s press selects it (the seam’s pointerdown fires headless):
|
||||
// the detail’s readout stands â title + the ISSUER line + the body +
|
||||
// the payout + the ACCEPT door (open state).
|
||||
// the detail’s readout stands — title + the ISSUER line + the
|
||||
// DESTINATION line (the patrol’s route stands in ANOTHER system) +
|
||||
// the body + the payout + the ACTION door (open state).
|
||||
win._rows[0].hit.__on.pointerdown();
|
||||
assert.equal(win.selectedId, posts[0].id, 'the row’s press lands the selection');
|
||||
assert.ok(
|
||||
|
|
@ -301,6 +322,14 @@ console.log('== LIVE — the postings stand, the selection reads, the ACCEPT fir
|
|||
win.detailSection.content.some((t) => t.text && /ISSUER — THE TALLY/.test(t.text)),
|
||||
'the detail’s issuer line stands',
|
||||
);
|
||||
assert.ok(
|
||||
win.detailSection.content.some((t) => t.text && /DESTINATION — APEX THRESHOLD/.test(t.text)),
|
||||
'the patrol’s DESTINATION line stands (the route’s system, named)',
|
||||
);
|
||||
assert.ok(
|
||||
!win.detailSection.content.some((t) => t.text && /ROUTE —/.test(t.text)),
|
||||
'no ROUTE line while the contract is not taken (no route to count)',
|
||||
);
|
||||
assert.ok(
|
||||
win.detailSection.content.some((t) => t.text && /the patrol contract/i.test(t.text)),
|
||||
'the contract’s body stands in the readout',
|
||||
|
|
@ -313,38 +342,93 @@ console.log('== LIVE — the postings stand, the selection reads, the ACCEPT fir
|
|||
assert.equal(doorTxt.text, 'ACCEPT CONTRACT', 'the door stands OPEN (the contract is not taken)');
|
||||
assert.ok(win.detailSection.content[win.detailSection.content.length - 2].__on?.pointerdown, 'the door is interactive while open');
|
||||
|
||||
// the ACCEPT press fires the scene’s seam (the ledger lands there â
|
||||
// the ACCEPT press fires the scene’s seam (the ledger lands there —
|
||||
// the surface’s note is theirs, not the window’s).
|
||||
win.detailSection.content[win.detailSection.content.length - 2].__on.pointerdown();
|
||||
assert.deepEqual(accepts, [posts[0].id], 'the ACCEPT fired the onAccept seam with the job id');
|
||||
|
||||
// the ledger says ACCEPTED now (the seam’s truth) â the repaint
|
||||
// (refresh() â the scene’s call after its accept) lands the states:
|
||||
// the row shows the ledger’s mark, the door stands ACCEPTED and
|
||||
// closed (no more pointerdown — a double-take is a no-op).
|
||||
// the ledger says ACCEPTED now (the seam’s truth) — the repaint
|
||||
// (refresh() — the scene’s call after its accept) lands the states:
|
||||
// the row shows CONTRACT ACCEPTED, the door stands SET PATROL ACTIVE
|
||||
// (live — it fires the onActivate seam), the ROUTE line stands (the
|
||||
// scene’s routeProgress seam: 0 of 3 logged).
|
||||
accepted.add(posts[0].id);
|
||||
win.refresh();
|
||||
assert.ok(
|
||||
win.jobsSection.content.some((t) => t.text && /CONTRACT ACCEPTED/.test(t.text)),
|
||||
'the taken row shows CONTRACT ACCEPTED (the ledger’s mark)');
|
||||
const dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||||
assert.equal(dTxt.text, 'CONTRACT ACCEPTED', 'the door stands ACCEPTED after the repaint');
|
||||
const dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||||
assert.equal(dG.__on?.pointerdown, undefined, 'the door is CLOSED once taken (no double-take)');
|
||||
const again = [...accepts];
|
||||
// a fresh open board for the same job (the ledger forgot — the seam
|
||||
// says it’s open again) would re-fire; the point is the door’s state
|
||||
// tracks the seams.
|
||||
assert.deepEqual(accepts, again, 'nothing fired on its own');
|
||||
assert.ok(
|
||||
win.detailSection.content.some((t) => t.text && /ROUTE — 0\/3 WAYPOINTS LOGGED/.test(t.text)),
|
||||
'the ROUTE line stands off the routeProgress seam (0/3)');
|
||||
let dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||||
assert.equal(dTxt.text, 'SET PATROL ACTIVE', 'the door stands SET ACTIVE after the accept');
|
||||
let dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||||
assert.ok(dG.__on?.pointerdown, 'the door is live while accepted (the activate is a real press)');
|
||||
dG.__on.pointerdown();
|
||||
assert.deepEqual(activates, [posts[0].id], 'the activate press fired the onActivate seam');
|
||||
|
||||
// â¦and COMPLETE, when the type’s objective stands (the seam’s truth
|
||||
// again — the window just reads it back).
|
||||
// …the ledger says ACTIVE now — the door stands as the active PLATE
|
||||
// (PATROL ACTIVE — nothing to press), the row’s mark follows.
|
||||
active.add(posts[0].id);
|
||||
win.refresh();
|
||||
assert.ok(
|
||||
win.jobsSection.content.some((t) => t.text && /PATROL ACTIVE/.test(t.text)),
|
||||
'the active row shows PATROL ACTIVE (the ledger’s mark)');
|
||||
dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||||
assert.equal(dTxt.text, 'PATROL ACTIVE', 'the door stands as the active plate');
|
||||
dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||||
assert.equal(dG.__on?.pointerdown, undefined, 'the plate is closed (nothing to press while active)');
|
||||
|
||||
// …the route’s check-ins land (the scene’s seam advances) — the
|
||||
// ROUTE line repaints off it.
|
||||
routeState.visited = 1;
|
||||
win.refresh();
|
||||
assert.ok(
|
||||
win.detailSection.content.some((t) => t.text && /ROUTE — 1\/3 WAYPOINTS LOGGED/.test(t.text)),
|
||||
'the ROUTE line advances with the check-ins (1/3)');
|
||||
|
||||
// …and COMPLETE, when the type’s objective stands (the seam’s truth
|
||||
// again — the window just reads it back): the door stands CLAIM
|
||||
// REWARD (live — it fires the onClaim seam, the payout named).
|
||||
active.delete(posts[0].id); // the done contract drops out of the active slot
|
||||
completed.add(posts[0].id);
|
||||
win.refresh();
|
||||
assert.ok(
|
||||
win.jobsSection.content.some((t) => t.text && /CONTRACT COMPLETE/.test(t.text)),
|
||||
'the completed row shows CONTRACT COMPLETE');
|
||||
assert.equal(win.detailSection.content[win.detailSection.content.length - 1].text, 'CONTRACT COMPLETE', 'the door stands COMPLETE');
|
||||
dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||||
assert.equal(dTxt.text, 'CLAIM REWARD — 50 CR', 'the door stands CLAIM (the payout named)');
|
||||
dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||||
assert.ok(dG.__on?.pointerdown, 'the door is live while complete (the claim is a real press)');
|
||||
dG.__on.pointerdown();
|
||||
assert.deepEqual(claims, [posts[0].id], 'the claim press fired the onClaim seam');
|
||||
|
||||
// …and CLAIMED — the door stands as the claimed PLATE (REWARD
|
||||
// CLAIMED — nothing to press; a double-pay is a no-op at the ledger,
|
||||
// and the door doesn’t even offer one).
|
||||
claimed.add(posts[0].id);
|
||||
win.refresh();
|
||||
assert.ok(
|
||||
win.jobsSection.content.some((t) => t.text && /REWARD CLAIMED/.test(t.text)),
|
||||
'the claimed row shows REWARD CLAIMED (the ledger’s mark)');
|
||||
dTxt = win.detailSection.content[win.detailSection.content.length - 1];
|
||||
assert.equal(dTxt.text, 'REWARD CLAIMED', 'the door stands as the claimed plate');
|
||||
dG = win.detailSection.content[win.detailSection.content.length - 2];
|
||||
assert.equal(dG.__on?.pointerdown, undefined, 'the plate is closed (nothing to press once claimed)');
|
||||
|
||||
// the stub categories (delivery/bounty) stand LOCAL — no target
|
||||
// fields, no route seam — and their open doors are still plain ACCEPT
|
||||
// (the window’s shape is one door; the state reads off the seams).
|
||||
win._rows[1].hit.__on.pointerdown();
|
||||
assert.ok(
|
||||
!win.detailSection.content.some((t) => t.text && /DESTINATION —/.test(t.text)),
|
||||
'a local posting carries no DESTINATION line',
|
||||
);
|
||||
assert.ok(
|
||||
!win.detailSection.content.some((t) => t.text && /ROUTE —/.test(t.text)),
|
||||
'a stub posting carries no ROUTE line (no route to count)',
|
||||
);
|
||||
assert.equal(win.detailSection.content[win.detailSection.content.length - 1].text, 'ACCEPT CONTRACT', 'the stub’s open door is plain ACCEPT');
|
||||
|
||||
// SEAM FAULT — a postings seam that throws stands the standby board
|
||||
// (the console never crashes on a scene fault).
|
||||
|
|
|
|||
|
|
@ -88,9 +88,14 @@ class Rectangle {
|
|||
}
|
||||
}
|
||||
|
||||
export { Controller, BaseFilterShader, Rectangle };
|
||||
export { Controller, BaseFilterShader, Rectangle, Scene };
|
||||
|
||||
// The base scene class (js/scenes/*.js extend Phaser.Scene — the scenes
|
||||
// import cleanly headless; nothing renders in the stub world).
|
||||
class Scene {}
|
||||
|
||||
export default {
|
||||
Scene,
|
||||
GameObjects: { Container, Sprite },
|
||||
Geom: { Rectangle },
|
||||
Physics: { Arcade: { Sprite } },
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
* - quests.json is registered in data/manifest.json;
|
||||
* - the window's knobs are present (title, the 2:3 video feed — the
|
||||
* same dimensions as the research feed, sweep/glitch, the two
|
||||
* category tabs with SIDE marked standby, the toasts);
|
||||
* category tabs with SIDE LIVE (the board’s accepted contracts —
|
||||
* data/sidequests.json), the toasts);
|
||||
* - the starter quest is well-formed: Main Story, one starter flag,
|
||||
* a non-empty checklist of KNOWN requirement types, a reward that
|
||||
* fits the ship's hold;
|
||||
|
|
@ -87,15 +88,16 @@ check('quests: glitch configured', typeof quests.glitch?.enabled === 'boolean' &
|
|||
Array.isArray(quests.glitch?.intervalMs) && Array.isArray(quests.glitch?.slices));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 3. The category tabs — Main Story live, Side Quests the standby socket
|
||||
// 3. The category tabs — Main Story + Side Quests (both live — the side
|
||||
// tab lists the board's accepted contracts, data/sidequests.json)
|
||||
// ----------------------------------------------------------------------
|
||||
const tabs = quests.tabs ?? [];
|
||||
check('quests: two category tabs', tabs.length === 2);
|
||||
const mainTab = tabs.find((t) => t.id === 'main');
|
||||
const sideTab = tabs.find((t) => t.id === 'side');
|
||||
check('quests: the MAIN tab is live', !!mainTab && mainTab.standby !== true && typeof mainTab.label === 'string' && hex.test(mainTab.accent ?? ''));
|
||||
check('quests: the SIDE tab is the standby socket', !!sideTab && sideTab.standby === true && typeof sideTab.label === 'string' && hex.test(sideTab.accent ?? ''));
|
||||
check('quests: the standby note is present', typeof quests.sideStandbyNote === 'string' && quests.sideStandbyNote.length > 0);
|
||||
check('quests: the SIDE tab is live (the board’s accepted contracts)', !!sideTab && sideTab.standby !== true && typeof sideTab.label === 'string' && hex.test(sideTab.accent ?? ''));
|
||||
check('quests: the standby note is present (a standby tab, if ever authored again, toasts it)', typeof quests.sideStandbyNote === 'string' && quests.sideStandbyNote.length > 0);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 4. The toasts — starter / ready / claim / claim-denied / capital-denied
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
// dev/sidequests.test.mjs â the JOB BOARD’s model, verified headless
|
||||
// dev/sidequests.test.mjs — the JOB BOARD’s model, verified headless
|
||||
// (node dev/sidequests.test.mjs — no browser, no Phaser render).
|
||||
//
|
||||
// What this pins (the board’s contract â data/sidequests.json):
|
||||
// What this pins (the board’s contract — data/sidequests.json):
|
||||
// - the CATEGORY GATE — the four types (patrol / delivery / bounty /
|
||||
// escort) are all accounted for, all OFFLINE by default, and a
|
||||
// category’s jobs appear on the board ONLY when its `enabled` flag
|
||||
// flips (flip one â only that type’s postings; flip all â the
|
||||
// per-system count; flip the feature’s master switch â an empty
|
||||
// board).
|
||||
// escort) are all accounted for; PATROL is the first LIVE category
|
||||
// (its rules land with this pass) while the other three stay
|
||||
// OFFLINE, and a category’s jobs appear on the board ONLY when its
|
||||
// `enabled` flag flips (flip the rest → the per-system count
|
||||
// spreads; flip the feature’s master switch → an empty board).
|
||||
// - DETERMINISM — the same contract stands on the same system across
|
||||
// calls (seed + record → the same postings — visits, scene restarts,
|
||||
// saves), a different seed draws its own board, and different
|
||||
// systems draw different boards (no global call-order dependence).
|
||||
// - The per-system COUNT (3â4 by design, within the data’s range),
|
||||
// - The per-system COUNT (3–4 by design, within the data’s range),
|
||||
// unique ids, and the slots spreading across the live types.
|
||||
// - The CONTEXT voice — the NEUTRAL pools are the pirate-safety voice
|
||||
// (the data’s contract: 2 of the 3 variants per category name the
|
||||
|
|
@ -20,20 +20,30 @@
|
|||
// factions-out voice (every variant carries {enemies}), and the
|
||||
// placeholders ({system}/{faction}/{issuer}/{enemies}) resolve to
|
||||
// the system’s values (no brace pair ever ships to the player).
|
||||
// - The REWARDS â inside the category’s band (scaled by the system’s
|
||||
// - The REWARDS — inside the category’s band (scaled by the system’s
|
||||
// distance d), in 5 CR steps, floored at 5 CR.
|
||||
// - The LEDGER (SideQuestState) — the accepted/completed marks: the
|
||||
// idempotent accept, the completed ⊆ accepted invariant, the save
|
||||
// round-trip (toJSON/fromJSON), the malformed-record degradation
|
||||
// (a save predating the feature loads empty; a foreign id drops
|
||||
// against the save’s galaxy job space), and the job space itself
|
||||
// (allSideQuestJobIds = systems × slots × the four types).
|
||||
// - The PATROL’s TARGET rule — the posting system by default; a
|
||||
// NEIGHBOR jumpgate-adjacent system (a different non-neutral
|
||||
// faction, at least neighborMinD from home) under the seeded
|
||||
// neighborChance; the posting’s fields carry the target.
|
||||
// - The PATROL’s ROUTE — buildPatrolRoute’s deterministic waypoints:
|
||||
// inside the region (inset), clear of the anchors, separated, the
|
||||
// count in the data’s range, and a completable fallback (an empty
|
||||
// region still stands a route — a patrol must always finish).
|
||||
// - The LEDGER (SideQuestState) — the accepted / active / completed /
|
||||
// claimed marks: the idempotent accept/complete/claim, the ONE
|
||||
// ACTIVE slot (the top accepted stands; a done/claimed contract
|
||||
// drops out), the ROUTE records (the patrol’s waypoints + the
|
||||
// visited marks), the save round-trip (toJSON/fromJSON), and the
|
||||
// malformed-record degradation (a save predating the feature loads
|
||||
// empty; a foreign id drops against the save’s galaxy job space).
|
||||
//
|
||||
// The model is pure ES (js/quests/SideQuests.js); the data-driven
|
||||
// contract lives in data/sidequests.json. The scene’s seams
|
||||
// (GameScene.sideQuestPostings / acceptSideQuest) and the window’s
|
||||
// rendering run over the same functions this pins â the window’s
|
||||
// headless pass stands in dev/jobboard.test.mjs.
|
||||
// (GameScene.sideQuestPostings / acceptSideQuest / setActiveSideQuest /
|
||||
// claimSideQuest) and the window’s rendering run over the same
|
||||
// functions this pins — the window’s headless pass stands in
|
||||
// dev/jobboard.test.mjs.
|
||||
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
|
|
@ -41,6 +51,7 @@ import { pathToFileURL } from 'node:url';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only — the scene’s seams)
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -67,6 +78,8 @@ const {
|
|||
rollSideQuestReward,
|
||||
allSideQuestJobIds,
|
||||
SideQuestState,
|
||||
patrolTargetSystem,
|
||||
buildPatrolRoute,
|
||||
} = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
|
||||
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
|
||||
|
||||
|
|
@ -92,20 +105,22 @@ const setEnabled = (map) => {
|
|||
config.data.sidequests.categories[cat].enabled = !!map[cat];
|
||||
}
|
||||
};
|
||||
// The data’s default gates (PATROL live — the first category whose
|
||||
// rules landed; the other three OFFLINE until theirs do).
|
||||
const restoreDefaultGates = () => setEnabled({ patrol: true });
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the category gate — the four types, all OFFLINE by default ==');
|
||||
console.log('== the category gate — patrol LIVE by default, the rest OFFLINE ==');
|
||||
{
|
||||
assert.deepEqual(SIDE_QUEST_CATEGORIES, ['patrol', 'delivery', 'bounty', 'escort'], 'the four types, canonical slot order');
|
||||
assert.equal(sideQuestsEnabled(), true, 'the feature’s master switch is ON (the data’s enabled flag)');
|
||||
assert.deepEqual(enabledSideQuestCategories(), [], 'all four OFFLINE until their rules land â the board’s face is standby');
|
||||
assert.deepEqual(sideQuestPostingsFor(SEED, REC_NEUTRAL), [], 'the default board is the standby (no postings)');
|
||||
assert.deepEqual(enabledSideQuestCategories(), ['patrol'], 'PATROL is the first live category — the board stands live');
|
||||
assert.equal(sideQuestCategoryMeta('huntr'), null, 'an unknown category → null (no fabrication)');
|
||||
|
||||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||||
const meta = sideQuestCategoryMeta(cat);
|
||||
assert.equal(meta.id, cat, `${cat}: its meta stands`);
|
||||
assert.equal(meta.enabled, false, `${cat}: OFFLINE by default`);
|
||||
assert.equal(meta.enabled, cat === 'patrol', `${cat}: live iff its gate says so (patrol live, the rest OFFLINE by default)`);
|
||||
assert.ok(meta.label.length > 0, `${cat}: a label for the board’s rows`);
|
||||
assert.match(meta.accent, /^#[0-9a-f]{6}$/i, `${cat}: a row-chip accent`);
|
||||
const band = DATA.sidequests.rewards[cat];
|
||||
|
|
@ -119,13 +134,20 @@ console.log('== the category gate — the four types, all OFFLINE by default =='
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The DEFAULT board stands live with the live category’s postings
|
||||
// (patrol — the other three gates stay OFFLINE).
|
||||
const def = sideQuestPostingsFor(SEED, REC_NEUTRAL);
|
||||
const [lo, hi] = sideQuestCountRange();
|
||||
assert.ok(def.length >= lo && def.length <= hi, `the default board: ${def.length} postings within [${lo},${hi}]`);
|
||||
assert.ok(def.every((p) => p.category === 'patrol'), 'the default board posts only the live type (patrol)');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the gate flips — one type live → only that type posts ==');
|
||||
console.log('== the gate flips — patrol live → only patrol posts; all live → the spread ==');
|
||||
{
|
||||
setEnabled({ patrol: true });
|
||||
try {
|
||||
restoreDefaultGates();
|
||||
{
|
||||
assert.deepEqual(enabledSideQuestCategories(), ['patrol'], 'one live category');
|
||||
const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL);
|
||||
const [lo, hi] = sideQuestCountRange();
|
||||
|
|
@ -147,14 +169,8 @@ console.log('== the gate flips — one type live → only that type posts ==');
|
|||
assert.ok(p.reward.credits >= 5, 'the 5 CR floor');
|
||||
assert.deepEqual(p.objective, { type: 'patrol' }, 'the completion socket stands typed (the type’s rules fill it in)');
|
||||
}
|
||||
} finally {
|
||||
setEnabled({});
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== all four live — the count, the determinism, the voice ==');
|
||||
{
|
||||
setEnabled({ patrol: true, delivery: true, bounty: true, escort: true });
|
||||
try {
|
||||
const [lo, hi] = sideQuestCountRange();
|
||||
|
|
@ -172,11 +188,11 @@ console.log('== all four live — the count, the determinism, the voice ==');
|
|||
const b = sideQuestPostingsFor(SEED, REC_ZENITH);
|
||||
assert.deepEqual(b, a, 'the same contract stands across calls');
|
||||
assert.notDeepEqual(sideQuestPostingsFor(SEED + 7, REC_ZENITH), a, 'a different seed → a different board');
|
||||
// â¦and the systems don’t share one ad (the variant pools + the
|
||||
// …and the systems don’t share one ad (the variant pools + the
|
||||
// per-posting draws stand per system).
|
||||
assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX), a, 'a different system draws its own board');
|
||||
|
||||
// NEUTRAL voice â the pirate-safety pools (the data’s contract: the
|
||||
// NEUTRAL voice — the pirate-safety pools (the data’s contract: the
|
||||
// majority of each neutral pool names the pirates/raiders).
|
||||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||||
const pool = DATA.sidequests.descriptions.neutral[cat];
|
||||
|
|
@ -216,7 +232,7 @@ console.log('== all four live — the count, the determinism, the voice ==');
|
|||
assert.ok(b0 <= b1, `${cat}: the band is ordered`);
|
||||
}
|
||||
|
||||
// The FEATURE’s master switch â off â the board empties regardless
|
||||
// The FEATURE’s master switch — off → the board empties regardless
|
||||
// of the category flags (the kill-switch contract).
|
||||
config.data.sidequests.enabled = false;
|
||||
try {
|
||||
|
|
@ -225,12 +241,12 @@ console.log('== all four live — the count, the determinism, the voice ==');
|
|||
config.data.sidequests.enabled = true;
|
||||
}
|
||||
} finally {
|
||||
setEnabled({});
|
||||
restoreDefaultGates();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the placeholder resolution (the pool’s {â¦} keys â the context) ==');
|
||||
console.log('== the placeholder resolution (the pool’s {…} keys → the context) ==');
|
||||
{
|
||||
const ctx = { context: 'faction', system: 'Zenith Verge', faction: 'Zenith', issuer: 'Zenith Concord', enemies: 'Apex and MindWeave' };
|
||||
assert.equal(resolveSideQuestText('Holds {system} against the {enemies}.', ctx), 'Holds Zenith Verge against the Apex and MindWeave.', 'every placeholder resolves to the context');
|
||||
|
|
@ -241,7 +257,145 @@ console.log('== the placeholder resolution (the pool’s {â¦} keys â th
|
|||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the ledger â SideQuestState (the run’s accepted/completed marks) ==');
|
||||
console.log('== the patrol’s TARGET rule (the posting’s system, or a NEIGHBOR’s) ==');
|
||||
{
|
||||
// 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
|
||||
// push-out neighbor — and a gate to sys-neutral (neutral: never a
|
||||
// patrol target) and to itself (same faction: never a target).
|
||||
const galaxy = {
|
||||
byId: new Map([
|
||||
[REC_ZENITH.id, { ...REC_ZENITH, d: 0.6 }],
|
||||
[REC_APEX.id, { ...REC_APEX, d: 0.8 }],
|
||||
['sys-neutral2', { id: 'sys-neutral2', name: 'Drift Line', faction: null, d: 0.7, zone: 'far', type: 'void', capital: false }],
|
||||
]),
|
||||
jumpGatesFor: (id) => {
|
||||
const links = {
|
||||
[REC_ZENITH.id]: [REC_APEX.id, 'sys-neutral2', REC_ZENITH.id],
|
||||
};
|
||||
return (links[id] ?? []).map((to) => galaxy.byId.get(to)).filter(Boolean);
|
||||
},
|
||||
};
|
||||
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)');
|
||||
assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 2), { byId: new Map(), jumpGatesFor: () => [] }), null, 'no links → null (the posting’s own system stands)');
|
||||
|
||||
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)');
|
||||
|
||||
const close = { id: 'sys-close', name: 'Close Post', faction: 'zenith', d: 0.1 };
|
||||
assert.equal(patrolTargetSystem(close, Rng.derive(SEED, 't', 4), galaxy), null, 'too close to home (< neighborMinD) → the posting’s own system stands');
|
||||
|
||||
// A far FACTION system with a different-faction gate: the seeded draw
|
||||
// (neighborChance) splits between the two — over many seeds BOTH
|
||||
// outcomes must stand (the draw is real, not pinned), and the
|
||||
// neighbor, when chosen, is always a DIFFERENT non-neutral faction.
|
||||
const picks = new Set();
|
||||
for (let i = 0; i < 64; i++) {
|
||||
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');
|
||||
|
||||
// The POSTING’s fields carry the target (the board’s DESTINATION line
|
||||
// + the scene’s route establishment read them).
|
||||
const posts = sideQuestPostingsFor(SEED, rec, galaxy).filter((p) => p.category === 'patrol');
|
||||
assert.ok(posts.length >= 1, 'a faction system posts patrols');
|
||||
for (const p of posts) {
|
||||
assert.equal(typeof p.targetSystemId, 'string', 'the patrol’s target system id stands');
|
||||
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(typeof p.targetSystemName, 'string', 'the patrol’s target system name stands');
|
||||
assert.equal(p.targetSystemName, galaxy.byId.get(p.targetSystemId).name, 'the target name resolves to the target id');
|
||||
}
|
||||
// The non-patrol categories (the stubs) carry no target fields.
|
||||
setEnabled({ patrol: true, delivery: true });
|
||||
try {
|
||||
const all = sideQuestPostingsFor(SEED, rec, galaxy);
|
||||
const del = all.find((p) => p.category === 'delivery');
|
||||
assert.ok(del, 'a delivery stub stands alongside the patrols');
|
||||
assert.equal(del.targetSystemId, undefined, 'the stub categories carry no target (their rules land later)');
|
||||
} finally {
|
||||
restoreDefaultGates();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the patrol’s ROUTE — buildPatrolRoute (the deterministic waypoints) ==');
|
||||
{
|
||||
const [lo, hi] = [
|
||||
Number(config.get('sidequests.patrol.waypoints', [3, 5])[0]),
|
||||
Number(config.get('sidequests.patrol.waypoints', [3, 5])[1]),
|
||||
];
|
||||
const inset = Number(config.get('sidequests.patrol.regionInset', 96));
|
||||
const clearance = Number(config.get('sidequests.patrol.anchorClearance', 1100));
|
||||
const sep = Number(config.get('sidequests.patrol.minSeparation', 1500));
|
||||
|
||||
// A single generous region (radius 2000 about the origin) + one
|
||||
// anchor at the center: the feasible set is the annulus
|
||||
// [clearance, 2000−inset] — the route must stand entirely inside it.
|
||||
const region = [{ x: 0, y: 0, radius: 2000 }];
|
||||
const anchors = [{ x: 0, y: 0 }];
|
||||
const r1 = buildPatrolRoute(SEED, 'sq-x-patrol-0', 'sys-x', region, anchors);
|
||||
const r2 = buildPatrolRoute(SEED, 'sq-x-patrol-0', 'sys-x', region, anchors);
|
||||
assert.deepEqual(r2, r1, 'determinism: same seed + contract + target → the SAME route (re-visits / scene restarts / saves)');
|
||||
assert.notDeepEqual(
|
||||
buildPatrolRoute(SEED + 13, 'sq-x-patrol-0', 'sys-x', region, anchors),
|
||||
r1,
|
||||
'a different seed draws its own route',
|
||||
);
|
||||
assert.ok(r1.waypoints.length >= lo && r1.waypoints.length <= hi, `${r1.waypoints.length} waypoints within the data’s range [${lo},${hi}]`);
|
||||
for (const [x, y] of r1.waypoints) {
|
||||
const d = Math.hypot(x, y);
|
||||
assert.ok(d >= clearance, `waypoint ${x},${y}: clear of the anchor (≥ ${clearance})`);
|
||||
assert.ok(d <= 2000 - inset + 1e-6, `waypoint ${x},${y}: inside the region’s inset (≤ ${2000 - inset})`);
|
||||
}
|
||||
for (let i = 0; i < r1.waypoints.length; i++) {
|
||||
for (let j = i + 1; j < r1.waypoints.length; j++) {
|
||||
const [ax, ay] = r1.waypoints[i];
|
||||
const [bx, by] = r1.waypoints[j];
|
||||
assert.ok(Math.hypot(ax - bx, ay - by) >= sep - 1e-6, `waypoints ${i},${j}: separated (≥ ${sep})`);
|
||||
}
|
||||
}
|
||||
|
||||
// MULTIPLE regions (the unlocked-tether case): every waypoint lands in
|
||||
// SOME region (inset) — the union is the flight envelope.
|
||||
const region2 = [
|
||||
{ x: -3000, y: 0, radius: 900 },
|
||||
{ x: 3000, y: 0, radius: 900 },
|
||||
];
|
||||
const anchors2 = [{ x: -3000, y: 0 }, { x: 3000, y: 0 }];
|
||||
const rm = buildPatrolRoute(SEED, 'sq-x-patrol-1', 'sys-x', region2, anchors2);
|
||||
assert.ok(rm.waypoints.length >= lo, `the multi-region route stands ${rm.waypoints.length} waypoints`);
|
||||
for (const [x, y] of rm.waypoints) {
|
||||
const inSome = region2.some((rg) => Math.hypot(x - rg.x, y - rg.y) <= rg.radius - inset + 1e-6);
|
||||
assert.ok(inSome, `waypoint ${x},${y}: inside some region of the envelope`);
|
||||
}
|
||||
|
||||
// The DEGENERATE case — an empty region (a barren system): the route
|
||||
// still stands (a patrol must ALWAYS be completable) — the fallback
|
||||
// disc (level-1 tether radius about the origin).
|
||||
const L1 = Number(config.get('tether.level1Radius', 5120)); // the fallback disc’s scale (data/tether.json’s level-1 radius)
|
||||
const r0 = buildPatrolRoute(SEED, 'sq-x-patrol-2', 'sys-x', [], []);
|
||||
assert.ok(r0.waypoints.length >= lo && r0.waypoints.length <= hi, `the fallback route stands ${r0.waypoints.length} waypoints (the range)`);
|
||||
for (const [x, y] of r0.waypoints) {
|
||||
assert.ok(Math.hypot(x, y) <= L1 + 1e-6, `fallback waypoint ${x},${y}: inside the level-1 disc`);
|
||||
}
|
||||
|
||||
// A TIGHT region (inset + clearance leave no room): the clearance
|
||||
// relaxes (the waypoint count and the region never do).
|
||||
const tight = [{ x: 0, y: 0, radius: 900 }]; // inset → 804 < clearance → the annulus is empty
|
||||
const rt = buildPatrolRoute(SEED, 'sq-x-patrol-3', 'sys-x', tight, anchors);
|
||||
assert.ok(rt.waypoints.length >= lo, `the tight region still stands ${rt.waypoints.length} waypoints (the count never relaxes)`);
|
||||
for (const [x, y] of rt.waypoints) {
|
||||
assert.ok(Math.hypot(x, y) <= 900 - inset + 1e-6, 'the REGION is never relaxed (the flight envelope holds)');
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the ledger — SideQuestState (the run’s accepted/active/completed/claimed marks) ==');
|
||||
{
|
||||
const st = new SideQuestState();
|
||||
assert.equal(st.accept('sq-a-patrol-0'), true, 'a new accept lands');
|
||||
|
|
@ -249,38 +403,279 @@ console.log('== the ledger â SideQuestState (the run’s accepted/completed
|
|||
assert.equal(st.isAccepted('sq-a-patrol-0'), true, 'isAccepted');
|
||||
assert.equal(st.complete('sq-a-bounty-0'), false, 'only an ACCEPTED contract can complete (completed ⊆ accepted)');
|
||||
assert.equal(st.accept('sq-a-bounty-0'), true, 'a second contract is accepted');
|
||||
assert.deepEqual(st.acceptedList(), ['sq-a-patrol-0', 'sq-a-bounty-0'], 'the taken order stands (the active slot’s "top")');
|
||||
assert.equal(st.complete('sq-a-bounty-0'), true, 'a taken contract completes');
|
||||
assert.equal(st.complete('sq-a-bounty-0'), false, 'complete is idempotent too');
|
||||
assert.equal(st.isCompleted('sq-a-bounty-0'), true, 'isCompleted');
|
||||
assert.equal(st.isCompleted('sq-a-patrol-0'), false, '…while its sibling stays accepted, not complete');
|
||||
|
||||
// The save round-trip â the ledger is the save’s side-quest state
|
||||
// The ONE ACTIVE slot — no active → the TOP accepted stands; a
|
||||
// contract completed/claimed drops out (the next top stands); the
|
||||
// player’s explicit set stands over the default.
|
||||
assert.equal(st.reconcileActive(), 'sq-a-patrol-0', 'the top accepted stands (the one not done/claimed)');
|
||||
assert.equal(st.isActive('sq-a-patrol-0'), true, 'isActive');
|
||||
assert.equal(st.setActive('sq-a-bounty-0'), false, 'a DONE contract can’t stand active');
|
||||
assert.equal(st.setActive('sq-a-patrol-0'), true, 'an explicit set stands');
|
||||
assert.equal(st.activeId, 'sq-a-patrol-0', 'the slot holds the set contract');
|
||||
st.accept('sq-a-bounty-0'); // already taken — a no-op
|
||||
st.complete('sq-a-patrol-0'); // done → it drops out of the slot
|
||||
assert.equal(st.activeId, null, 'both contracts done → no active contract');
|
||||
|
||||
// CLAIM — idempotent, completed ⊆ claimed enforced.
|
||||
const stc = new SideQuestState();
|
||||
stc.accept('sq-c-patrol-0');
|
||||
stc.complete('sq-c-patrol-0');
|
||||
assert.equal(stc.claim('sq-c-patrol-0'), true, 'a completed contract claims');
|
||||
assert.equal(stc.claim('sq-c-patrol-0'), false, 'the idempotent claim (a double-press can’t double-pay)');
|
||||
assert.equal(stc.isClaimed('sq-c-patrol-0'), true, 'isClaimed');
|
||||
const stx = new SideQuestState();
|
||||
stx.accept('sq-x-patrol-0');
|
||||
assert.equal(stx.claim('sq-x-patrol-0'), false, 'an UNCOMPLETED contract can’t claim (claimed ⊆ completed)');
|
||||
stx.complete('sq-x-patrol-0');
|
||||
stx.claim('sq-x-patrol-0');
|
||||
assert.equal(stx.reconcileActive(), null, 'a claimed contract drops out of the active slot (nothing stands)');
|
||||
stx.accept('sq-x-patrol-1');
|
||||
assert.equal(stx.reconcileActive(), 'sq-x-patrol-1', 'the next top accepted stands');
|
||||
|
||||
// The ROUTE records (the patrol’s waypoints + the visited marks).
|
||||
const rtr = new SideQuestState();
|
||||
rtr.accept('sq-r-patrol-0');
|
||||
assert.equal(rtr.setRoute('sq-r-foreign-0', { waypoints: [[1, 2]] }), false, 'a route for an UNACCEPTED contract drops');
|
||||
assert.equal(rtr.setRoute('sq-r-patrol-0', null), false, 'a null route drops');
|
||||
const route = { targetSystemId: 'sys-r', credits: 90, waypoints: [[100, 200], [300, 400], [500, 600]], visited: [] };
|
||||
assert.equal(rtr.setRoute('sq-r-patrol-0', route), true, 'a route stands for a taken contract');
|
||||
assert.equal(rtr.routeFor('sq-r-patrol-0').credits, 90, 'the contract’s payout is fixed on the route');
|
||||
assert.equal(rtr.routeFor('sq-r-other-0'), null, 'routeFor: no route → null');
|
||||
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 7), false, 'an out-of-range index doesn’t log');
|
||||
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), true, 'a waypoint logs (any order — the route is a set of points)');
|
||||
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), false, 'the idempotent visit (a per-frame pass can’t double-log)');
|
||||
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), true, 'another waypoint logs');
|
||||
assert.deepEqual(rtr.routeFor('sq-r-patrol-0').visited, [1, 0], 'the visited marks stand (in log order)');
|
||||
// Malformed route records degrade (the finite pairs keep, the rest drops).
|
||||
const rtm = new SideQuestState();
|
||||
rtm.accept('sq-m-patrol-0');
|
||||
rtm.setRoute('sq-m-patrol-0', { waypoints: [[1, 2], [NaN, 9], ['x'], 42], visited: [0, 0, 9] });
|
||||
assert.deepEqual(rtm.routeFor('sq-m-patrol-0').waypoints, [[1, 2]], 'the malformed waypoints degrade to the finite pairs');
|
||||
assert.deepEqual(rtm.routeFor('sq-m-patrol-0').visited, [0], 'the visited indices dedupe + range-check');
|
||||
|
||||
// The save round-trip — the ledger is the save’s side-quest state
|
||||
// (the postings themselves regenerate from the seed: a save = seed +
|
||||
// player state).
|
||||
const json = st.toJSON();
|
||||
assert.deepEqual(json, { accepted: ['sq-a-patrol-0', 'sq-a-bounty-0'], completed: ['sq-a-bounty-0'] }, 'the toJSON shape');
|
||||
assert.deepEqual(json, {
|
||||
accepted: ['sq-a-patrol-0', 'sq-a-bounty-0'],
|
||||
completed: ['sq-a-bounty-0', 'sq-a-patrol-0'],
|
||||
claimed: [],
|
||||
activeId: null,
|
||||
routes: {},
|
||||
}, 'the toJSON shape (accepted/completed/claimed/activeId/routes)');
|
||||
const back = SideQuestState.fromJSON(json);
|
||||
assert.deepEqual(back.toJSON(), json, 'fromJSON(toJSON) is the identity');
|
||||
assert.deepEqual(back.acceptedList(), st.acceptedList(), 'the taken order survives the round-trip');
|
||||
|
||||
// A save WITH a route + an active slot — the scene’s check-ins and the
|
||||
// board’s route line read them after a reload.
|
||||
const live = new SideQuestState();
|
||||
live.accept('sq-s-patrol-0');
|
||||
live.accept('sq-s-patrol-1');
|
||||
live.setActive('sq-s-patrol-1');
|
||||
live.setRoute('sq-s-patrol-0', { targetSystemId: 'sys-s', credits: 60, waypoints: [[1, 1], [2, 2]], visited: [0] });
|
||||
const liveJson = live.toJSON();
|
||||
const backLive = SideQuestState.fromJSON(liveJson);
|
||||
assert.equal(backLive.activeId, 'sq-s-patrol-1', 'the saved ACTIVE slot stands');
|
||||
assert.deepEqual(backLive.routeFor('sq-s-patrol-0').visited, [0], 'the saved visited marks stand');
|
||||
assert.equal(backLive.routeFor('sq-s-patrol-1'), null, 'a contract without a route stays route-less');
|
||||
assert.equal(backLive.visitWaypoint('sq-s-patrol-0', 1), true, 'check-ins resume on the restored route');
|
||||
|
||||
// A save predating the feature — no field → an empty ledger, no throw.
|
||||
assert.deepEqual(SideQuestState.fromJSON(null).toJSON(), { accepted: [], completed: [] }, 'a pre-feature save loads empty (old saves load)');
|
||||
assert.deepEqual(SideQuestState.fromJSON('garbage').toJSON(), { accepted: [], completed: [] }, 'a malformed record degrades, not throws');
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: 'x', completed: 42 }).toJSON(), { accepted: [], completed: [] }, 'wrong shapes are dropped');
|
||||
assert.deepEqual(SideQuestState.fromJSON(null).toJSON(), { accepted: [], completed: [], claimed: [], activeId: null, routes: {} }, 'a pre-feature save loads empty (old saves load)');
|
||||
assert.deepEqual(SideQuestState.fromJSON('garbage').toJSON(), { accepted: [], completed: [], claimed: [], activeId: null, routes: {} }, 'a malformed record degrades, not throws');
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: 'x', completed: 42 }).toJSON(), { accepted: [], completed: [], claimed: [], activeId: null, routes: {} }, 'wrong shapes are dropped');
|
||||
|
||||
// completed ⊆ accepted is RE-ESTABLISHED on restore.
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-z-bounty-9'] }).toJSON(), { accepted: ['sq-a-patrol-0'], completed: [] }, 'a completed id with no accept drops');
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'] }).toJSON(), { accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'] }, 'a completed-inside-accepted entry survives');
|
||||
// completed ⊆ accepted and claimed ⊆ completed are RE-ESTABLISHED on
|
||||
// restore; the active slot reconciles (a done saved-active drops out,
|
||||
// the top accepted stands instead).
|
||||
const base = { accepted: ['sq-a-patrol-0'], completed: [], claimed: [], activeId: null, routes: {} };
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-z-bounty-9'] }).toJSON(), { ...base, completed: [], activeId: 'sq-a-patrol-0' }, 'a completed id with no accept drops (the top accepted stands active)');
|
||||
assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'], claimed: ['sq-a-patrol-0'] }).toJSON(), { ...base, completed: ['sq-a-patrol-0'], claimed: ['sq-a-patrol-0'] }, 'a completed+claimed entry inside accepted survives');
|
||||
assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0', 'sq-a-patrol-1'], completed: ['sq-a-patrol-0'], activeId: 'sq-a-patrol-0' }).activeId, 'sq-a-patrol-1', 'a DONE saved-active drops out — the top accepted 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');
|
||||
|
||||
// The job-space guard â ids the save’s galaxy never posted are dropped
|
||||
// (a hand-edited or foreign ledger degrades instead of sticking).
|
||||
// The job-space guard — ids the save’s galaxy never posted are dropped
|
||||
// (a hand-edited or foreign ledger degrades instead of sticking),
|
||||
// routes included.
|
||||
const known = allSideQuestJobIds(RECORDS);
|
||||
const [lo, hi] = sideQuestCountRange();
|
||||
assert.equal(known.size, RECORDS.length * hi * SIDE_QUEST_CATEGORIES.length, 'the space = systems × the slot range × the four types');
|
||||
const [lo2, hi2] = sideQuestCountRange();
|
||||
assert.equal(known.size, RECORDS.length * hi2 * SIDE_QUEST_CATEGORIES.length, 'the space = systems × the slot range × the four types');
|
||||
assert.ok(known.has('sq-sys-zenith-patrol-0'), 'a real job id stands in the space');
|
||||
assert.ok(!known.has('sq-nowhere-patrol-0'), 'a foreign system id is outside the space');
|
||||
const guarded = SideQuestState.fromJSON({ accepted: ['sq-sys-zenith-patrol-0', 'sq-nowhere-patrol-0'], completed: [] }, known);
|
||||
const guarded = SideQuestState.fromJSON(
|
||||
{
|
||||
accepted: ['sq-sys-zenith-patrol-0', 'sq-nowhere-patrol-0'],
|
||||
completed: [],
|
||||
claimed: [],
|
||||
activeId: null,
|
||||
routes: { 'sq-sys-zenith-patrol-0': { targetSystemId: 'x', credits: 10, waypoints: [[1, 1]], visited: [] }, 'sq-nowhere-patrol-0': { waypoints: [[1, 1]], visited: [] } },
|
||||
},
|
||||
known,
|
||||
);
|
||||
assert.deepEqual(guarded.toJSON().accepted, ['sq-sys-zenith-patrol-0'], 'the foreign id drops; the real one stands');
|
||||
assert.deepEqual(SideQuestState.fromJSON(null, known).toJSON(), { accepted: [], completed: [] }, 'a pre-feature save loads empty even with a job space');
|
||||
assert.equal(guarded.routeFor('sq-nowhere-patrol-0'), null, 'a foreign route drops with its id');
|
||||
assert.ok(guarded.routeFor('sq-sys-zenith-patrol-0'), 'the real route stands');
|
||||
assert.deepEqual(SideQuestState.fromJSON(null, known).toJSON(), { accepted: [], completed: [], claimed: [], activeId: null, routes: {} }, 'a pre-feature save loads empty even with a job space');
|
||||
}
|
||||
|
||||
console.log('\nsidequests: OK â the board’s model (the gate, the determinism, the voice, the ledger)');
|
||||
// ===========================================================================
|
||||
console.log('== the scene’s seams — the QUESTS console + tracker see the accepted contracts ==');
|
||||
{
|
||||
// The scene’s seams (GameScene.questSnapshot / claimQuest /
|
||||
// setPriorityQuest / claimSideQuest) run over the SAME ledger + postings
|
||||
// this file pins — called headless on a stub `this` (the jobboard’s
|
||||
// makeScene pattern): the ledger is the real SideQuestState, the story
|
||||
// ledger the real (ungranted) QuestState, the galaxy the hand records.
|
||||
const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href);
|
||||
const { QuestState } = await import(pathToFileURL(join(__dirname, '../js/quests/QuestState.js')).href);
|
||||
|
||||
const makeScene = () => {
|
||||
const state = {
|
||||
credits: 0,
|
||||
toasts: [],
|
||||
sfx: [],
|
||||
refreshes: 0,
|
||||
};
|
||||
const scene = {
|
||||
sideQuestState: new SideQuestState(),
|
||||
questState: new QuestState(),
|
||||
galaxy: { seed: SEED, byId: new Map(RECORDS.map((r) => [r.id, r])) },
|
||||
ship: { addCredits: (n) => { state.credits += n; } },
|
||||
refreshCreditsHud: () => {},
|
||||
playSfx: (n) => state.sfx.push(n),
|
||||
consoleToast: (label, o) => state.toasts.push({ label: String(label), o }),
|
||||
questWindow: { refresh: () => state.refreshes++ },
|
||||
state,
|
||||
};
|
||||
// The scene's seam methods under test — the REAL prototype functions,
|
||||
// bound onto the stub `this` (their internal `this.*` calls land on
|
||||
// the stub's ledger/galaxy/ship seams).
|
||||
for (const k of ['questSnapshot', '_sideQuestView', '_sideQuestPosting', 'claimQuest', 'claimSideQuest', 'setPriorityQuest']) {
|
||||
scene[k] = GameScene.prototype[k];
|
||||
}
|
||||
return scene;
|
||||
};
|
||||
const call = (fn, scene, ...args) => fn.call(scene, ...args);
|
||||
|
||||
// A REAL posting id on the neutral record (the board stands it —
|
||||
// sq-<systemId>-patrol-<slot>), and its deterministic reward.
|
||||
const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL);
|
||||
const job = posts.find((p) => p.category === 'patrol');
|
||||
assert.ok(job, 'the neutral board stands a patrol (the live category)');
|
||||
|
||||
// --- the snapshot: the SIDE QUESTS tab’s rows -------------------------
|
||||
const scene = makeScene();
|
||||
const callQuestSnapshot = () => GameScene.prototype.questSnapshot.call(scene);
|
||||
const sideRows = (snap) => (snap.quests ?? []).filter((q) => q.category === 'side');
|
||||
|
||||
assert.equal(sideRows(callQuestSnapshot()).length, 0, 'no accepted contracts → the side tab is empty (the story rows stand alone)');
|
||||
|
||||
scene.sideQuestState.accept(job.id);
|
||||
let row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id);
|
||||
assert.ok(row, 'the ACCEPTED contract stands in the snapshot (the tab lists it)');
|
||||
assert.equal(row.title, job.title, '…on the posting’s title');
|
||||
assert.equal(row.issuer, job.issuer, '…on the posting’s issuer');
|
||||
assert.equal(row.claimed, false, '…not yet claimed');
|
||||
assert.equal(row.complete, false, '…not complete before the route stood (no route, no waypoints)');
|
||||
|
||||
// The route lands (the accept seam’s job — buildPatrolRoute on the
|
||||
// target’s region): the checklist is its waypoints, LIVE.
|
||||
scene.sideQuestState.setRoute(job.id, { targetSystemId: REC_NEUTRAL.id, credits: 77, waypoints: [[10, 20], [300, 400], [7, 8]], visited: [] });
|
||||
row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id);
|
||||
assert.equal(row.checks.length, 3, 'the checklist = the route’s waypoints (three)');
|
||||
assert.equal(row.progress, '0/3', 'progress 0/3');
|
||||
assert.deepEqual(row.reward, '77 CR', 'the reward = the contract’s payout (fixed at the accept)');
|
||||
assert.equal(row.active, false, 'accepted but not yet ACTIVE (the board’s SET PATROL ACTIVE door stands)');
|
||||
scene.sideQuestState.setActive(job.id);
|
||||
row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id);
|
||||
assert.equal(row.active, true, '…and once stood active (the scene’s setActive seam), it reads ACTIVE');
|
||||
|
||||
scene.sideQuestState.visitWaypoint(job.id, 0);
|
||||
row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id);
|
||||
assert.equal(row.checks[0].done, true, 'a logged waypoint reads done in the checklist');
|
||||
assert.equal(row.checks[0].detail, 'LOGGED', '…with its LOGGED detail');
|
||||
assert.equal(row.checks[1].done, false, '…and the next one stands open');
|
||||
assert.equal(row.progress, '1/3', 'progress 1/3');
|
||||
assert.equal(row.complete, false, 'not complete until every waypoint is logged');
|
||||
|
||||
scene.sideQuestState.visitWaypoint(job.id, 1);
|
||||
scene.sideQuestState.visitWaypoint(job.id, 2);
|
||||
scene.sideQuestState.complete(job.id); // the scene’s updatePatrol does this on the last check-in
|
||||
row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id);
|
||||
assert.equal(row.complete, true, 'every waypoint logged → COMPLETE (the READY row)');
|
||||
assert.equal(row.claimed, false, '…until its reward is claimed');
|
||||
assert.equal(row.active, false, 'the done contract drops out of the ACTIVE slot (reconcileActive)');
|
||||
|
||||
// A foreign accepted id (outside the galaxy’s job space) never stands
|
||||
// a row — no posting to read it from, no crash.
|
||||
scene.sideQuestState.accept('sq-nowhere-patrol-0');
|
||||
assert.equal(sideRows(callQuestSnapshot()).length, 1, 'a foreign contract stands no row (degrades, doesn’t fabricate)');
|
||||
|
||||
// --- the claim seam: the console/tracker’s CLAIM REWARD ---------------
|
||||
const before = scene.state.credits;
|
||||
call(GameScene.prototype.claimQuest, scene, job.id);
|
||||
assert.equal(scene.state.credits, before + 77, 'the claim pays the contract’s payout (the route’s credits)');
|
||||
assert.equal(scene.sideQuestState.isClaimed(job.id), true, '…and marks it claimed (exactly once — the ledger’s idempotency)');
|
||||
assert.ok(scene.state.toasts.some((t) => t.label.includes('+77')), '…with the console’s claim toast (the reward in the line)');
|
||||
assert.ok(scene.state.sfx.includes('construct'), '…with the payout tick (the scene’s voice)');
|
||||
assert.ok(scene.state.refreshes > 0, '…and the open console repaints (refresh)');
|
||||
|
||||
const after = scene.state.credits;
|
||||
call(GameScene.prototype.claimQuest, scene, job.id);
|
||||
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.
|
||||
const job2 = posts.find((p) => p.id !== job.id);
|
||||
scene.sideQuestState.accept(job2.id);
|
||||
scene.sideQuestState.setRoute(job2.id, { targetSystemId: REC_NEUTRAL.id, credits: 99, waypoints: [[1, 1]], visited: [] });
|
||||
const before2 = scene.state.credits;
|
||||
call(GameScene.prototype.claimQuest, scene, job2.id);
|
||||
assert.equal(scene.state.credits, before2, 'an uncomplete contract pays NOTHING');
|
||||
assert.equal(scene.sideQuestState.isClaimed(job2.id), false, '…and stays unclaimed');
|
||||
assert.ok(scene.state.toasts.some((t) => t.label.toUpperCase().includes('NOTHING TO CLAIM')), '…with the refusal toast (the console’s voice)');
|
||||
|
||||
// A story id falls through to the story ledger’s path (ungranted here —
|
||||
// no throw, no side-ledger mark, no payout).
|
||||
const before3 = scene.state.credits;
|
||||
call(GameScene.prototype.claimQuest, scene, 'start_your_journey');
|
||||
assert.equal(scene.state.credits, before3, 'a story id takes the story path (no side-ledger mark, no payout)');
|
||||
|
||||
// A claimed contract drops off the tracker’s featured slot (the story
|
||||
// claim’s shape — the priority re-resolves on the next poll).
|
||||
const sceneP = makeScene();
|
||||
sceneP.sideQuestState.accept(job.id);
|
||||
sceneP.sideQuestState.setRoute(job.id, { targetSystemId: REC_NEUTRAL.id, credits: 50, waypoints: [[1, 1]], visited: [0] });
|
||||
sceneP.sideQuestState.complete(job.id);
|
||||
sceneP.questState.priorityCleared = false;
|
||||
sceneP.questState.resolvePriority([job.id]); // the tracker’s auto-priority (no story quest stands)
|
||||
assert.equal(sceneP.questState.priority, job.id, 'the tracker’s auto-priority features the only active quest (the side contract)');
|
||||
call(GameScene.prototype.claimQuest, sceneP, job.id);
|
||||
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)');
|
||||
|
||||
// --- SET PRIORITY: the popup/console’s door on a side contract --------
|
||||
const sceneQ = makeScene();
|
||||
const a = posts.find((p) => p.category === 'patrol');
|
||||
const b = posts.find((p) => p.id !== a.id);
|
||||
sceneQ.sideQuestState.accept(a.id);
|
||||
sceneQ.sideQuestState.accept(b.id);
|
||||
assert.equal(call(GameScene.prototype.setPriorityQuest, sceneQ, b.id), true, 'SET PRIORITY on an accepted contract stands (the side path)');
|
||||
assert.equal(sceneQ.sideQuestState.isActive(b.id), true, '…it stands the side ledger’s ACTIVE slot (the contract in flight)');
|
||||
assert.equal(sceneQ.questState.priority, b.id, '…featured through the tracker’s seam (the story ledger’s priority)');
|
||||
assert.ok(sceneQ.state.toasts.some((t) => t.label.toUpperCase().includes('PRIORITY SET')), '…with the priority toast (the console’s voice)');
|
||||
const other = posts.find((p) => p.id !== a.id && p.id !== b.id);
|
||||
sceneQ.sideQuestState.accept(other.id);
|
||||
sceneQ.sideQuestState.complete(other.id); // a READY contract — nothing left to fly
|
||||
assert.equal(call(GameScene.prototype.setPriorityQuest, sceneQ, other.id), false, 'SET PRIORITY on a DONE contract refuses (nothing left to fly)');
|
||||
assert.equal(call(GameScene.prototype.setPriorityQuest, sceneQ, 'start_your_journey'), false, 'SET PRIORITY on an ungranted story quest refuses (the story ledger’s own gate)');
|
||||
}
|
||||
|
||||
console.log('\nsidequests: OK — the board’s model (the gate, the voice, the patrol’s target + route, the ledger) + the QUESTS console/tracker seams');
|
||||
|
|
|
|||
|
|
@ -5,27 +5,53 @@
|
|||
* ledger of the contracts it has taken.
|
||||
*
|
||||
* LAYERING — the board is a VIEW over this (js/ui/JobBoardWindow.js):
|
||||
* - the WINDOW renders sideQuestPostingsFor(seed, record) (the current
|
||||
* system's postings) and hands the player's ACCEPT to the scene;
|
||||
* - the SCENE (GameScene.acceptSideQuest — the claimSurfaceQuest
|
||||
* precedent: state + effects there, the note on the surface) lands it
|
||||
* on the SideQuestState ledger (registry 'sidequests' — rides the
|
||||
* saves like the quest ledger, SaveData capture/prepareLoad);
|
||||
* - the WINDOW renders sideQuestPostingsFor(seed, record, galaxy) (the
|
||||
* current system's postings) and hands the player's intent to the
|
||||
* scene (ACCEPT / SET ACTIVE / CLAIM REWARD);
|
||||
* - the SCENE (GameScene.acceptSideQuest / setActiveSideQuest /
|
||||
* claimSideQuest — the claimSurfaceQuest precedent: state + effects
|
||||
* there, the note on the surface) lands them on the SideQuestState
|
||||
* ledger (registry 'sidequests' — rides the saves like the quest
|
||||
* ledger, SaveData capture/prepareLoad);
|
||||
* - this module owns the CONTRACTS: which ones stand, what they say,
|
||||
* what they pay, and the accepted/completed record — pure, seeded,
|
||||
* Node-testable (dev/sidequests.test.mjs).
|
||||
* what they pay, the PATROL's route (buildPatrolRoute +
|
||||
* patrolTargetSystem), and the accepted/active/completed/claimed
|
||||
* record — pure, seeded, Node-testable (dev/sidequests.test.mjs).
|
||||
*
|
||||
* CATEGORIES — the four job types (data/sidequests.json → categories):
|
||||
* patrol / delivery / bounty / escort. Every category is ACCOUNTED FOR —
|
||||
* its slot machinery here, its description pool + reward band in the
|
||||
* data, the window's row + contract readout, the ledger, the accept
|
||||
* seam — behind its OWN `enabled` gate. All four start OFFLINE
|
||||
* (enabled:false) because none of the four objective/completion rule
|
||||
* sets is built yet, and the gate is the contract: a category's jobs
|
||||
* appear on the board ONLY when its rules land and its flag flips. A
|
||||
* posted job is therefore always a job whose category is live (the
|
||||
* completion wiring for the type arrives with the type — posting
|
||||
* posting.objective, below, is its typed socket).
|
||||
* seam — behind its OWN `enabled` gate, and the gate is the contract:
|
||||
* a category's jobs appear on the board ONLY when its rules land and
|
||||
* its flag flips. PATROL is the first live type (enabled:true) — a
|
||||
* patrol's objective is its ROUTE (below); the other three stay
|
||||
* OFFLINE until their rule sets land.
|
||||
*
|
||||
* PATROL — the route the player flies (data/sidequests.json → patrol):
|
||||
* - TARGET — patrolTargetSystem: the route's system. Default the
|
||||
* posting's own system; in a FACTION system at or beyond the
|
||||
* diagonal mark (neighborMinD — the '40% from home' rule), a seeded
|
||||
* draw (neighborChance) may stand a NEIGHBOR — a system the
|
||||
* posting's jump gates reach (galaxy.jumpGatesFor) controlled by
|
||||
* ANOTHER faction (the push-the-factions-out voice made a route).
|
||||
* - ROUTE — buildPatrolRoute: N isolated waypoints ([min,max] in the
|
||||
* data) as plain world coordinates, inside the caller's REGION (the
|
||||
* player's unlocked tether regions when the target is the system
|
||||
* they stand in; one tether region around each anchor when it is a
|
||||
* system not yet unlocked — the scene's job to assemble), keeping
|
||||
* anchorClearance from every anchor (planet / station / gate)
|
||||
* and minSeparation apart (relaxed, never the region — a patrol
|
||||
* must always be completable). Seeded on (seed, 'sidequests',
|
||||
* <targetSystemId>, 'patrol', <jobId>, 'route') — same contract +
|
||||
* region ⇒ same route, across re-accepts, scene restarts, saves.
|
||||
* - LEDGER — SideQuestState carries the accepted order, the ONE
|
||||
* active contract (reconcileActive: the top accepted contract
|
||||
* stands active until it is done and claimed), the route record
|
||||
* (waypoints + visited), and the claimed marks. The scene's
|
||||
* updatePatrol checks the ship in (visitRadius, any order),
|
||||
* completes the contract, and pays the reward exactly once on
|
||||
* claim.
|
||||
*
|
||||
* DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") —
|
||||
* postings derive from the galaxy seed + the system record through
|
||||
|
|
@ -47,6 +73,7 @@
|
|||
*/
|
||||
import { config } from '../config/Config.js';
|
||||
import { Rng } from '../utils/Rng.js';
|
||||
import { Tether } from '../tether/Tether.js';
|
||||
|
||||
/** The four job types, canonical slot order (the data's category keys). */
|
||||
export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort'];
|
||||
|
|
@ -201,6 +228,163 @@ export function rollSideQuestReward(cat, d, r) {
|
|||
return { credits };
|
||||
}
|
||||
|
||||
/**
|
||||
* The patrol's TARGET system — the system the route will be built in.
|
||||
* Default (returns null): the posting's own system. The NEIGHBOR rule
|
||||
* (data/sidequests.json → patrol): in a FACTION system at or beyond the
|
||||
* diagonal mark (neighborMinD — the design's '40% from home'), a seeded
|
||||
* draw (neighborChance) may stand a system the posting's jump gates
|
||||
* reach (galaxy.jumpGatesFor) controlled by ANOTHER faction — the
|
||||
* push-them-out voice made a route. The draw rides the posting's own
|
||||
* seeded stream, so the same posting stands with the same target for
|
||||
* 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 {Rng} r the posting's seeded stream
|
||||
* @param {object|null} [galaxy] the galaxy (jumpGatesFor + byId) — null in tests
|
||||
* @returns {{id:string, name:string}|null} null = the posting system stands
|
||||
*/
|
||||
export function patrolTargetSystem(rec, r, galaxy = null) {
|
||||
const faction = typeof rec?.faction === 'string' ? rec.faction : '';
|
||||
if (faction === '' || faction === 'neutral') return null; // neutral space patrols its own system
|
||||
const minD = Number(config.get('sidequests.patrol.neighborMinD', 0.4));
|
||||
if (!Number.isFinite(minD) || clamp01(Number(rec?.d)) < minD) return null; // the '40% from home' mark
|
||||
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;
|
||||
try {
|
||||
links = galaxy.jumpGatesFor(rec.id);
|
||||
} catch {
|
||||
links = null; // an unknown id (a hand-built test record) — the posting system stands
|
||||
}
|
||||
for (const nb of links ?? []) {
|
||||
if (!nb || typeof nb.id !== 'string' || nb.id === rec.id) continue;
|
||||
const nf = typeof nb.faction === 'string' ? nb.faction : '';
|
||||
if (nf !== '' && nf !== 'neutral' && nf !== faction) cands.push(nb); // a NEIGHBOR controlled by ANOTHER faction
|
||||
}
|
||||
if (cands.length === 0) return null; // no other-faction link — the posting system stands
|
||||
const nb = r.pick(cands);
|
||||
return nb ? { id: nb.id, name: String(nb.name ?? nb.id) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The patrol's ROUTE — N isolated waypoints (data/sidequests.json →
|
||||
* patrol.waypoints [min,max]) as plain world coordinates inside the
|
||||
* target system. The REGION argument is the contract's flight envelope
|
||||
* (the scene assembles it: the player's UNLOCKED tether regions when
|
||||
* the target is the system they stand in; one tether region — a level-1
|
||||
* radius — around each of the target's planets / stations / gates when
|
||||
* it is a system not yet unlocked). Waypoints stay inside the region
|
||||
* (a rim inset off the tether line), anchorClearance px clear of every
|
||||
* ANCHOR (a planet / station / gate center — the scene's list) and
|
||||
* minSeparation px apart; a tight field relaxes the clearance and
|
||||
* separation (never the region) until it gives, and a field that gives
|
||||
* nowhere stands the point at its region's heart — a patrol must always
|
||||
* be completable.
|
||||
*
|
||||
* Seeded on its own labelled fork (seed, 'sidequests',
|
||||
* <targetSystemId>, 'patrol', <jobId>, 'route') — the worldgen
|
||||
* determinism contract: same seed + contract + region ⇒ same route,
|
||||
* across re-accepts, scene restarts, and saves; nothing here depends on
|
||||
* call order.
|
||||
*
|
||||
* @param {string|number} seed the galaxy's seed
|
||||
* @param {string} jobId the accepted contract's id (the fork's key)
|
||||
* @param {string} targetSystemId the route's system (the posting's target)
|
||||
* @param {object[]} region [{ x, y, radius }] the flight envelope
|
||||
* @param {object[]} [anchors] [{ x, y }] the keep-out centers (planets / stations / gates)
|
||||
* @returns {{targetSystemId:string, waypoints:number[][], visited:number[]}}
|
||||
*/
|
||||
export function buildPatrolRoute(seed, jobId, targetSystemId, region, anchors = []) {
|
||||
const rawRange = config.get('sidequests.patrol.waypoints', [3, 5]);
|
||||
let lo = Math.floor(Number(Array.isArray(rawRange) ? rawRange[0] : 3));
|
||||
let hi = Math.floor(Number(Array.isArray(rawRange) ? rawRange[1] : 5));
|
||||
if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
|
||||
lo = 3;
|
||||
hi = 5;
|
||||
}
|
||||
lo = Math.max(1, lo);
|
||||
hi = Math.max(lo, hi);
|
||||
const anchorClearance = Math.max(0, Number(config.get('sidequests.patrol.anchorClearance', 1100)) || 0);
|
||||
const minSeparation = Math.max(0, Number(config.get('sidequests.patrol.minSeparation', 1500)) || 0);
|
||||
const inset = Math.max(0, Number(config.get('sidequests.patrol.regionInset', 96)) || 0);
|
||||
|
||||
const regions = (Array.isArray(region) ? region : [])
|
||||
.map((t) => ({ x: Number(t?.x), y: Number(t?.y), radius: Number(t?.radius) }))
|
||||
.filter((t) => Number.isFinite(t.x) && Number.isFinite(t.y) && Number.isFinite(t.radius) && t.radius > 0);
|
||||
if (regions.length === 0) {
|
||||
// No usable region (a caller without tether data — a test, or a
|
||||
// degenerate system): the system's heart at one tether's width, so
|
||||
// the route still stands (a patrol must always be completable).
|
||||
regions.push({ x: 0, y: 0, radius: Tether.radiusForLevel(1) });
|
||||
}
|
||||
const anchorPts = (Array.isArray(anchors) ? anchors : [])
|
||||
.map((a) => ({ x: Number(a?.x), y: Number(a?.y) }))
|
||||
.filter((a) => Number.isFinite(a.x) && Number.isFinite(a.y));
|
||||
|
||||
// The effective envelope: each region inset by the rim margin (a
|
||||
// waypoint never sits on a tether's line). A region smaller than its
|
||||
// inset gives up the inset rather than the region (kept at a 4 px
|
||||
// floor so the sampler never weights a zero-disc).
|
||||
const eff = regions.map((t) => ({ x: t.x, y: t.y, r: Math.max(4, t.radius - inset) }));
|
||||
const areas = eff.map((t) => t.r * t.r); // the sampler's weights (disc area)
|
||||
const inside = (x, y) => eff.some((t) => (x - t.x) ** 2 + (y - t.y) ** 2 <= t.r * t.r);
|
||||
|
||||
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', jobId, 'route');
|
||||
const n = r.int(lo, hi);
|
||||
const pickRegion = () => {
|
||||
const total = areas.reduce((a, b) => a + b, 0);
|
||||
let u = r.next() * total;
|
||||
for (let i = 0; i < eff.length; i++) {
|
||||
u -= areas[i];
|
||||
if (u <= 0) return eff[i];
|
||||
}
|
||||
return eff[eff.length - 1];
|
||||
};
|
||||
|
||||
const placed = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
let clear = anchorClearance;
|
||||
let sep = minSeparation;
|
||||
let pt = null;
|
||||
for (let attempt = 0; attempt < 900 && !pt; attempt++) {
|
||||
const t = pickRegion();
|
||||
const rad = Math.sqrt(r.next()) * t.r; // uniform in the disc (the sqrt)
|
||||
const ang = r.next() * Math.PI * 2;
|
||||
const x = t.x + Math.cos(ang) * rad;
|
||||
const y = t.y + Math.sin(ang) * rad;
|
||||
if (!inside(x, y)) continue;
|
||||
const okA = clear <= 0 || anchorPts.every((a) => Math.hypot(x - a.x, y - a.y) >= clear);
|
||||
const okS = sep <= 0 || placed.every((p) => Math.hypot(x - p.x, y - p.y) >= sep);
|
||||
if (okA && okS) pt = { x, y };
|
||||
else if (attempt === 299 || attempt === 699) {
|
||||
clear *= 0.5; // the field's giving — ease the clearance…
|
||||
sep *= 0.5; // …and the separation (never the region)
|
||||
}
|
||||
}
|
||||
if (!pt) {
|
||||
// The field gave nowhere (a system of one crowded anchor): stand
|
||||
// the point at the region's heart, half an inset off center on a
|
||||
// seeded bearing — inside the envelope, best-effort isolated.
|
||||
const t = pickRegion();
|
||||
const ang = r.next() * Math.PI * 2;
|
||||
const rad = Math.min(t.r * 0.5, Math.max(4, anchorClearance * 0.5));
|
||||
pt = { x: t.x + Math.cos(ang) * rad, y: t.y + Math.sin(ang) * rad };
|
||||
}
|
||||
placed.push(pt);
|
||||
}
|
||||
|
||||
return {
|
||||
targetSystemId: String(targetSystemId),
|
||||
waypoints: placed.map((p) => [Math.round(p.x), Math.round(p.y)]),
|
||||
visited: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The system's LIVE postings (the board's list) — 3–4 by design
|
||||
* (sideQuestCountRange), drawn from the LIVE categories only
|
||||
|
|
@ -208,22 +392,27 @@ export function rollSideQuestReward(cat, d, r) {
|
|||
* then repeats into the variant pools (the same category can hold more
|
||||
* than one posting once the count outruns the live set).
|
||||
*
|
||||
* Deterministic in (seed, record) — the labelled forks under
|
||||
* Deterministic in (seed, record[, galaxy]) — the labelled forks under
|
||||
* Rng.derive(seed, seedLabel, <sysId>) make the roll, the category
|
||||
* order, and every per-posting draw independent of call order, so the
|
||||
* board stands the same across visits, scene restarts, and saves.
|
||||
* order, and every per-posting draw (the patrol's target included) independent
|
||||
* of call order, so the board stands the same across visits, scene
|
||||
* restarts, and saves.
|
||||
*
|
||||
* A posting's shape (the window's row + detail + the scene's accept):
|
||||
* { id, systemId, systemName, faction (null = neutral), category,
|
||||
* label, accent, title, issuer, description, reward: { credits },
|
||||
* objective: { type } } — objective is the typed socket the
|
||||
* category's completion rules fill in when the type lands.
|
||||
* category's completion rules fill in when the type lands. A PATROL
|
||||
* posting additionally carries targetSystemId / targetSystemName (the
|
||||
* route's system — patrolTargetSystem; default the posting's own
|
||||
* system).
|
||||
*
|
||||
* @param {string|number} seed the galaxy's seed
|
||||
* @param {object} rec a system record ({ id, name, faction, d, … })
|
||||
* @param {object|null} [galaxy] the galaxy (the patrol's neighbor rule's jumpGatesFor) — null in tests
|
||||
* @returns {object[]} [] when the feature is off or no category is live
|
||||
*/
|
||||
export function sideQuestPostingsFor(seed, rec) {
|
||||
export function sideQuestPostingsFor(seed, rec, galaxy = null) {
|
||||
if (!sideQuestsEnabled()) return [];
|
||||
if (!rec || typeof rec.id !== 'string' || rec.id === '') return [];
|
||||
const cats = enabledSideQuestCategories();
|
||||
|
|
@ -239,14 +428,14 @@ export function sideQuestPostingsFor(seed, rec) {
|
|||
for (let slot = 0; slot < count; slot++) {
|
||||
const cat = order[slot % order.length];
|
||||
const r = Rng.derive(seed, seedLabel(), rec.id, cat, slot); // per-posting stream
|
||||
const posting = buildPosting(seed, rec, ctx, cat, slot, r);
|
||||
const posting = buildPosting(seed, rec, ctx, cat, slot, r, galaxy);
|
||||
if (posting) out.push(posting);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One posting from its pool variant + reward roll (null on a bad pool entry). */
|
||||
function buildPosting(seed, rec, ctx, cat, slot, r) {
|
||||
function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null) {
|
||||
const meta = sideQuestCategoryMeta(cat);
|
||||
if (!meta) return null;
|
||||
|
||||
|
|
@ -281,9 +470,23 @@ function buildPosting(seed, rec, ctx, cat, slot, r) {
|
|||
// witness, its payout rule) is built WITH the type and rides here —
|
||||
// the UI already renders objective.text when one stands.
|
||||
objective: { type: cat },
|
||||
// The PATROL's target system — the route's system (patrolTargetSystem:
|
||||
// the posting system, or a NEIGHBOR the jump gates reach when the
|
||||
// system's faction, diagonal, and seeded draw agree). Patrol-only —
|
||||
// the other categories' routes (when they land) carry their own
|
||||
// shape.
|
||||
...(cat === 'patrol' ? patrolTargetFields(rec, r, galaxy) : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The patrol posting's target fields (its own system, or the neighbor's). */
|
||||
function patrolTargetFields(rec, r, galaxy) {
|
||||
const t = patrolTargetSystem(rec, r, galaxy);
|
||||
return t
|
||||
? { targetSystemId: t.id, targetSystemName: t.name }
|
||||
: { targetSystemId: rec.id, targetSystemName: String(rec.name ?? rec.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The seed's JOB SPACE — every id the ledger could lawfully hold: each
|
||||
* system × the full slot range (the range's max — a superset of what any
|
||||
|
|
@ -311,19 +514,35 @@ export function allSideQuestJobIds(records) {
|
|||
|
||||
/**
|
||||
* The run's SIDE QUEST ledger (pure; save-ready): which posted contracts
|
||||
* the player has ACCEPTED and which of those have COMPLETED. The ids are
|
||||
* the player has ACCEPTED (in the order taken), which is the ONE ACTIVE
|
||||
* contract being flown, which of the taken contracts have COMPLETED,
|
||||
* which of those have been CLAIMED, and the PATROL route record (its
|
||||
* target system, its waypoints, its visited marks). The ids are
|
||||
* postings' ids (sq-<sysId>-<cat>-<slot>) — the postings themselves
|
||||
* regenerate from the seed, so the save carries only the marks (a save =
|
||||
* seed + player state). Invariants: completed ⊆ accepted; accept is
|
||||
* idempotent (a double-press or a re-open can't double-take a contract);
|
||||
* a save predating the feature (no field) loads an empty ledger (old
|
||||
* saves load); ids the save's galaxy never posted are dropped, not
|
||||
* propagated.
|
||||
* seed + player state).
|
||||
*
|
||||
* Invariants (the scene's flows lean on them):
|
||||
* - completed ⊆ accepted, claimed ⊆ completed;
|
||||
* - accept / complete / claim are idempotent (a double-press or a
|
||||
* re-open can't double-take, double-pay, or re-open a contract);
|
||||
* - ONE active contract at a time (activeId) — reconcileActive keeps
|
||||
* a standing active valid, and otherwise stands the TOP accepted
|
||||
* contract (acceptedOrder's head) that is not done + claimed; null
|
||||
* when no contract is out;
|
||||
* - a save predating the feature (no field) loads an empty ledger
|
||||
* (old saves load); ids the save's galaxy never posted are dropped,
|
||||
* not propagated; a route record only for a contract this ledger
|
||||
* holds (a route for a foreign id degrades away).
|
||||
*/
|
||||
export class SideQuestState {
|
||||
constructor() {
|
||||
this.accepted = new Set();
|
||||
this.acceptedOrder = []; // the order taken (the active slot's "top")
|
||||
this.completed = new Set(); // completed ⊆ accepted
|
||||
this.claimed = new Set(); // claimed ⊆ completed
|
||||
this.activeId = null; // the ONE contract being flown (taken, not done, not claimed)
|
||||
this.routes = new Map(); // id → { targetSystemId, credits, waypoints, visited }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -334,6 +553,7 @@ export class SideQuestState {
|
|||
if (typeof id !== 'string' || id === '') return false;
|
||||
if (this.accepted.has(id)) return false;
|
||||
this.accepted.add(id);
|
||||
this.acceptedOrder.push(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -341,10 +561,16 @@ export class SideQuestState {
|
|||
return this.accepted.has(id);
|
||||
}
|
||||
|
||||
/** The taken order (the ledger's "top" is index 0). */
|
||||
acceptedList() {
|
||||
return [...this.acceptedOrder];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a taken contract COMPLETE (the type's objective stood — the
|
||||
* scene's call when a category lands; only ACCEPTED contracts can
|
||||
* complete — completed ⊆ accepted).
|
||||
* complete — completed ⊆ accepted). A done contract drops out of the
|
||||
* ACTIVE slot (reconcileActive — the next top one stands).
|
||||
* @returns {boolean} true when NEWLY completed
|
||||
*/
|
||||
complete(id) {
|
||||
|
|
@ -352,6 +578,7 @@ export class SideQuestState {
|
|||
if (!this.accepted.has(id)) return false;
|
||||
if (this.completed.has(id)) return false;
|
||||
this.completed.add(id);
|
||||
this.reconcileActive();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -359,10 +586,125 @@ export class SideQuestState {
|
|||
return this.completed.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a completed contract CLAIMED (the reward is paid exactly once —
|
||||
* idempotent; claimed ⊆ completed, so an uncompleted contract can't
|
||||
* be claimed). A claimed contract drops out of the ACTIVE slot.
|
||||
* @returns {boolean} true when NEWLY claimed
|
||||
*/
|
||||
claim(id) {
|
||||
if (typeof id !== 'string' || id === '') return false;
|
||||
if (!this.completed.has(id)) return false;
|
||||
if (this.claimed.has(id)) return false;
|
||||
this.claimed.add(id);
|
||||
this.reconcileActive();
|
||||
return true;
|
||||
}
|
||||
|
||||
isClaimed(id) {
|
||||
return this.claimed.has(id);
|
||||
}
|
||||
|
||||
isActive(id) {
|
||||
return this.activeId === id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand the ACTIVE contract — the ONE being flown. It must be taken,
|
||||
* not done, not claimed (the board's door only offers it in that state
|
||||
* anyway — this is the belt-and-braces pass).
|
||||
* @returns {boolean} true when it stands
|
||||
*/
|
||||
setActive(id) {
|
||||
if (typeof id !== 'string' || id === '') return false;
|
||||
if (!this.accepted.has(id) || this.completed.has(id) || this.claimed.has(id)) return false;
|
||||
this.activeId = id;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACTIVE slot's truth: a standing active (taken, not done, not
|
||||
* claimed) is kept; otherwise the TOP accepted contract (acceptedOrder's
|
||||
* head — "if no quest is active and the player has accepted quests, the
|
||||
* top accepted stands") that is not done + claimed takes the slot; null
|
||||
* when no contract is out.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
reconcileActive() {
|
||||
if (
|
||||
this.activeId &&
|
||||
this.accepted.has(this.activeId) &&
|
||||
!this.completed.has(this.activeId) &&
|
||||
!this.claimed.has(this.activeId)
|
||||
) {
|
||||
return this.activeId;
|
||||
}
|
||||
this.activeId =
|
||||
this.acceptedOrder.find((id) => !this.completed.has(id) && !this.claimed.has(id)) ?? null;
|
||||
return this.activeId;
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the patrol's ROUTE
|
||||
|
||||
/**
|
||||
* Stand a contract's ROUTE record (the patrol's waypoints — the scene's
|
||||
* buildPatrolRoute result, at the accept). Only a TAKEN contract can
|
||||
* carry a route (a route for a foreign id degrades away); the record
|
||||
* is validated + normalized (finite waypoint pairs, visited indices
|
||||
* in range, no duplicates).
|
||||
* @returns {boolean} true when it stands
|
||||
*/
|
||||
setRoute(id, route) {
|
||||
if (typeof id !== 'string' || id === '') return false;
|
||||
if (!this.accepted.has(id)) return false;
|
||||
if (!route || typeof route !== 'object') return false;
|
||||
const waypoints = (Array.isArray(route.waypoints) ? route.waypoints : [])
|
||||
.filter((w) => Array.isArray(w) && w.length >= 2 && Number.isFinite(w[0]) && Number.isFinite(w[1]))
|
||||
.map((w) => [Math.round(w[0]), Math.round(w[1])]);
|
||||
const visited = [...new Set(
|
||||
(Array.isArray(route.visited) ? route.visited : []).filter(
|
||||
(i) => Number.isInteger(i) && i >= 0 && i < waypoints.length,
|
||||
),
|
||||
)];
|
||||
this.routes.set(id, {
|
||||
targetSystemId: String(route.targetSystemId ?? ''),
|
||||
credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's payout — fixed at the accept
|
||||
waypoints,
|
||||
visited,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A contract's route record (the scene's check-ins + the board's route
|
||||
* line read it).
|
||||
* @returns {{targetSystemId:string, credits:number, waypoints:number[][], visited:number[]}|null}
|
||||
*/
|
||||
routeFor(id) {
|
||||
return this.routes.get(id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check off a waypoint (idempotent — the scene's per-frame pass may
|
||||
* see the ship inside the radius on several frames; only the FIRST
|
||||
* logs). @returns {boolean} true when NEWLY logged
|
||||
*/
|
||||
visitWaypoint(id, idx) {
|
||||
const route = this.routes.get(id);
|
||||
if (!route) return false;
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= route.waypoints.length) return false;
|
||||
if (route.visited.includes(idx)) return false;
|
||||
route.visited.push(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
accepted: [...this.accepted],
|
||||
accepted: [...this.acceptedOrder],
|
||||
completed: [...this.completed],
|
||||
claimed: [...this.claimed],
|
||||
activeId: this.activeId,
|
||||
routes: Object.fromEntries(this.routes),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +713,9 @@ export class SideQuestState {
|
|||
* empty ledger rather than a throw (contracts are a progression
|
||||
* nicety, not a crash vector — the QuestState precedent). With a
|
||||
* `known` job space (allSideQuestJobIds of the restored galaxy) only
|
||||
* ids inside it survive — a foreign or hand-edited id degrades.
|
||||
* ids inside it survive — a foreign or hand-edited id degrades. The
|
||||
* active slot is restored through reconcileActive: a standing saved
|
||||
* active is kept, else the top accepted stands (the save's order).
|
||||
* @param {object} data the record.sidequests shape
|
||||
* @param {Set<string>|null} [known] the save's galaxy job space
|
||||
*/
|
||||
|
|
@ -381,14 +725,27 @@ export class SideQuestState {
|
|||
const list = (arr) => (Array.isArray(arr) ? arr.filter((x) => typeof x === 'string' && x !== '') : []);
|
||||
const ok = (id) => (known ? known.has(id) : true);
|
||||
for (const id of list(data.accepted)) {
|
||||
if (!ok(id)) continue;
|
||||
if (!ok(id) || st.accepted.has(id)) continue;
|
||||
st.accepted.add(id);
|
||||
st.acceptedOrder.push(id);
|
||||
}
|
||||
for (const id of list(data.completed)) {
|
||||
if (!st.accepted.has(id)) continue; // completed ⊆ accepted
|
||||
if (!ok(id)) continue;
|
||||
if (!ok(id) || !st.accepted.has(id) || st.completed.has(id)) continue; // completed ⊆ accepted
|
||||
st.completed.add(id);
|
||||
}
|
||||
for (const id of list(data.claimed)) {
|
||||
if (!ok(id) || !st.completed.has(id) || st.claimed.has(id)) continue; // claimed ⊆ completed
|
||||
st.claimed.add(id);
|
||||
}
|
||||
const routes = data.routes && typeof data.routes === 'object' && !Array.isArray(data.routes) ? data.routes : null;
|
||||
if (routes) {
|
||||
for (const [id, raw] of Object.entries(routes)) {
|
||||
if (!ok(id) || !st.accepted.has(id)) continue; // a route only for a contract this ledger holds
|
||||
st.setRoute(id, raw);
|
||||
}
|
||||
}
|
||||
if (typeof data.activeId === 'string' && data.activeId !== '') st.activeId = data.activeId;
|
||||
st.reconcileActive(); // a standing saved active is kept; else the top accepted stands; else null
|
||||
return st;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { SaveManager } from '../save/SaveManager.js';
|
|||
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
|
||||
import { CommsHub } from '../comms/CommsHub.js';
|
||||
import { commsForTrigger } from '../comms/CommsModel.js';
|
||||
import { Tether } from '../tether/Tether.js';
|
||||
import { TetherField } from '../tether/TetherField.js';
|
||||
import { Mining } from '../mining/Mining.js';
|
||||
import { MiningPopup } from '../ui/MiningPopup.js';
|
||||
|
|
@ -58,7 +59,7 @@ import { MapWindow } from '../ui/MapWindow.js';
|
|||
import { QuestWindow } from '../ui/QuestWindow.js';
|
||||
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
|
||||
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
|
||||
import { SideQuestState, sideQuestPostingsFor } from '../quests/SideQuests.js';
|
||||
import { SideQuestState, sideQuestPostingsFor, buildPatrolRoute } from '../quests/SideQuests.js';
|
||||
import { tutorialTarget } from '../quests/TutorialGuide.js';
|
||||
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
|
||||
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
|
||||
|
|
@ -876,6 +877,15 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
this.tetherToastAt = null;
|
||||
|
||||
// The active PATROL's in-world WAYPOINT beacons (data/sidequests.json
|
||||
// → patrol.marker): a diamond + ring + soft glow per waypoint (the
|
||||
// yellow pulse while open, the dim done mark once visited) — plain
|
||||
// coordinates, not world objects (the route's shape), repainted per
|
||||
// frame (js/quests/SideQuests.js's route record on the scene's
|
||||
// sideQuestState). Depth 9: above the tether line (6) + the route
|
||||
// pulse, below the ship (10).
|
||||
this.patrolG = this.add.graphics().setDepth(9); // world coordinates (the ship + tether scroll with it)
|
||||
|
||||
// Discovery: which objects the player has found (within discovery
|
||||
// distance of an edge — data/game.json), tracked per system. Kept in
|
||||
// the shared registry so it survives scene restarts; serializable for
|
||||
|
|
@ -2443,6 +2453,8 @@ export class GameScene extends Phaser.Scene {
|
|||
// story.capitalReturned (monotonic; _maybeWitnessCapitalReturn is a
|
||||
// no-op once it stands, so the per-frame cost is a ledger read).
|
||||
this._maybeWitnessCapitalReturn();
|
||||
this._drawPatrolMarkers(_time); // the active patrol's beacons (the route's system only)
|
||||
this.updatePatrol(); // the check-ins (the compass + the beacons see the fresh ledger this frame)
|
||||
if (!_commsPaused) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
|
||||
}
|
||||
|
||||
|
|
@ -2603,10 +2615,22 @@ export class GameScene extends Phaser.Scene {
|
|||
const destObjId = destObj?.id ?? null;
|
||||
const destOffscreen = !!(destObj && !circleInView(destObj.x, destObj.y, destObj.radius, view));
|
||||
|
||||
// The active PATROL's next stop (data/sidequests.json → patrol.compass —
|
||||
// the YELLOW arrow, its own color off the route gate's orange): the
|
||||
// nearest unvisited waypoint. In the route's own system it is the
|
||||
// waypoint itself (plain coordinates — no discovery entry to
|
||||
// suppress, and only off-screen: on screen the beacon is visible);
|
||||
// in another system it is the jumpgate that jumps there — the yellow
|
||||
// takes the gate's slot, so the gate's ordinary cyan entry is
|
||||
// suppressed (the route gate's rule, a different color).
|
||||
const patrolT = this.patrolCompassTarget();
|
||||
const patrolGateId = patrolT?.kind === 'gate' ? patrolT.gate?.discoveryId : null;
|
||||
|
||||
const offscreen = [];
|
||||
for (const o of objects) {
|
||||
if (o.id === routeGateId) continue; // the route arrow takes this slot
|
||||
if (o.id === destObjId) continue; // the destination arrow takes this slot
|
||||
if (o.id === patrolGateId) continue; // the patrol arrow takes this slot
|
||||
if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) {
|
||||
offscreen.push(o);
|
||||
}
|
||||
|
|
@ -2639,6 +2663,35 @@ export class GameScene extends Phaser.Scene {
|
|||
alwaysFull: true,
|
||||
});
|
||||
}
|
||||
if (patrolT) {
|
||||
if (patrolT.kind === 'gate' && patrolT.gate && !circleInView(patrolT.gate.x, patrolT.gate.y, patrolT.gate.bound, view)) {
|
||||
offscreen.push({
|
||||
id: `patrol:gate:${patrolT.gate.discoveryId}`,
|
||||
x: patrolT.gate.x,
|
||||
y: patrolT.gate.y,
|
||||
radius: patrolT.gate.bound,
|
||||
typeLabel: config.get('sidequests.patrol.compass.typeLabel', 'Patrol'),
|
||||
color: config.get('sidequests.patrol.compass.color', '#ffd60a'),
|
||||
name: patrolT.gate.discoveryName,
|
||||
// The patrol's next gate — the full readout (like the route gate),
|
||||
// autopilot-resolvable (the `patrol:gate:` id's own branch).
|
||||
alwaysFull: true,
|
||||
});
|
||||
} else if (patrolT.kind === 'waypoint' && !circleInView(patrolT.x, patrolT.y, 32, view)) {
|
||||
offscreen.push({
|
||||
id: `patrol:wp:${patrolT.idx}`,
|
||||
x: patrolT.x,
|
||||
y: patrolT.y,
|
||||
radius: 32,
|
||||
typeLabel: config.get('sidequests.patrol.compass.typeLabel', 'Patrol'),
|
||||
color: config.get('sidequests.patrol.compass.color', '#ffd60a'),
|
||||
name: `WAYPOINT ${patrolT.idx + 1}`,
|
||||
// The patrol's next point — the full readout (the thing to fly),
|
||||
// autopilot-resolvable (the `patrol:wp:` id's own branch).
|
||||
alwaysFull: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// The ship's position drives the compass's FAR display (targets
|
||||
// beyond game.discovery.compass.farDistance fold to their compact
|
||||
// chip + shrunken arrow until hovered — js/ui/DiscoveryCompass.js).
|
||||
|
|
@ -2752,6 +2805,35 @@ export class GameScene extends Phaser.Scene {
|
|||
if (typeof id === 'string') {
|
||||
if (id.startsWith('route:')) effectiveId = id.slice('route:'.length);
|
||||
else if (id.startsWith('dest:')) effectiveId = id.slice('dest:'.length);
|
||||
else if (id.startsWith('patrol:')) {
|
||||
// The active PATROL's compass entry (see updateDiscovery):
|
||||
// `patrol:gate:<gateId>` — the yellow route gate in ANOTHER
|
||||
// system (the gate's own autopilot flies it — the orange route
|
||||
// gate's rule); `patrol:wp:<i>` — the yellow next-waypoint (plain
|
||||
// coordinates, NOT a discovery object: the aim clamps to the
|
||||
// tether union — the ship flies as far as its tether lets it,
|
||||
// the waypoint's radius is generous enough to land the check-in).
|
||||
const payload = id.slice('patrol:'.length);
|
||||
if (payload.startsWith('gate:')) {
|
||||
this.autopilotTo(payload.slice('gate:'.length));
|
||||
return;
|
||||
}
|
||||
if (payload.startsWith('wp:')) {
|
||||
const idx = Number.parseInt(payload.slice('wp:'.length), 10);
|
||||
const st = this.sideQuestState;
|
||||
const route = st?.activeId ? st.routeFor(st.activeId) : null;
|
||||
const wp =
|
||||
Number.isInteger(idx) && route && Array.isArray(route.waypoints) ? route.waypoints[idx] : null;
|
||||
if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1])) {
|
||||
const aim = this.tetherField.clampPoint(wp[0], wp[1]);
|
||||
this.showTargetMarker(aim.x, aim.y); // the aim feedback
|
||||
this.ship.setTarget(aim.x, aim.y);
|
||||
this.hideHint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const o =
|
||||
this.discoverableObjects().find((v) => v.id === effectiveId) ??
|
||||
|
|
@ -4145,9 +4227,86 @@ export class GameScene extends Phaser.Scene {
|
|||
progress: `${doneCount}/${checks.length}`,
|
||||
});
|
||||
}
|
||||
// The SIDE QUESTS tab (category 'side') — the ACCEPTED contracts
|
||||
// (the side ledger, js/quests/SideQuests.js — the board's accepts):
|
||||
// the patrol's checklist is its route's waypoints (the ledger's
|
||||
// route record's visited marks — LIVE as the ship checks them in),
|
||||
// its reward is the contract's payout (fixed at the accept), and
|
||||
// its CLAIM rides the same claim seam (claimQuest dispatches the
|
||||
// sq- ids to the side ledger's payout + idempotency). The tracker
|
||||
// HUD stacks them after the main story (the QUESTS HUD, upper-left)
|
||||
// — the one active contract's waypoints stand as its checklist.
|
||||
const sideState = this.sideQuestState;
|
||||
if (sideState) {
|
||||
for (const id of sideState.acceptedList()) {
|
||||
const view = this._sideQuestView(id);
|
||||
if (view) quests.push(view);
|
||||
}
|
||||
}
|
||||
return { categories, quests };
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACCEPTED contract's posting (the side ledger's sq- id parsed
|
||||
* back to its SYSTEM + the deterministic posting of its category/slot
|
||||
* — the postings are regenerated on demand, the same regeneration the
|
||||
* board + the detail readout stand on). The id's shape is
|
||||
* 'sq-<systemId>-<category>-<slot>' (SideQuests's postingId — the
|
||||
* greedy split is the true one: the category is letters, the slot
|
||||
* digits, at the id's end).
|
||||
* @returns {?{id: string, title: string, description: string, issuer: string, reward: object}}
|
||||
*/
|
||||
_sideQuestPosting(id) {
|
||||
if (typeof id !== 'string' || !id.startsWith('sq-') || !this.galaxy) return null;
|
||||
const m = /^sq-(.+)-([a-z]+)-(\d+)$/i.exec(id);
|
||||
if (!m) return null;
|
||||
const rec = this.galaxy.byId.get(m[1]) ?? null;
|
||||
if (!rec) return null; // a foreign system id — no posting to stand a row from
|
||||
return sideQuestPostingsFor(this.galaxy.seed, rec, this.galaxy).find((p) => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One side-quest row for the dossier + the tracker (the snapshot's
|
||||
* category-'side' entry): the contract's posting (title /
|
||||
* description / issuer), the patrol's checklist = its waypoints
|
||||
* (visited marks LIVE), complete = every waypoint logged, claimed
|
||||
* = the reward paid out, reward = the contract's payout (the route
|
||||
* record's credits — fixed at the accept).
|
||||
* @returns {?{id: string, category: string, title: string, description: string, issuer: string, icon: string, active: boolean, checks: Array<object>, reward: string, complete: boolean, claimed: boolean, progress: string}}
|
||||
*/
|
||||
_sideQuestView(id) {
|
||||
const st = this.sideQuestState;
|
||||
if (!st || !st.isAccepted(id)) return null;
|
||||
const posting = this._sideQuestPosting(id);
|
||||
if (!posting) return null; // a foreign id (degraded) — no posting to stand a row from
|
||||
const route = st.routeFor(id);
|
||||
const waypoints = route && Array.isArray(route.waypoints) ? route.waypoints : [];
|
||||
const visited = route && Array.isArray(route.visited) ? route.visited : [];
|
||||
const checks = waypoints.map((wp, i) => ({
|
||||
label: `Waypoint ${i + 1}`,
|
||||
done: visited.includes(i),
|
||||
detail: visited.includes(i) ? 'LOGGED' : '',
|
||||
}));
|
||||
const credits =
|
||||
route && Number.isFinite(route.credits) && route.credits > 0
|
||||
? route.credits
|
||||
: (posting.reward?.credits ?? 0);
|
||||
return {
|
||||
id,
|
||||
category: 'side',
|
||||
title: posting.title ?? id,
|
||||
description: posting.description ?? '',
|
||||
issuer: posting.issuer ?? '',
|
||||
icon: 'diamond',
|
||||
active: st.isActive(id),
|
||||
checks,
|
||||
reward: `${credits} CR`,
|
||||
complete: waypoints.length > 0 && visited.length === waypoints.length,
|
||||
claimed: st.isClaimed(id),
|
||||
progress: `${visited.length}/${waypoints.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One checklist row for the dossier: the step's LIVE evaluation
|
||||
* (_questCheckEval) made MONOTONIC by the quest ledger's completed
|
||||
|
|
@ -4485,7 +4644,7 @@ export class GameScene extends Phaser.Scene {
|
|||
sideQuestPostings() {
|
||||
const rec = this.systemRecord ?? null;
|
||||
if (!this.galaxy || !rec) return [];
|
||||
return sideQuestPostingsFor(this.galaxy.seed, rec);
|
||||
return sideQuestPostingsFor(this.galaxy.seed, rec, this.galaxy);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4504,10 +4663,247 @@ export class GameScene extends Phaser.Scene {
|
|||
const posting = this.sideQuestPostings().find((p) => p.id === jobId) ?? null;
|
||||
if (!posting) return { ok: false };
|
||||
if (!this.sideQuestState.accept(jobId)) return { ok: false };
|
||||
// The PATROL's ROUTE stands WITH the accept — the route is the
|
||||
// contract (the scene's _establishPatrol builds it from the player's
|
||||
// live tether context + the target system's seeded content; the
|
||||
// ledger's route record rides the save, so a re-accept after a load
|
||||
// keeps the SAME coordinates — the route never re-rolls).
|
||||
if (posting.category === 'patrol') this._establishPatrol(jobId, posting);
|
||||
this.playSfx('construct'); // the power-up tick — the contract lands
|
||||
return { ok: true, posting };
|
||||
}
|
||||
|
||||
/**
|
||||
* The PATROL's accept — stand its ROUTE on the ledger (buildPatrolRoute
|
||||
* — js/quests/SideQuests.js). The REGION is the contract's flight
|
||||
* envelope: the player's UNLOCKED tether regions when the target is
|
||||
* the system they stand in (their live tether field — the range they
|
||||
* can actually fly in there), or one tether region (a level-1 radius)
|
||||
* around each of the target's planets / stations / gates when it is a
|
||||
* system not yet unlocked (its seeded content — the worldgen contract:
|
||||
* the same layout the player will find on arrival). The keep-out
|
||||
* anchors are the same objects' centers (the waypoints stand clear of
|
||||
* them — the data's anchorClearance). The contract's payout is fixed
|
||||
* here too (route.credits — the claim pays this, even if the band is
|
||||
* re-tuned later: a signed contract is a signed contract).
|
||||
*/
|
||||
_establishPatrol(jobId, posting) {
|
||||
const targetId = posting.targetSystemId ?? this.systemRecord?.id ?? '';
|
||||
if (!targetId || !this.sideQuestState) return;
|
||||
let region = [];
|
||||
let anchors = [];
|
||||
if (targetId === this.systemRecord?.id) {
|
||||
// The system the player stands in: their UNLOCKED tether regions.
|
||||
const live = [...(this.systemPlanets ?? []), ...(this.systemStations ?? []), ...(this.systemGates ?? [])];
|
||||
if (this.planet) live.push(this.planet);
|
||||
anchors = live.map((e) => ({ x: e.x, y: e.y }));
|
||||
region = (this.tetherField?.tethers ?? []).map((t) => ({ x: t.x, y: t.y, radius: t.radius }));
|
||||
} else {
|
||||
// A NEIGHBOR system (unlocked or not): one tether region around
|
||||
// each of its planets / stations / gates (the content's layout —
|
||||
// seeded, the same the player will find on arrival).
|
||||
const content = this.galaxy?.contentOf?.(targetId) ?? null;
|
||||
const pts = [
|
||||
...(Array.isArray(content?.planets) ? content.planets : []),
|
||||
...(Array.isArray(content?.settlements) ? content.settlements : []),
|
||||
...(Array.isArray(content?.jumps) ? content.jumps : []),
|
||||
].filter((a) => Number.isFinite(a?.x) && Number.isFinite(a?.y));
|
||||
anchors = pts.map((a) => ({ x: a.x, y: a.y }));
|
||||
const radius = Tether.radiusForLevel(1);
|
||||
region = pts.map((a) => ({ x: a.x, y: a.y, radius }));
|
||||
}
|
||||
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, targetId, region, anchors);
|
||||
route.credits = posting.reward?.credits ?? 0; // the contract's payout (fixed at the accept)
|
||||
this.sideQuestState.setRoute(jobId, route);
|
||||
}
|
||||
|
||||
/**
|
||||
* The board's SET ACTIVE (the window's door → here): stand the ONE
|
||||
* contract the player flies right now (the ledger validates — taken,
|
||||
* not done, not claimed — the board only offers the door in that
|
||||
* state). No effect is the scene's: the active state IS the in-flight
|
||||
* face (the compass's yellow next-stop arrow — patrolCompassTarget —
|
||||
* and the route's in-world beacons — _drawPatrolMarkers, both reading
|
||||
* sideQuestState.activeId).
|
||||
* @returns {boolean} true when it stands
|
||||
*/
|
||||
setActiveSideQuest(jobId) {
|
||||
if (!this.sideQuestState) return false;
|
||||
return this.sideQuestState.setActive(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The board's CLAIM REWARD (the window's door → here — the claimQuest
|
||||
* precedent, the side-quest shape): the contract taken + COMPLETE, paid
|
||||
* exactly once (the ledger's claim() is idempotent — a double-press or
|
||||
* a re-open can't pay twice). The payout is the contract's OWN: the
|
||||
* route record's credits (fixed at the accept); a category without a
|
||||
* route (the stubs, when they land) falls back to the posting's reward
|
||||
* (regenerated from the seed — the contract stands). The scene owns
|
||||
* the effect (the credits land); the ledger owns the record.
|
||||
* @returns {{ok:boolean, credits?:number}}
|
||||
*/
|
||||
claimSideQuest(jobId) {
|
||||
const st = this.sideQuestState;
|
||||
if (!st || !st.isAccepted(jobId) || !st.isCompleted(jobId) || st.isClaimed(jobId)) return { ok: false };
|
||||
if (!st.claim(jobId)) return { ok: false }; // someone beat us to it — idempotent
|
||||
let credits = 0;
|
||||
const route = st.routeFor(jobId);
|
||||
if (route && Number.isFinite(route.credits)) credits = route.credits;
|
||||
if (!credits) {
|
||||
// A category without a route: the posting's reward (the seed's
|
||||
// contract stands — sq-<sysId>-<cat>-<slot>).
|
||||
const m = /^sq-(.+)-([a-z]+)-(\d+)$/i.exec(String(jobId));
|
||||
if (m) {
|
||||
const rec = this.galaxy?.byId?.get(m[1]);
|
||||
if (rec && this.galaxy) {
|
||||
credits = sideQuestPostingsFor(this.galaxy.seed, rec, this.galaxy).find((p) => p.id === jobId)?.reward?.credits ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (credits > 0 && this.ship) {
|
||||
this.ship.addCredits(credits);
|
||||
this.refreshCreditsHud();
|
||||
}
|
||||
this.playSfx('construct'); // the payout tick — the reward lands
|
||||
return { ok: true, credits };
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's ROUTE readout seam (the detail's "ROUTE — 2/4 WAYPOINTS
|
||||
* LOGGED" line): the accepted patrol's checkpoint count. Null when the
|
||||
* contract carries no route (the other categories) or its route never
|
||||
* stood.
|
||||
* @returns {{visited:number, total:number}|null}
|
||||
*/
|
||||
patrolProgress(jobId) {
|
||||
const route = this.sideQuestState?.routeFor(jobId);
|
||||
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return null;
|
||||
return { visited: route.visited.length, total: route.waypoints.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* The active PATROL's NEXT STOP for the off-screen compass (the yellow
|
||||
* arrow — data/sidequests.json → patrol.compass; the route gate's
|
||||
* orange precedent, a different color of its own). The route is a set
|
||||
* of points, not a path (the check-ins are in any order), so "next"
|
||||
* is the NEAREST unvisited waypoint: in the route's OWN system it is
|
||||
* the waypoint itself (plain coordinates — a compass entry with no
|
||||
* discovery object behind it); in ANOTHER system it is the jumpgate
|
||||
* that jumps there (the yellow takes the gate's slot — the gate's
|
||||
* ordinary cyan entry is suppressed, the route-gate's rule). Null when
|
||||
* nothing is active / done / claimed / in the route's system /
|
||||
* unvisited (the arrow stands until the last waypoint is logged).
|
||||
* @returns {{kind:'waypoint', idx:number, x:number, y:number}|{kind:'gate', gate:object}|null}
|
||||
*/
|
||||
patrolCompassTarget() {
|
||||
const st = this.sideQuestState;
|
||||
const id = st?.activeId ?? null;
|
||||
if (!id || !this.ship || !this.systemRecord) return null;
|
||||
if (st.isCompleted(id) || st.isClaimed(id)) return null;
|
||||
const route = st.routeFor(id);
|
||||
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return null;
|
||||
let idx = -1;
|
||||
let best = Infinity;
|
||||
for (let i = 0; i < route.waypoints.length; i++) {
|
||||
if (route.visited.includes(i)) continue;
|
||||
const d = (this.ship.x - route.waypoints[i][0]) ** 2 + (this.ship.y - route.waypoints[i][1]) ** 2;
|
||||
if (d < best) {
|
||||
best = d;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
if (idx < 0) return null; // every waypoint logged (completion lands this frame)
|
||||
const wp = route.waypoints[idx];
|
||||
if (route.targetSystemId === this.systemRecord.id) {
|
||||
return { kind: 'waypoint', idx, x: wp[0], y: wp[1] };
|
||||
}
|
||||
// Another system: the gate that jumps there (the player flies the
|
||||
// gate, not the coordinates).
|
||||
for (const gt of this.systemGates ?? []) if (gt.gate?.to === route.targetSystemId) return { kind: 'gate', gate: gt };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active patrol's CHECK-INS (per frame, update): the ship within
|
||||
* visitRadius (data/sidequests.json → patrol.visitRadius) of an
|
||||
* unvisited waypoint logs it — any order, the route is a set of
|
||||
* points (each check-in toasts + pings in the console's voice);
|
||||
* every waypoint logged stands the contract COMPLETE — the board's
|
||||
* CLAIM REWARD door opens, and the reward stays unclaimed until the
|
||||
* player takes it (the patrol's completion is separate from its
|
||||
* payout, like the story quests' claim).
|
||||
*/
|
||||
updatePatrol() {
|
||||
const st = this.sideQuestState;
|
||||
const id = st?.activeId ?? null;
|
||||
if (!id || !this.ship || !this.systemRecord) return;
|
||||
if (st.isCompleted(id) || st.isClaimed(id)) return;
|
||||
const route = st.routeFor(id);
|
||||
if (!route || route.targetSystemId !== this.systemRecord.id) return; // the route's system ≠ here — no check-ins while away
|
||||
const wps = route.waypoints;
|
||||
if (wps.length === 0) return;
|
||||
const radius = Math.max(1, Number(config.get('sidequests.patrol.visitRadius', 420)) || 420);
|
||||
for (let i = 0; i < wps.length; i++) {
|
||||
if (route.visited.includes(i)) continue;
|
||||
if (Math.hypot(this.ship.x - wps[i][0], this.ship.y - wps[i][1]) <= radius) {
|
||||
if (st.visitWaypoint(id, i)) { // idempotent — only the first frame logs
|
||||
const t = config.get('sidequests.ui.patrolWaypointToast', {});
|
||||
this.playSfx('discovery'); // the waypoint's soft chime
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'WAYPOINT {i} OF {n} LOGGED').replace('{i}', String(i + 1)).replace('{n}', String(wps.length)),
|
||||
{ glyph: t.glyph ?? '◈', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (route.visited.length === wps.length && !st.isCompleted(id)) {
|
||||
st.complete(id); // the done contract drops out of the ACTIVE slot (reconcileActive stands the next top one)
|
||||
const t = config.get('sidequests.ui.patrolCompleteToast', {});
|
||||
this.playSfx('construct'); // the route's done tick
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'PATROL ROUTE COMPLETE — REPORT TO THE JOB BOARD FOR YOUR REWARD'),
|
||||
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active patrol's IN-WORLD beacons (per frame, update): the route
|
||||
* waypoints in the route's system — a diamond + ring + soft glow per
|
||||
* point (the data's patrol.marker colors): the yellow PULSE while the
|
||||
* waypoint stands, the dim done mark once visited. Only the ACTIVE
|
||||
* contract's route, only in ITS system (the other contracts' routes
|
||||
* sleep in the ledger; the arrow points the player to their system
|
||||
* first). Clear of the world's solids by construction (the route's
|
||||
* keep-out), so no collision glow is owed.
|
||||
*/
|
||||
_drawPatrolMarkers(time) {
|
||||
const g = this.patrolG;
|
||||
if (!g) return;
|
||||
g.clear();
|
||||
const st = this.sideQuestState;
|
||||
const id = st?.activeId ?? null;
|
||||
if (!id || !this.systemRecord || st.isCompleted(id) || st.isClaimed(id)) return;
|
||||
const route = st.routeFor(id);
|
||||
if (!route || route.targetSystemId !== this.systemRecord.id) return;
|
||||
const color = toColor(config.get('sidequests.patrol.marker.color', '#ffd60a'), 0xffd60a);
|
||||
const doneColor = toColor(config.get('sidequests.patrol.marker.doneColor', '#67e863'), 0x67e863);
|
||||
for (let i = 0; i < route.waypoints.length; i++) {
|
||||
const [x, y] = route.waypoints[i];
|
||||
const done = route.visited.includes(i);
|
||||
const c = done ? doneColor : color;
|
||||
const pulse = done ? 0 : 0.5 + 0.5 * Math.sin(time * 0.004 + i * 1.7); // each point on its own phase
|
||||
const s = 7 + (done ? 0 : 3 * pulse); // the diamond's half-diagonal
|
||||
g.fillStyle(c, 0.05 + (done ? 0 : 0.05 * pulse)); // the soft glow
|
||||
g.fillCircle(x, y, 26 + (done ? 0 : 6 * pulse));
|
||||
g.lineStyle(1.5, c, done ? 0.4 : 0.45 + 0.4 * pulse); // the ring
|
||||
g.strokeCircle(x, y, 15 + (done ? 0 : 5 * pulse));
|
||||
g.fillStyle(c, done ? 0.5 : 0.95); // the diamond (45° square)
|
||||
g.fillPoints([{ x, y: y - s }, { x: x + s, y }, { x, y: y + s }, { x: x - s, y }], true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLAIM REWARD action (QuestWindow's button → here). The window
|
||||
* only offers the button when the checklist reads complete; this is
|
||||
|
|
@ -4517,6 +4913,50 @@ export class GameScene extends Phaser.Scene {
|
|||
* effect (the minerals land in the hold); the ledger owns the record.
|
||||
*/
|
||||
claimQuest(id) {
|
||||
// SIDE QUEST — the side ledger's sq- id (the tracker popup's CLAIM
|
||||
// REWARD + the console's CLAIM REWARD both land here): the side
|
||||
// path (claimSideQuest — the payout + the idempotency live there),
|
||||
// with the console's toast shape. A story id falls through to the
|
||||
// story ledger's path below.
|
||||
if (typeof id === 'string' && id.startsWith('sq-')) {
|
||||
const st = this.sideQuestState;
|
||||
if (st?.isClaimed(id)) {
|
||||
this.questWindow?.refresh();
|
||||
return;
|
||||
}
|
||||
if (!st?.isAccepted(id) || !st.isCompleted(id)) {
|
||||
const t = config.get('sidequests.ui.claimDenyToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'NOTHING TO CLAIM — ROUTE NOT COMPLETE'),
|
||||
{
|
||||
glyph: t.glyph ?? '✗',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
|
||||
},
|
||||
);
|
||||
this.questWindow?.refresh();
|
||||
return;
|
||||
}
|
||||
const res = this.claimSideQuest(id);
|
||||
if (res?.ok) {
|
||||
const t = config.get('sidequests.ui.claimToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'REWARD CLAIMED — +{reward} CR').replace('{reward}', String(res.credits ?? 0)),
|
||||
{
|
||||
glyph: t.glyph ?? '✓',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
|
||||
},
|
||||
);
|
||||
// The claimed contract drops off the tracker's featured slot
|
||||
// (the story claim's shape — the priority re-resolves to the
|
||||
// next active quest on the next poll).
|
||||
if (this.questState?.priority === id) {
|
||||
this.questState.priority = null;
|
||||
this.questState.priorityCleared = false;
|
||||
}
|
||||
}
|
||||
this.questWindow?.refresh();
|
||||
return;
|
||||
}
|
||||
if (!this.questState || !this.questState.isGranted(id)) return;
|
||||
if (this.questState.isClaimed(id)) {
|
||||
this.questWindow?.refresh();
|
||||
|
|
@ -5010,8 +5450,34 @@ export class GameScene extends Phaser.Scene {
|
|||
* to the newly featured quest LIVE.
|
||||
*/
|
||||
setPriorityQuest(id) {
|
||||
if (!this.questState) return;
|
||||
if (!this.questState.setPriority(id)) return; // not held, claimed, or already the priority
|
||||
if (!this.questState) return false;
|
||||
// SIDE QUEST — the side ledger's sq- id (the popup/console's SET
|
||||
// PRIORITY both land here): the choice is the side ledger's ACTIVE
|
||||
// slot (the contract in flight) featured through the tracker's seam
|
||||
// (the story ledger's priority — the scene owns both ledgers; the
|
||||
// story ledger itself stays story-pure).
|
||||
if (typeof id === 'string' && id.startsWith('sq-')) {
|
||||
if (!this.sideQuestState?.setActive(id)) return false; // not held, done, or claimed
|
||||
this.questState.priorityCleared = false; // the explicit set re-arms (a clear sticks until then)
|
||||
this.questState.resolvePriority([id]); // the featured slot (stores the side id)
|
||||
const t = config.get('quests.tracker.priorityToast', {});
|
||||
let title = id;
|
||||
try {
|
||||
title = this.questSnapshot?.()?.quests?.find((q) => q.id === id)?.title ?? id;
|
||||
} catch {
|
||||
/* the id stands */
|
||||
}
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(title).toUpperCase()),
|
||||
{
|
||||
glyph: t.glyph ?? '◆',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
|
||||
},
|
||||
);
|
||||
this.questWindow?.refresh();
|
||||
return true;
|
||||
}
|
||||
if (!this.questState.setPriority(id)) return false; // not held, claimed, or already the priority
|
||||
const def = questDef(id);
|
||||
const t = config.get('quests.tracker.priorityToast', {});
|
||||
this.consoleToast(
|
||||
|
|
@ -5019,6 +5485,7 @@ export class GameScene extends Phaser.Scene {
|
|||
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)) },
|
||||
);
|
||||
this.questWindow?.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1250,7 +1250,12 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
postings: () => this.gameScene?.sideQuestPostings?.() ?? [],
|
||||
isAccepted: (id) => this.gameScene?.sideQuestState?.isAccepted?.(id) ?? false,
|
||||
isCompleted: (id) => this.gameScene?.sideQuestState?.isCompleted?.(id) ?? false,
|
||||
isActive: (id) => this.gameScene?.sideQuestState?.isActive?.(id) ?? false,
|
||||
isClaimed: (id) => this.gameScene?.sideQuestState?.isClaimed?.(id) ?? false,
|
||||
routeProgress: (id) => this.gameScene?.patrolProgress?.(id) ?? null,
|
||||
onAccept: (id) => this.acceptSurfaceJob(id),
|
||||
onActivate: (id) => this.activateSurfaceJob(id),
|
||||
onClaim: (id) => this.claimSurfaceJob(id),
|
||||
});
|
||||
|
||||
// The QUESTS console (the deck's QUESTS slot — right of SHIP): the
|
||||
|
|
@ -1463,16 +1468,31 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
* on the SLEEPING GameScene (the quest ledger + the ship's hold are
|
||||
* its; the beginBuild precedent), and the console note is ours (we are
|
||||
* the scene the player sees — a sleeping scene's toasts don't animate).
|
||||
* SIDE QUESTS (the board's accepted contracts — the sq- ids) ride the
|
||||
* same door: the scene's claimQuest dispatches them to the side
|
||||
* ledger's payout + idempotency, and the note stands on the side
|
||||
* ledger's claim mark + the contract's payout (the route record's
|
||||
* credits — fixed at the accept).
|
||||
*/
|
||||
claimSurfaceQuest(id) {
|
||||
const g = this.gameScene;
|
||||
if (!g?.claimQuest) return;
|
||||
const before = g.questState?.isClaimed(id) ?? false;
|
||||
g.claimQuest(id);
|
||||
const now = g.questState?.isClaimed(id) ?? false;
|
||||
const isSide = typeof id === 'string' && id.startsWith('sq-');
|
||||
const claimedOf = (gid) =>
|
||||
isSide ? gid.sideQuestState?.isClaimed?.(id) ?? false : gid.questState?.isClaimed?.(id) ?? false;
|
||||
const before = claimedOf(g);
|
||||
g.claimQuest(id); // the scene's seam (the side dispatch for sq- ids)
|
||||
const now = claimedOf(g);
|
||||
if (!before && now) {
|
||||
const t = config.get('quests.claimToast', {});
|
||||
this.consoleNote(String(t.text ?? 'REWARD CLAIMED'), {
|
||||
const t = isSide ? config.get('sidequests.ui.claimToast', {}) : config.get('quests.claimToast', {});
|
||||
let text = String(t.text ?? 'REWARD CLAIMED');
|
||||
if (isSide) {
|
||||
text = text.replace('{reward}', `+${g.sideQuestState?.routeFor?.(id)?.credits ?? 0} CR`);
|
||||
} else {
|
||||
const label = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.reward ?? '';
|
||||
if (label) text = text.replace('{reward}', String(label).toUpperCase());
|
||||
}
|
||||
this.consoleNote(text, {
|
||||
glyph: t.glyph ?? '✓',
|
||||
glyphColor: t.color ? toCss(t.color) : undefined,
|
||||
});
|
||||
|
|
@ -1480,11 +1500,10 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
// Still open — the refusal, in the console's voice (the game
|
||||
// scene's own toast is behind the sleeping scene — this is the one
|
||||
// the player reads).
|
||||
const t = config.get('quests.claimDenyToast', {});
|
||||
this.consoleNote(
|
||||
String(t.text ?? 'REQUIREMENTS INCOMPLETE').replace('{missing}', 'REQUIREMENTS'),
|
||||
{ glyph: t.glyph ?? '✗', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
const t = isSide ? config.get('sidequests.ui.claimDenyToast', {}) : config.get('quests.claimDenyToast', {});
|
||||
this.consoleNote(String(t.text ?? 'NOTHING TO CLAIM'), {
|
||||
glyph: t.glyph ?? '✗', glyphColor: t.color ? toCss(t.color) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1517,15 +1536,81 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.jobBoardWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* SET ACTIVE from the surface console — the ledger's ONE ACTIVE slot
|
||||
* (GameScene.setActiveSideQuest — the scene's state; the note is
|
||||
* ours, the same shape as acceptSurfaceJob). The active state IS the
|
||||
* in-flight face: the sleeping GameScene's compass reads
|
||||
* sideQuestState.activeId (the yellow next-stop arrow — the gate
|
||||
* toward the route's system when it's another system, the nearest
|
||||
* unvisited waypoint when it's this one) and paints the route's
|
||||
* in-world beacons.
|
||||
*/
|
||||
activateSurfaceJob(jobId) {
|
||||
const g = this.gameScene;
|
||||
if (!g?.setActiveSideQuest) return;
|
||||
if (!g.setActiveSideQuest(jobId)) return; // not held, done, or claimed
|
||||
const t = config.get('sidequests.ui.activateToast', {});
|
||||
// The note names the contract (the posting's title off the scene's
|
||||
// live board — the acceptToast precedent).
|
||||
const posting = (g.sideQuestPostings?.() ?? []).find((p) => p.id === jobId) ?? null;
|
||||
const text = String(t.text ?? 'PATROL ACTIVE').replace('{title}', String(posting?.title ?? '').toUpperCase());
|
||||
this.consoleNote(text, {
|
||||
glyph: t.glyph ?? '▸',
|
||||
glyphColor: t.color ? toCss(t.color) : undefined,
|
||||
});
|
||||
this.jobBoardWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* CLAIM REWARD from the surface console (GameScene.claimSideQuest —
|
||||
* the scene's state + the payout, the claimSurfaceQuest precedent):
|
||||
* the contract's credits land exactly once (the ledger's claim is
|
||||
* idempotent); the note is ours (we are the scene the player sees).
|
||||
*/
|
||||
claimSurfaceJob(jobId) {
|
||||
const g = this.gameScene;
|
||||
if (!g?.claimSideQuest) return;
|
||||
const res = g.claimSideQuest(jobId);
|
||||
if (res?.ok) {
|
||||
const t = config.get('sidequests.ui.claimToast', {});
|
||||
this.consoleNote(
|
||||
String(t.text ?? 'REWARD CLAIMED — +{reward} CR').replace('{reward}', String(res.credits ?? 0)),
|
||||
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
} else {
|
||||
const d = config.get('sidequests.ui.claimDenyToast', {});
|
||||
this.consoleNote(String(d.text ?? 'NOTHING TO CLAIM'), {
|
||||
glyph: d.glyph ?? '✗',
|
||||
glyphColor: d.color ? toCss(d.color) : undefined,
|
||||
});
|
||||
}
|
||||
this.jobBoardWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* SET PRIORITY from the surface console — the ledger lives on the
|
||||
* sleeping GameScene (the claimSurfaceQuest precedent), and the
|
||||
* console note is ours (we are the scene the player sees).
|
||||
* SIDE QUESTS ride the same door: the choice is the side ledger's
|
||||
* ACTIVE slot (the contract in flight) featured through the tracker's
|
||||
* seam (the story ledger's priority — the scene owns both ledgers).
|
||||
*/
|
||||
setPrioritySurfaceQuest(id) {
|
||||
const g = this.gameScene;
|
||||
if (!g?.questState) return;
|
||||
if (!g.questState.setPriority(id)) return; // not held, claimed, or already the priority
|
||||
const isSide = typeof id === 'string' && id.startsWith('sq-');
|
||||
let ok = false;
|
||||
if (isSide) {
|
||||
ok = g.sideQuestState?.setActive(id) ?? false; // not held, done, or claimed
|
||||
if (ok) {
|
||||
g.questState.priorityCleared = false; // the explicit set re-arms (a clear sticks until then)
|
||||
g.questState.resolvePriority([id]); // the featured slot (stores the side id)
|
||||
}
|
||||
} else {
|
||||
ok = g.questState.setPriority(id); // not held, claimed, or already the priority
|
||||
}
|
||||
if (!ok) return;
|
||||
let title = id;
|
||||
try {
|
||||
title = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.title ?? id;
|
||||
|
|
|
|||
|
|
@ -168,7 +168,12 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
this.postings = typeof o.postings === 'function' ? o.postings : () => [];
|
||||
this.isAccepted = typeof o.isAccepted === 'function' ? o.isAccepted : () => false;
|
||||
this.isCompleted = typeof o.isCompleted === 'function' ? o.isCompleted : () => false;
|
||||
this.isActive = typeof o.isActive === 'function' ? o.isActive : () => false;
|
||||
this.isClaimed = typeof o.isClaimed === 'function' ? o.isClaimed : () => false;
|
||||
this.routeProgress = typeof o.routeProgress === 'function' ? o.routeProgress : () => null;
|
||||
this.onAccept = typeof o.onAccept === 'function' ? o.onAccept : null;
|
||||
this.onActivate = typeof o.onActivate === 'function' ? o.onActivate : null;
|
||||
this.onClaim = typeof o.onClaim === 'function' ? o.onClaim : null;
|
||||
this.selectedId = null; // the list's selection (the detail's contract)
|
||||
|
||||
this.openState = 'closed'; // closed | opening | open | closing
|
||||
|
|
@ -241,7 +246,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
const headH = 12; // a section's header line (10 px font)
|
||||
const headGap = 10; // header → panel
|
||||
const secGap = 16; // available panel → details header
|
||||
const detailH = 190; // the details panel — title + issuer + the contract's copy + the payout + the ACCEPT bar
|
||||
const detailH = 210; // the details panel — title + issuer + the contract's lines (destination / route) + the copy + the payout + the action bar
|
||||
const availHeadY = bodyY + 8; // the AVAILABLE JOBS header (just under the top bar)
|
||||
const availTop = availHeadY + headH + headGap;
|
||||
const detailPanelY = bodyBottom - detailH;
|
||||
|
|
@ -481,10 +486,28 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
}
|
||||
}
|
||||
|
||||
/** The row/door's LEDGER state off the seams (the scene's truth, the
|
||||
* board's contract): open → accepted → active → complete → claimed.
|
||||
* (A category without an active stage — the stubs — simply never reads
|
||||
* 'active'; the door's shape is the same either way.) */
|
||||
_rowState(p) {
|
||||
if (this.isClaimed(p.id)) return 'claimed';
|
||||
if (this.isCompleted(p.id)) return 'complete';
|
||||
if (this.isActive(p.id)) return 'active';
|
||||
if (this.isAccepted(p.id)) return 'accepted';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
/** The patrol's compass color as a hex number (the active state's accent). */
|
||||
_activeColor() {
|
||||
return hexNum(config.get('sidequests.patrol.compass.color', '#ffd60a'), 0xffd60a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the AVAILABLE JOBS list: the postings' rows (chip + title +
|
||||
* issuer line, the payout — or the row's LEDGER state once the contract
|
||||
* is taken), selectable, or the standby plate while no category is live.
|
||||
* is taken / active / done / paid), selectable, or the standby plate
|
||||
* while no category is live.
|
||||
*/
|
||||
_renderList() {
|
||||
const s = this.scene.add;
|
||||
|
|
@ -529,7 +552,6 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
const gap = 8;
|
||||
const n = posts.length;
|
||||
const rowH = Math.max(34, Math.min(58, (availH - 2 * pad - (n - 1) * gap) / n));
|
||||
const unit = String(ui.rewardUnit ?? 'CR');
|
||||
|
||||
posts.forEach((p, i) => {
|
||||
const y = availTop + pad + i * (rowH + gap);
|
||||
|
|
@ -556,15 +578,26 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
.setScrollFactor(0);
|
||||
// The row's right end: the payout while open, the ledger's mark once
|
||||
// taken (the state is read off the seams at repaint — the ledger is
|
||||
// the truth, the scene's refresh() lands it after the accept).
|
||||
let rightTxt = `${p.reward?.credits ?? 0} ${unit}`;
|
||||
let rightColor = C.amber;
|
||||
if (this.isCompleted(p.id)) {
|
||||
rightTxt = String(ui.completed ?? 'CONTRACT COMPLETE');
|
||||
// the truth, the scene's refresh() lands it after each action).
|
||||
const state = this._rowState(p);
|
||||
const unit = String(ui.rewardUnit ?? 'CR');
|
||||
let rightTxt;
|
||||
let rightColor;
|
||||
if (state === 'claimed') {
|
||||
rightTxt = String(ui.claimed ?? 'REWARD CLAIMED');
|
||||
rightColor = C.faint;
|
||||
} else if (state === 'complete') {
|
||||
rightTxt = String(ui.complete ?? 'CONTRACT COMPLETE');
|
||||
rightColor = C.neon;
|
||||
} else if (this.isAccepted(p.id)) {
|
||||
} else if (state === 'active') {
|
||||
rightTxt = String(ui.active ?? 'PATROL ACTIVE');
|
||||
rightColor = this._activeColor();
|
||||
} else if (state === 'accepted') {
|
||||
rightTxt = String(ui.accepted ?? 'CONTRACT ACCEPTED');
|
||||
rightColor = C.green;
|
||||
} else {
|
||||
rightTxt = `${p.reward?.credits ?? 0} ${unit}`;
|
||||
rightColor = C.amber;
|
||||
}
|
||||
const right = s
|
||||
.text(rightX + rightW - 16, y + 10, rightTxt, {
|
||||
|
|
@ -689,8 +722,48 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
letterSpacing: 2,
|
||||
})
|
||||
.setScrollFactor(0);
|
||||
// The contract's LINES (the patrol's shape — the other categories',
|
||||
// when they land, add their own): the DESTINATION (a patrol whose
|
||||
// route stands in ANOTHER system — the posting names the target)
|
||||
// and the ROUTE progress (the accepted patrol's checkpoints — the
|
||||
// ledger's truth off the scene's seam).
|
||||
let lineY = py + 52;
|
||||
if (sel.targetSystemName && sel.targetSystemName !== sel.systemName) {
|
||||
const dest = s
|
||||
.text(
|
||||
rightX + 14,
|
||||
lineY,
|
||||
String(ui.destination ?? 'DESTINATION — {name}').replace('{name}', String(sel.targetSystemName).toUpperCase()),
|
||||
{ fontFamily: BODY, fontSize: '9.5px', color: toCss(C.amber), letterSpacing: 2, fontStyle: 'bold' },
|
||||
)
|
||||
.setScrollFactor(0);
|
||||
this.add(dest);
|
||||
this.detailSection.content.push(dest);
|
||||
lineY += 16;
|
||||
}
|
||||
let prog = null;
|
||||
try {
|
||||
prog = this.routeProgress(sel.id);
|
||||
} catch {
|
||||
prog = null; // a seam fault stands the line out, never a crash
|
||||
}
|
||||
if (prog && Number.isFinite(prog.visited) && Number.isFinite(prog.total)) {
|
||||
const route = s
|
||||
.text(
|
||||
rightX + 14,
|
||||
lineY,
|
||||
String(ui.routeProgress ?? 'ROUTE — {visited}/{total} WAYPOINTS LOGGED')
|
||||
.replace('{visited}', String(prog.visited))
|
||||
.replace('{total}', String(prog.total)),
|
||||
{ fontFamily: BODY, fontSize: '9.5px', color: toCss(this._activeColor()), letterSpacing: 2, fontStyle: 'bold' },
|
||||
)
|
||||
.setScrollFactor(0);
|
||||
this.add(route);
|
||||
this.detailSection.content.push(route);
|
||||
lineY += 16;
|
||||
}
|
||||
const body = s
|
||||
.text(rightX + 14, py + 52, fitText(String(sel.description ?? ''), Math.floor(wrapW / 5.4), 5), {
|
||||
.text(rightX + 14, lineY, fitText(String(sel.description ?? ''), Math.floor(wrapW / 5.4), 5), {
|
||||
fontFamily: BODY,
|
||||
fontSize: '10.5px',
|
||||
color: toCss(C.dim),
|
||||
|
|
@ -708,16 +781,33 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
})
|
||||
.setScrollFactor(0);
|
||||
|
||||
// the ACCEPT door (full-width bar — the console's action shape; its
|
||||
// state off the ledger's seams: open → accepted → complete)
|
||||
const state = this.isCompleted(sel.id) ? 'completed' : this.isAccepted(sel.id) ? 'accepted' : 'open';
|
||||
const label =
|
||||
state === 'completed'
|
||||
? String(ui.completed ?? 'CONTRACT COMPLETE')
|
||||
: state === 'accepted'
|
||||
? String(ui.accepted ?? 'CONTRACT ACCEPTED')
|
||||
: String(ui.accept ?? 'ACCEPT CONTRACT');
|
||||
const color = state === 'open' ? accent : state === 'accepted' ? C.green : C.neon;
|
||||
// the ACTION door (full-width bar — the console's action shape; its
|
||||
// state off the ledger's seams: open → accepted → active → complete
|
||||
// → claimed — each state's door onto the scene's own seam; the
|
||||
// active/claimed states stand as plates — nothing to press)
|
||||
const state = this._rowState(sel);
|
||||
let label;
|
||||
let color;
|
||||
let action = null; // 'accept' | 'activate' | 'claim' | null (a plate)
|
||||
if (state === 'open') {
|
||||
label = String(ui.accept ?? 'ACCEPT CONTRACT');
|
||||
color = accent;
|
||||
action = 'accept';
|
||||
} else if (state === 'accepted') {
|
||||
label = String(ui.activate ?? 'SET PATROL ACTIVE');
|
||||
color = C.green;
|
||||
action = 'activate';
|
||||
} else if (state === 'active') {
|
||||
label = String(ui.active ?? 'PATROL ACTIVE');
|
||||
color = this._activeColor();
|
||||
} else if (state === 'complete') {
|
||||
label = String(ui.claim ?? 'CLAIM REWARD — {reward} CR').replace('{reward}', String(sel.reward?.credits ?? 0));
|
||||
color = C.green;
|
||||
action = 'claim';
|
||||
} else {
|
||||
label = String(ui.claimed ?? 'REWARD CLAIMED');
|
||||
color = C.faint;
|
||||
}
|
||||
const bh = 26;
|
||||
const by = py + ph - 34;
|
||||
const bx = rightX + 14;
|
||||
|
|
@ -741,14 +831,14 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
panel(g, bx, by, bw, bh, {
|
||||
notch: 7,
|
||||
fill: C.panel,
|
||||
fillAlpha: state === 'open' ? (btn.hover ? 0.9 : 0.5) : 0.35,
|
||||
fillAlpha: action ? (btn.hover ? 0.9 : 0.5) : 0.35,
|
||||
stroke: color,
|
||||
strokeAlpha: state === 'open' ? (btn.hover ? 1 : 0.8) : 0.45,
|
||||
strokeAlpha: action ? (btn.hover ? 1 : 0.8) : 0.45,
|
||||
});
|
||||
txt.setAlpha(state === 'open' ? (btn.hover ? 1 : 0.9) : 0.7);
|
||||
txt.setAlpha(action ? (btn.hover ? 1 : 0.9) : 0.7);
|
||||
};
|
||||
paint();
|
||||
if (state === 'open') {
|
||||
if (action) {
|
||||
const hit = new Phaser.Geom.Rectangle(bx, by, bw, bh);
|
||||
g.setInteractive({ useHandCursor: true, hitArea: hit, hitAreaCallback: (area, px, py2) => area.contains(px, py2) });
|
||||
g.on('pointerover', () => {
|
||||
|
|
@ -762,7 +852,9 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
});
|
||||
g.on('pointerdown', () => {
|
||||
this.sfx('ui_click');
|
||||
if (this.onAccept) this.onAccept(sel.id);
|
||||
if (action === 'accept' && this.onAccept) this.onAccept(sel.id);
|
||||
else if (action === 'activate' && this.onActivate) this.onActivate(sel.id);
|
||||
else if (action === 'claim' && this.onClaim) this.onClaim(sel.id);
|
||||
});
|
||||
}
|
||||
this.add(txt);
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@
|
|||
* Left: the mission feed — assets/videos/quests.mp4, a 2:3 portrait (the
|
||||
* same dimensions as research.mp4) that LOOPS MUTED while the console is
|
||||
* open; the NO SIGNAL plate stands in until the clip is authored.
|
||||
* Right: the category tabs — MAIN STORY (live) and SIDE QUESTS (a
|
||||
* STANDBY socket — side quests are deferred by design) — a THIN list of
|
||||
* Right: the category tabs — MAIN STORY (the campaign's spine) and SIDE
|
||||
* QUESTS (live — the board's accepted contracts, their patrol waypoints
|
||||
* as the checklist, claimed from here like any quest) — a THIN list of
|
||||
* the active quests, and below it the big detail plate: the quest's name,
|
||||
* who/where it was issued from, its description, the requirement
|
||||
* checklist, and its reward, with the CLAIM action.
|
||||
|
|
@ -612,7 +613,7 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
const defs = config.get('quests.tabs', []);
|
||||
const arr = Array.isArray(defs) && defs.length ? defs : [
|
||||
{ id: 'main', label: 'Main Story' },
|
||||
{ id: 'side', label: 'Side Quests', standby: true },
|
||||
{ id: 'side', label: 'Side Quests' },
|
||||
];
|
||||
const tabW = 170;
|
||||
const gap = 10;
|
||||
|
|
@ -811,7 +812,7 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
const quests = (this._snap?.quests ?? []).filter((q) => q.category === this._activeTab);
|
||||
if (quests.length === 0) {
|
||||
const msg = this._activeTab === 'side'
|
||||
? 'NO CONTRACTS ON FILE — SIDE QUESTS OFFLINE'
|
||||
? 'NO CONTRACTS ON FILE — ACCEPT A JOB AT THE BOARD'
|
||||
: 'NO ACTIVE QUESTS — THE DOSSIER IS EMPTY';
|
||||
const txt = s
|
||||
.text(rightX + 14, listY + 30, msg, {
|
||||
|
|
|
|||
Loading…
Reference in New Issue