From 41f6dbbfd9da217a18c2a2c446e427aacb74603c Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 22 Sep 2026 19:14:33 -0600 Subject: [PATCH] Make side quest job boards per-surface-world instead of per-system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Board postings now derive from (seed, system, world key) so two worlds in one system stand different boards; count range drops to 1–3 per world - Posting ids gain a world segment (`sq----`); add `parseSideQuestId` and use it for scene lookups and claim reward reads - `allSideQuestJobIds` now builds the job space from each system's surface worlds (planets + free-space stations) via `galaxy.ensureContent`; old per-system ids no longer stand, so in-flight contracts in pre-per-world saves drop on load (documented Path A trade) - Surface scene passes its world name/isStation through to postings, accept, and activate; window copy updated to say "world" not "system" - Tests updated for the new signature, per-world determinism, id parsing, and job-space guards --- data/sidequests.json | 12 +-- dev/bounty-hunt-shot.mjs | 6 +- dev/bounty-hunts.test.mjs | 51 +++++++---- dev/sidequests.test.mjs | 148 ++++++++++++++++++++++---------- js/quests/SideQuests.js | 176 ++++++++++++++++++++++++++++---------- js/save/SaveData.js | 13 ++- js/scenes/GameScene.js | 91 ++++++++++++++------ js/scenes/SurfaceScene.js | 11 +-- js/ui/JobBoardWindow.js | 6 +- 9 files changed, 358 insertions(+), 156 deletions(-) diff --git a/data/sidequests.json b/data/sidequests.json index dc43441..e930206 100644 --- a/data/sidequests.json +++ b/data/sidequests.json @@ -1,11 +1,11 @@ { - "_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.", + "_comment": "SIDE QUESTS — the contracts posted on the JOB BOARD (the Explore drawer's Job Board slot — every SURFACE WORLD EXCEPT in the home system, data/actionbar.json → surface.explore.items). The board is a VIEW over js/quests/SideQuests.js: `sideQuestPostingsFor(seed, record, worldKey)` derives 1–3 deterministic postings from the galaxy seed + the system record + the WORLD (a planet's p / a station's s — two worlds in one system stand two DIFFERENT boards; the same world posts the 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). Note: the board went PER-WORLD (from per-system) — old per-system ids (sq---) no longer stand a board, and in-flight contracts in old saves drop on load (a documented Path A trade). 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 boards read different contracts.", "enabled": true, "board": { - "_comment": "perSystem = the postings-per-system roll [min,max] (seeded per system — Rng.derive(seed, seedLabel, ); 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 + "_comment": "perWorld = the postings-per-surface-world roll [min,max] (seeded per world — Rng.derive(seed, seedLabel, , ); 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).", + "perWorld": [ + 1, + 3 ], "seedLabel": "sidequests" }, @@ -196,7 +196,7 @@ } }, "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. THE BOUNTY POOLS ARE DIFFERENT — descriptions.neutral.bounty is the BOUNTY HUNT's WANTED LIST: 20 dossiers, each a named pirate leader with an epithet, a crime, and the craft it flies (`craft` = a data/combat.json → enemyTypes id, spread across all seven frames, so the copy and the fight agree; `craftLabel` is the readout's plain words for it). descriptions.faction.bounty is deliberately EMPTY — a documented stub: categories.bounty.contexts is ['neutral'], so a faction board never draws a hunt, and the pool stays empty until a faction author posts its own wanted list (the generator would otherwise fall back to the neutral list and post pirate-hunt copy on a faction board).", + "_comment": "The variant pools: context (neutral | faction) × category. The generator picks ONE variant per posting, seeded on (galaxy seed, system id, world key, category, slot) — a world's contract is STABLE (the board doesn't reshuffle between visits or saves), and different worlds draw different ones (3+ variants per pool keeps the galaxy's boards from reading as one repeated ad). Placeholders (resolved at generation, never stored): {system} = the system's name; in the faction pools {faction} = the system's faction label (data/factions.json), {issuer} = that faction's board name (→ factions above), {enemies} = the OTHER two factions ('MindWeave and Zenith'). The NEUTRAL pools are the pirate-safety voice; the FACTION pools are the push-the-other-factions-out voice. THE BOUNTY POOLS ARE DIFFERENT — descriptions.neutral.bounty is the BOUNTY HUNT's WANTED LIST: 20 dossiers, each a named pirate leader with an epithet, a crime, and the craft it flies (`craft` = a data/combat.json → enemyTypes id, spread across all seven frames, so the copy and the fight agree; `craftLabel` is the readout's plain words for it). descriptions.faction.bounty is deliberately EMPTY — a documented stub: categories.bounty.contexts is ['neutral'], so a faction board never draws a hunt, and the pool stays empty until a faction author posts its own wanted list (the generator would otherwise fall back to the neutral list and post pirate-hunt copy on a faction board).", "neutral": { "patrol": [ { diff --git a/dev/bounty-hunt-shot.mjs b/dev/bounty-hunt-shot.mjs index c81f0d4..0fc06a8 100644 --- a/dev/bounty-hunt-shot.mjs +++ b/dev/bounty-hunt-shot.mjs @@ -63,8 +63,8 @@ async function waitFor(fn, tries = 260, everyMs = 100) { } const STAGE = (location.hash || '#locked').slice(1); -/** The hunt contracts on the standing world's board. */ -const huntsOn = (gs) => gs.sideQuestPostings().filter((p) => p.category === 'bounty'); +/** The hunt contracts on the standing world's board (the board is per-world now). */ +const huntsOn = (gs) => gs.sideQuestPostings(gs.planet?.discoveryName ?? null, false).filter((p) => p.category === 'bounty'); try { // ── the real boot: menu → NEW GAME on a seed whose home system is Neutral ── @@ -115,7 +115,7 @@ try { combatLevel: gs.combatLevel(), board: { openState: win.openState, - rows: gs.sideQuestPostings().map((p) => ({ id: p.id, category: p.category, locked: p.locked === true, minLevel: p.minLevel ?? null })), + rows: gs.sideQuestPostings(gs.planet?.discoveryName ?? null, false).map((p) => ({ id: p.id, category: p.category, locked: p.locked === true, minLevel: p.minLevel ?? null })), huntRow: { title: h.title, issuer: h.issuer, reward: h.reward, locked: h.locked === true, minLevel: h.minLevel ?? null, diff --git a/dev/bounty-hunts.test.mjs b/dev/bounty-hunts.test.mjs index 0e61fad..a9273df 100644 --- a/dev/bounty-hunts.test.mjs +++ b/dev/bounty-hunts.test.mjs @@ -123,11 +123,16 @@ section('THE WANTED LIST — 20 Neutral dossiers'); section('THE BOARD — rows on Neutral boards, LOCKED below level 2'); { const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40, zone: 'mid', type: 'void', capital: false }; - const at = (level) => S.sideQuestPostingsFor(1234, REC, null, level); + const at = (level, wk = 'p1') => S.sideQuestPostingsFor(1234, REC, wk, null, level); + // The board is PER-WORLD now (1–3 postings): no single world is guaranteed + // a bounty slot, so these assertions pool the record's own worlds (the + // model's contract is that the NEUTRAL voice posts the hunt — on SOME + // world's board). + const scan = (level) => ['p1', 'p2', 'p3', 'p4'].flatMap((wk) => at(level, wk)); const low = at(1), high = at(2), noLevel = at(null); - const huntsLow = low.filter((p) => p.category === 'bounty'); - check('a Neutral board posts BOUNTY HUNT contracts', huntsLow.length > 0 || at(7).some((p) => p.category === 'bounty'), `low=${huntsLow.length} high=${high.filter((p) => p.category === 'bounty').length}`); - const anyHunt = [...low, ...high, ...at(3), ...at(4), ...at(5)].find((p) => p.category === 'bounty'); + const huntsLow = scan(1).filter((p) => p.category === 'bounty'); + check('a Neutral board posts BOUNTY HUNT contracts', huntsLow.length > 0, `low=${huntsLow.length}`); + const anyHunt = scan(2).find((p) => p.category === 'bounty'); check('the hunt row carries the category label + accent', anyHunt?.label === 'BOUNTY HUNT' && /^#/.test(anyHunt?.accent)); for (const p of huntsLow) { check(`locked at level 1 (${p.title})`, p.locked === true && p.minLevel === 2); @@ -138,7 +143,7 @@ section('THE BOARD — rows on Neutral boards, LOCKED below level 2'); check('the detail line names the level too', /LEVEL 2/.test(subCopy?.text ?? ''), subCopy?.text); const deny = S.bountyDenyCopy(); check('the scene’s refusal exists and names the level', /LEVEL 2/.test(deny?.text ?? ''), deny?.text); - const huntHigh = high.filter((p) => p.category === 'bounty'); + const huntHigh = scan(2).filter((p) => p.category === 'bounty'); for (const p of huntHigh) check(`open at level 2 (${p.title})`, p.locked === false && p.minLevel === 2); check('an unknown level stands the board open (the gate is the SCENE’s read)', noLevel.every((p) => p.locked === false)); check('sideQuestAcceptable refuses below and opens at the gate', S.sideQuestAcceptable({ category: 'bounty' }, 1).locked === true && S.sideQuestAcceptable({ category: 'bounty' }, 2).ok === true); @@ -158,7 +163,7 @@ section('THE BOARD — rows on Neutral boards, LOCKED below level 2'); for (const f of ['apex', 'mindweave', 'zenith']) { const rec = { id: `sys-${f}`, name: `${f} Reach`, faction: f, d: 30, zone: 'mid', type: 'void', capital: false }; for (const lvl of [1, 2, 5]) { - const posts = S.sideQuestPostingsFor(99, rec, null, lvl); + const posts = S.sideQuestPostingsFor(99, rec, 'p1', null, lvl); check(`${f} boards never post a hunt (level ${lvl})`, posts.every((p) => p.category !== 'bounty'), posts.map((p) => p.category).join(',')); } } @@ -306,7 +311,7 @@ section('SEQUENTIAL LAW — only the NEXT leg logs (patrol law untouched)'); // ========================================================================== section('THE SAVE — a signed contract is a signed contract'); { - const JOB = 'sq-sys-free-bounty-0'; // a real posting id (sq---) — the save's job space + const JOB = 'sq-sys-free-p1-bounty-0'; // a real posting id (sq----) — the save's job space const plan = S.deriveBountyPlan(606, JOB, 'sys-free', 3, { craft: 'spitter', mark: { name: 'Save Mark', epithet: 'OF THE SAVE' } }); const st = new SideQuestState(); st.accept(JOB); @@ -315,7 +320,11 @@ section('THE SAVE — a signed contract is a signed contract'); st.visitWaypoint(JOB, 0); if (plan.pickets[0]) st.bountySkirmishCleared(JOB, 0); - const known = S.allSideQuestJobIds([{ id: 'sys-free', name: 'Free Reach', faction: null, d: 40 }]); + const known = S.allSideQuestJobIds({ + records: [{ id: 'sys-free', name: 'Free Reach', faction: null, d: 40 }], + byId: new Map([['sys-free', { id: 'sys-free', name: 'Free Reach', faction: null, d: 40 }]]), + ensureContent: () => ({ planets: [{ id: 'sys-free-p1', name: 'Free One', ordinal: 1 }], settlements: [], jumps: [] }), + }); check('the contract stands inside the save’s own job space', known.has(JOB)); const round = SideQuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON())), known); const r = round.routeFor(JOB); @@ -339,8 +348,8 @@ section('THE SAVE — a signed contract is a signed contract'); check('a bounty index outside the legs degrades (no plan, no fight)', bad.routeFor('sq-bad').waypoints.length === 3 && bad.routeFor('sq-bad').bounty === null); bad.setRoute('sq-bad', { kind: 'bounty', waypoints, bounty: { legs: 3, bountyAt: 2, pickets: [false, false], eliteType: 'not-a-frame' } }); check('an unknown frame degrades the plan rather than crashing one', bad.routeFor('sq-bad').bounty === null); - const preHunt = SideQuestState.fromJSON({ accepted: ['sq-sys-free-patrol-1'], routes: { 'sq-sys-free-patrol-1': { waypoints, visited: [0] } } }, known); - check('a save written before hunts existed loads as a patrol', preHunt.routeFor('sq-sys-free-patrol-1').kind === 'patrol' && preHunt.routeFor('sq-sys-free-patrol-1').bounty === null); + const preHunt = SideQuestState.fromJSON({ accepted: ['sq-sys-free-p1-patrol-1'], routes: { 'sq-sys-free-p1-patrol-1': { waypoints, visited: [0] } } }, known); + check('a save written before hunts existed loads as a patrol', preHunt.routeFor('sq-sys-free-p1-patrol-1').kind === 'patrol' && preHunt.routeFor('sq-sys-free-p1-patrol-1').bounty === null); } // ========================================================================== @@ -391,7 +400,7 @@ section('THE FLYING — accept → legs → mark → kill → claim (the real sc }; return s; } - const huntOf = (s) => S.sideQuestPostingsFor(s.galaxy.seed, REC, s.galaxy, s.combatLevel()).filter((p) => p.category === 'bounty'); + const huntOf = (s) => S.sideQuestPostingsFor(s.galaxy.seed, REC, 'p1', s.galaxy, s.combatLevel()).filter((p) => p.category === 'bounty'); // ---- the gate refuses at level 1 ------------------------------------------------- { @@ -441,13 +450,19 @@ section('THE FLYING — accept → legs → mark → kill → claim (the real sc check('the compass points at the NEXT leg only', compass?.kind === 'waypoint' && compass.idx === 0, JSON.stringify(compass)); check('the hunt’s compass carries its own label/color (never a contact)', /hunt/i.test(String(compass?.typeLabel ?? '')) && String(compass?.color).toLowerCase() === String(SQ.bounty.compass.color).toLowerCase()); - // Leg 1. + // Leg 1. (Whichever leg the rolled plan put its fight on stands here: + // the mark on this leg, a picket, or the quiet lane.) at(0); s.updateSideQuestRoute(); route = s.sideQuestState.routeFor(id); check('leg 1 logs', route.visited.includes(0)); const plan = route.bounty; - if (plan.pickets[0]) { + if (plan.bountyAt === 0) { + check('the mark stands on leg 1 — its nine-deep wave fires here', s.combat.started.length === 1 && s.combat.started[0].script.waves[0].total === 9, JSON.stringify(s.combat.started.map((x) => x.script.waves[0].total))); + const toast = s.toasts.at(-1).text; + check('the sighting names the leader (the mark is the fight)', /THE MARK IS HERE/.test(toast) && toast.includes(String(plan.mark?.name ?? '').toUpperCase()), toast); + clearRun(); + } else if (plan.pickets[0]) { check('a rolled picket fires on its own leg', s.combat.started.length === 1 && s.combat.started[0].script.waves[0].total === 6, JSON.stringify(s.combat.started.map((x) => x.script.waves[0].total))); const toast = s.toasts.at(-1).text; check('the picket toast counts hostiles but never names the mark', /INBOUND/.test(toast) && !new RegExp(String(plan.mark?.name ?? '@NOPE@')).test(toast), toast); @@ -533,7 +548,11 @@ section('THE FLYING — accept → legs → mark → kill → claim (the real sc check('no further wave fires once the hunt is done', (s.combat.started.length, s.updateSideQuestRoute(), s.combat.waveRun?.state !== 'running')); // The whole hunt rides the save. - const known = S.allSideQuestJobIds([REC]); + const known = S.allSideQuestJobIds({ + records: [REC], + byId: new Map([[REC.id, REC]]), + ensureContent: () => ({ planets: [{ id: 'sys-free-p1', name: 'Free One', ordinal: 1 }], settlements: [], jumps: [] }), + }); const back = SideQuestState.fromJSON(JSON.parse(JSON.stringify(s.sideQuestState.toJSON())), known); check('the finished hunt reloads claimed with its plan', back.isClaimed(id) && back.routeFor(id).bounty.down === true && back.routeFor(id).xp === route.xp); } @@ -543,7 +562,7 @@ section('THE FLYING — accept → legs → mark → kill → claim (the real sc let found = null; for (let i = 0; i < 400 && !found; i++) { const cand = makeScene({ level: 3, seed: 4000 + i }); // the board AND the plan ride the scene's own seed - const hunts = S.sideQuestPostingsFor(4000 + i, REC, cand.galaxy, 3).filter((p) => p.category === 'bounty'); + const hunts = S.sideQuestPostingsFor(4000 + i, REC, 'p1', cand.galaxy, 3).filter((p) => p.category === 'bounty'); for (const h of hunts) { cand.acceptSideQuest(h.id); const r = cand.sideQuestState.routeFor(h.id); @@ -578,7 +597,7 @@ section('THE FLYING — accept → legs → mark → kill → claim (the real sc section('NO SPOILERS — nothing the player reads says where the mark stands'); { const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40 }; - const posts = S.sideQuestPostingsFor(777, REC, null, 3).filter((p) => p.category === 'bounty'); + const posts = S.sideQuestPostingsFor(777, REC, 'p1', null, 3).filter((p) => p.category === 'bounty'); const planOf = (p) => S.deriveBountyPlan(777, p.id, p.targetSystemId, 3, { craft: p.dossier?.craft }); const copyKeys = ['bountyLockBadge', 'bountyLockSub', 'bountyAcceptDenyToast', 'bountyLegToast', 'bountySkirmishToast', 'bountySightedToast', 'bountyBroughtToBookToast', 'bountyCompleteToast', 'bountyRouteProgress']; for (const k of copyKeys) { diff --git a/dev/sidequests.test.mjs b/dev/sidequests.test.mjs index 473a066..20dcfaa 100644 --- a/dev/sidequests.test.mjs +++ b/dev/sidequests.test.mjs @@ -5,14 +5,15 @@ // - the CATEGORY GATE — the four types (patrol / delivery / bounty / // 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), +// OFFLINE, and a category's jobs appear on the board ONLY when its +// `enabled` flag flips (flip the rest → the per-world count +// spreads; flip the feature's master switch → an empty board). +// - DETERMINISM — the same contract stands on the same WORLD across +// calls (seed + record + world → the same postings — visits, scene +// restarts, saves), a different seed draws its own board, and +// different worlds (another planet in the same system, another +// system) draw different boards (no global call-order dependence). +// - The per-world COUNT (1–3 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 @@ -74,6 +75,7 @@ const { enabledSideQuestCategories, sideQuestCountRange, sideQuestPostingsFor, + parseSideQuestId, resolveSideQuestText, rollSideQuestReward, allSideQuestJobIds, @@ -145,7 +147,7 @@ console.log('== the category gate — patrol + bounty hunt LIVE by default, the // The DEFAULT board stands live with the live categories’ postings (patrol + // bounty — delivery/escort stay OFFLINE). - const def = sideQuestPostingsFor(SEED, REC_NEUTRAL); + const def = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1'); const [lo, hi] = sideQuestCountRange(); assert.ok(def.length >= lo && def.length <= hi, `the default board: ${def.length} postings within [${lo},${hi}]`); assert.ok(def.every((p) => p.category === 'patrol' || p.category === 'bounty'), 'the default board posts only the live types'); @@ -157,16 +159,17 @@ console.log('== the gate flips — one live type → only it posts; all live → setEnabled({ patrol: true }); // ONE live type, so the shape asserts below are patrol alone { assert.deepEqual(enabledSideQuestCategories(), ['patrol'], 'one live category'); - const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL); + const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1'); const [lo, hi] = sideQuestCountRange(); - assert.ok(lo >= 1 && hi >= lo, 'the per-system count range stands (3–4 by design)'); + assert.ok(lo >= 1 && hi >= lo, 'the per-world count range stands (1–3 by design)'); assert.ok(posts.length >= lo && posts.length <= hi, `${posts.length} postings within [${lo},${hi}]`); assert.ok(posts.every((p) => p.category === 'patrol'), 'only the live type posts'); for (const p of posts) { assert.equal(p.systemId, 'sys-free', 'the posting’s system id'); + assert.equal(p.worldKey, 'p1', 'the posting’s world key (the board’s world)'); assert.equal(p.systemName, 'Free Reach', 'the posting’s system name'); assert.equal(p.faction, null, 'a neutral system’s postings carry no faction'); - assert.match(p.id, /^sq-sys-free-patrol-\d+$/, 'a stable, save-safe id (the ledger’s key)'); + assert.match(p.id, /^sq-sys-free-p1-patrol-\d+$/, 'a stable, save-safe id (the ledger’s key)'); assert.equal(p.label, 'PATROL', 'the row’s label (the data’s category label)'); assert.match(p.accent, /^#[0-9a-f]{6}$/i, 'the row’s accent'); assert.ok(p.title.length > 0, 'the title'); @@ -177,28 +180,42 @@ console.log('== the gate flips — one live type → only it posts; all live → 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)'); } + + // The id's split (parseSideQuestId — the scene’s posting lookups + + // the claim's reward read parse the ledger’s id back to its board + + // slot, then regenerate the board): planet AND station worlds stand, + // and the OLD per-system shape (no world key) stands no world — null. + assert.deepEqual(parseSideQuestId('sq-sys-free-p1-patrol-2'), { systemId: 'sys-free', worldKey: 'p1', category: 'patrol', slot: 2 }, 'a planet board’s id splits back apart'); + assert.deepEqual(parseSideQuestId('sq-sys-zenith-s3-bounty-0'), { systemId: 'sys-zenith', worldKey: 's3', category: 'bounty', slot: 0 }, 'a station board’s id splits back apart'); + assert.equal(parseSideQuestId('sq-sys-zenith-patrol-0'), null, 'an OLD per-system id (no world key) is not a live id — null'); + assert.equal(parseSideQuestId('sq-sys-zenith-p1-noclue-0'), null, 'a category the board never posts is not a live id — null'); + assert.equal(parseSideQuestId('nope'), null, 'a non-posting id → null'); + assert.equal(parseSideQuestId(42), null, 'a non-string → null'); } setEnabled({ patrol: true, delivery: true, bounty: true, escort: true }); try { const [lo, hi] = sideQuestCountRange(); for (const rec of RECORDS) { - const posts = sideQuestPostingsFor(SEED, rec); + const posts = sideQuestPostingsFor(SEED, rec, 'p1'); assert.ok(posts.length >= lo && posts.length <= hi, `${rec.id}: ${posts.length} postings within [${lo},${hi}]`); const ids = new Set(posts.map((p) => p.id)); assert.equal(ids.size, posts.length, `${rec.id}: the ids are unique (category + slot)`); assert.ok(new Set(posts.map((p) => p.category)).size >= Math.min(3, posts.length), `${rec.id}: the slots spread across the live types`); } - // DETERMINISM — same seed + record → the same board across calls - // (visits / scene restarts / saves); a different seed draws its own. - const a = sideQuestPostingsFor(SEED, REC_ZENITH); - const b = sideQuestPostingsFor(SEED, REC_ZENITH); + // DETERMINISM — same seed + record + world → the same board across + // calls (visits / scene restarts / saves); a different seed draws its + // own; a different WORLD (another planet in the same system) stands + // its own board too. + const a = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'); + const b = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'); 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 - // per-posting draws stand per system). - assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX), a, 'a different system draws its own board'); + assert.notDeepEqual(sideQuestPostingsFor(SEED + 7, REC_ZENITH, 'p1'), a, 'a different seed → a different board'); + // …and the worlds don't share one ad (the variant pools + the + // per-posting draws stand per world). + assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_ZENITH, 'p2'), a, 'a different WORLD in the same system draws its own board'); + assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX, 'p1'), a, 'a different system draws its own board'); // NEUTRAL voice — the pirate-safety pools (the data’s contract: the // majority of each neutral pool names the pirates/raiders). @@ -207,7 +224,7 @@ console.log('== the gate flips — one live type → only it posts; all live → const themed = pool.filter((v) => /pirate|raider/i.test(v.title + v.body)).length; assert.ok(themed >= 2, `neutral ${cat}: ${themed}/${pool.length} variants carry the pirate-safety voice`); } - const neutralPosts = sideQuestPostingsFor(SEED, REC_NEUTRAL); + const neutralPosts = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1'); for (const p of neutralPosts) { assert.equal(p.faction, null, 'the neutral postings carry no faction'); assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'the neutral copy is placeholder-free'); @@ -220,7 +237,7 @@ console.log('== the gate flips — one live type → only it posts; all live → const pool = DATA.sidequests.descriptions.faction[cat]; assert.ok(pool.every((v) => v.body.includes('{enemies}')), `faction ${cat}: every variant carries {enemies}`); } - const z = sideQuestPostingsFor(SEED, REC_ZENITH); + const z = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'); for (const p of z) { assert.equal(p.faction, 'zenith', 'the posting carries its system’s faction'); assert.match(p.issuer, /Zenith/, 'the faction’s ISSUER names the faction (the data’s factions table)'); @@ -244,7 +261,7 @@ console.log('== the gate flips — one live type → only it posts; all live → // of the category flags (the kill-switch contract). config.data.sidequests.enabled = false; try { - assert.deepEqual(sideQuestPostingsFor(SEED, REC_ZENITH), [], 'sidequests.enabled:false → no postings (the kill-switch)'); + assert.deepEqual(sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'), [], 'sidequests.enabled:false → no postings (the kill-switch)'); } finally { config.data.sidequests.enabled = true; } @@ -310,7 +327,7 @@ console.log('== the patrol’s TARGET rule (the posting’s system, or a NEIGHBO // 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'); + const posts = sideQuestPostingsFor(SEED, rec, 'p1', 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'); @@ -321,7 +338,7 @@ console.log('== the patrol’s TARGET rule (the posting’s system, or a NEIGHBO // The non-patrol categories (the stubs) carry no target fields. setEnabled({ patrol: true, delivery: true }); try { - const all = sideQuestPostingsFor(SEED, rec, galaxy); + const all = sideQuestPostingsFor(SEED, rec, 'p1', 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)'); @@ -504,8 +521,8 @@ console.log('== the patrol’s ENCOUNTERS — derivePatrolEncounters (the contac console.log('== the XP REWARD — patrolEncounterXp (the claim’s second payout) =='); { const { patrolEncounterXp } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href); - // The payout's XP = the XP WORTH of the patrol's defeated contacts — - // the contacts × the wave's total × the roster's weighted-average xp. + // The payout's XP = the XP WORTH of the patrol’s defeated contacts — + // the contacts × the wave's total × the roster’s weighted-average xp. // The per-type XP is read from data/combat.json rather than pinned here // (the tuning breathes — a re-balance moves the numbers, not the rule). const { enemyTypeConfig } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href); @@ -624,7 +641,7 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl assert.deepEqual(backEnc.routeFor('sq-e-patrol-0').encounters, enc, 'the route’s encounters (at/cleared/roster/wave) round-trip the save'); assert.equal(backEnc.encounterCleared('sq-e-patrol-0', 2), false, '…and the cleared mark is still idempotent after the restore'); - // The route's XP PAYOUT (the claim's second reward — beside the + // The route's XP PAYOUT (the claim’s second reward — beside the // credits): an explicit one (the accept's fix) stands on the record; // a route without one (an old save) derives it from its own spec; // a route without encounters (the old shape) degrades to 0 (the @@ -686,27 +703,45 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'], claimed: ['sq-a-patrol-0'], activeId: 'sq-a-patrol-0' }).activeId, null, 'a CLAIMED saved-active drops out — nothing stands'); assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], activeId: 'sq-nowhere-0' }).activeId, 'sq-a-patrol-0', 'a foreign saved-active drops — the top accepted stands'); - // The job-space guard — ids the save’s galaxy never posted are dropped + // The job-space guard — ids the save's galaxy never posted are dropped // (a hand-edited or foreign ledger degrades instead of sticking), - // routes included. - const known = allSideQuestJobIds(RECORDS); + // routes included. The space is PER-WORLD now: each system's SURFACE + // WORLDS (the fake galaxy's planets + free-space stations) × the slot + // range × the four types. + const fakeGalaxy = { + records: RECORDS, + byId: new Map(RECORDS.map((r) => [r.id, r])), + ensureContent: (id) => { + const rec = RECORDS.find((r) => r.id === id) ?? null; + if (!rec) return null; + return { + planets: [1, 2].map((n) => ({ id: `${id}-p${n}`, name: `${rec.name ?? id} ${n}`, ordinal: n })), + settlements: [{ id: `${id}-s1`, name: `${rec.name ?? id} Outpost`, anchor: { type: 'space' } }], + jumps: [], + }; + }, + }; + const known = allSideQuestJobIds(fakeGalaxy); 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'); + const WORLDS_PER_SYSTEM = 3; // 2 planets + 1 free-space station (the fake galaxy's content) + assert.equal(known.size, RECORDS.length * WORLDS_PER_SYSTEM * hi2 * SIDE_QUEST_CATEGORIES.length, 'the space = worlds × the slot range × the four types'); + assert.ok(known.has('sq-sys-zenith-p1-patrol-0'), 'a real job id stands in the space'); + assert.ok(known.has('sq-sys-zenith-s1-patrol-0'), 'a free-space STATION’s job id stands in the space'); + assert.ok(!known.has('sq-sys-zenith-patrol-0'), 'an OLD per-system id (no world key) is outside the space (Path A — it drops)'); 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'], + accepted: ['sq-sys-zenith-p1-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: [] } }, + routes: { 'sq-sys-zenith-p1-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(guarded.toJSON().accepted, ['sq-sys-zenith-p1-patrol-0'], 'the foreign id drops; the real one stands'); 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.ok(guarded.routeFor('sq-sys-zenith-p1-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'); } @@ -750,8 +785,8 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc const call = (fn, scene, ...args) => fn.call(scene, ...args); // A REAL posting id on the neutral record (the board stands it — - // sq--patrol-), and its deterministic reward. - const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL); + // sq--p1-patrol-), and its deterministic reward. + const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1'); const job = posts.find((p) => p.category === 'patrol'); assert.ok(job, 'the neutral board stands a patrol (the live category)'); @@ -816,8 +851,16 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc 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); + // The uncomplete refusal — the console's voice, no payout. (job2 comes + // from a DIFFERENT world's board — the per-world count is 1–3, so this + // world's single patrol isn't guaranteed a sibling; another world's + // board always stands its own patrols.) + let job2 = null; + for (const wk of ['p2', 'p3', 'p4']) { + job2 = sideQuestPostingsFor(SEED, REC_NEUTRAL, wk).find((p) => p.category === 'patrol' && p.id !== job.id); + if (job2) break; + } + assert.ok(job2, 'a sibling contract stands on another world’s board'); 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; @@ -845,17 +888,28 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc assert.equal(sceneP.questState.priority, null, 'the claimed contract drops off the featured slot (the priority re-resolves)'); assert.equal(sceneP.questState.priorityCleared, false, '…with the auto-assignment RE-ARMED (the claim re-arms)'); - // --- SET PRIORITY: the popup/console’s door on a side contract -------- + // --- SET PRIORITY: the popup/console's door on a side contract -------- const sceneQ = makeScene(); - const a = posts.find((p) => p.category === 'patrol'); - const b = posts.find((p) => p.id !== a.id); + // The per-world count is 1–3, so one world may not stand three distinct + // contracts — pool the world's own boards in order (p1, p2, …). The + // ledger doesn't care which world posted a contract (the id is the key). + const pool = []; + for (const wk of ['p1', 'p2', 'p3', 'p4']) { + for (const p of sideQuestPostingsFor(SEED, REC_NEUTRAL, wk)) { + if (!pool.some((q) => q.id === p.id)) pool.push(p); + } + if (pool.length >= 3) break; + } + const a = pool.find((p) => p.category === 'patrol'); + const b = pool.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); + const other = pool.find((p) => p.id !== a.id && p.id !== b.id); + assert.ok(other, 'a third distinct contract stands (the world boards pool)'); 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)'); @@ -883,7 +937,7 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc sceneC.ship = { x: 0, y: 0, addCredits: (n) => { sceneC.state.credits += n; } }; sceneC.systemRecord = REC_NEUTRAL; sceneC.combat = combat; - const xpGrants = []; // the run's XP bank's witness (the claim's XP payout) + const xpGrants = []; // the run's XP bank’s witness (the claim’s XP payout) combat.xp = { grant(n, src = 'kill') { xpGrants.push({ n: Math.max(0, Math.floor(Number(n) || 0)), src }); diff --git a/js/quests/SideQuests.js b/js/quests/SideQuests.js index 3ee5a49..274760e 100644 --- a/js/quests/SideQuests.js +++ b/js/quests/SideQuests.js @@ -1,13 +1,17 @@ /** * SideQuests — the job board's model: the contracts posted on every - * system EXCEPT the player's home (safe space — the Explore drawer's - * own gate, data/actionbar.json → surface.explore.items), and the run's - * ledger of the contracts it has taken. + * SURFACE WORLD (each planet and free-space station) EXCEPT in the + * player's home system (safe space — the Explore drawer's own gate, + * data/actionbar.json → surface.explore.items), and the run's ledger + * of the contracts it has taken. A world's board stands in that world + * (the board is a VIEW over the postings — two planets in one system + * post two different boards); the CONTRACTS themselves still fly in + * the system's space (a patrol's route is the system's, as always). * * LAYERING — the board is a VIEW over this (js/ui/JobBoardWindow.js): - * - 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 WINDOW renders sideQuestPostingsFor(seed, record, worldKey, + * galaxy) (the current WORLD'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 @@ -72,10 +76,11 @@ * once on claim (the credits + the XP, the claim's two payouts). * * DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") — - * postings derive from the galaxy seed + the system record through - * LABELLED forks: Rng.derive(seed, 'sidequests', , …). The same - * contract stands on the same system for the whole run — across visits, - * scene restarts, and saves — and a different system draws its own (the + * postings derive from the galaxy seed + the system record + the WORLD + * through LABELLED forks: Rng.derive(seed, 'sidequests', , + * , …). The same contract stands on the same WORLD for the whole + * run — across visits, scene restarts, and saves — and a different world + * (another planet in the same system, another system) draws its own (the * variant pool keeps the galaxy's boards from reading as one repeated * ad). Nothing here depends on call order. * @@ -108,16 +113,16 @@ export function sideQuestsEnabled() { } /** - * The postings-per-system roll range (data/sidequests.json → - * board.perSystem). A malformed range falls back to the design's 3–4. + * The postings-per-world roll range (data/sidequests.json → + * board.perWorld). A malformed range falls back to the design's 1–3. * @returns {[number, number]} [min, max] (integers, min ≤ max) */ export function sideQuestCountRange() { - const raw = config.get('sidequests.board.perSystem', null); - if (!Array.isArray(raw) || raw.length < 2) return [3, 4]; + const raw = config.get('sidequests.board.perWorld', null); + if (!Array.isArray(raw) || raw.length < 2) return [1, 3]; let lo = Math.floor(Number(raw[0])); let hi = Math.floor(Number(raw[1])); - if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 4]; + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [1, 3]; lo = Math.max(1, lo); hi = Math.max(lo, hi); return [lo, hi]; @@ -1306,50 +1311,66 @@ function normalizeBountyPlan(raw, wpCount) { } /** - * The system's LIVE postings (the board's list) — 3–4 by design + * The WORLD's LIVE postings (the board's list) — 1–3 by design * (sideQuestCountRange), drawn from the LIVE categories only * (enabledSideQuestCategories): distinct categories while they last, * then repeats into the variant pools (the same category can hold more - * than one posting once the count outruns the live set). + * than one posting once the count outruns the live set). The board is + * PER SURFACE WORLD (a planet's `p` / a station's `s` key — + * the record id minus the system prefix): two worlds in one system + * stand two DIFFERENT boards, while the flavor's {system} voice stays + * the system's (the contract is about keeping the SYSTEM safe — the + * board just sits on a world), and the contract still flies in the + * system's space (the patrol's route is the system's, as always). * - * Deterministic in (seed, record[, galaxy]) — the labelled forks under - * Rng.derive(seed, seedLabel, ) make the roll, the category - * 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. + * Deterministic in (seed, record, world[, galaxy]) — the labelled forks + * under Rng.derive(seed, seedLabel, , ) make the roll, the + * category 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. A PATROL - * posting additionally carries targetSystemId / targetSystemName (the - * route's system — patrolTargetSystem; default the posting's own - * system). + * { id, systemId, systemName, worldKey, 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. A PATROL posting additionally carries targetSystemId / + * targetSystemName (the route's system — patrolTargetSystem; default + * the posting's own system). The id's shape is + * 'sq----' (the save-safe ledger key — + * parseSideQuestId below splits it back apart). * * @param {string|number} seed the galaxy's seed * @param {object} rec a system record ({ id, name, faction, d, … }) + * @param {string} worldKey the board's world (a planet's `p` / a station's `s`) * @param {object|null} [galaxy] the galaxy (the patrol's neighbor rule's jumpGatesFor) — null in tests + * @param {number|null} [level] the player's combat level (the BOUNTY HUNT's gate stamp) * @returns {object[]} [] when the feature is off or no category is live */ -export function sideQuestPostingsFor(seed, rec, galaxy = null, level = null) { +export function sideQuestPostingsFor(seed, rec, worldKey, galaxy = null, level = null) { if (!sideQuestsEnabled()) return []; if (!rec || typeof rec.id !== 'string' || rec.id === '') return []; + // A world the board can't name (a caller without world data — a test, + // a hand-built record) borrows the FIRST world's board — the board + // never blanks for a missing key, and the key is part of the seed, so + // the fallback is still deterministic. + const wk = String(worldKey ?? '').trim(); + const world = /^[a-z][a-z0-9]{0,3}$/i.test(wk) ? wk.toLowerCase() : 'p1'; const ctx = postingContext(rec); const cats = enabledSideQuestCategories(ctx.context); // a faction board never draws a neutral-only type if (cats.length === 0) return []; const [lo, hi] = sideQuestCountRange(); - const base = Rng.derive(seed, seedLabel(), rec.id); + const base = Rng.derive(seed, seedLabel(), rec.id, world); const count = base.int(lo, hi); - const order = base.shuffle(cats); // the system's slot order (seeded, stable) + const order = base.shuffle(cats); // the world's slot order (seeded, stable) const out = []; const usedDossier = new Set(); // one board never posts the SAME wanted leader twice 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, galaxy, usedDossier); + const r = Rng.derive(seed, seedLabel(), rec.id, world, cat, slot); // per-posting stream + const posting = buildPosting(seed, rec, ctx, cat, slot, r, galaxy, usedDossier, world); if (!posting) continue; if (cat === 'bounty' && posting.dossier?.name) usedDossier.add(posting.dossier.name); // The level gate is PRESENTATION + ACCEPT, never generation: the @@ -1363,8 +1384,31 @@ export function sideQuestPostingsFor(seed, rec, galaxy = null, level = null) { return out; } +/** + * A POSTING id back apart (the scene's _sideQuestPosting / the claim's + * reward lookup parse the ledger's id to its board + slot, then + * regenerate the board from the seed and find the posting). The id's + * shape is 'sq-\u003csystemId\u003e-\u003cworld\u003e-\u003ccategory\u003e-\u003cslot\u003e' (a planet's world key + * `p\u003cordinal\u003e`, a station's `s\u003cn\u003e' — both a letter + digits, so the + * greedy split is the true one: the category is letters, the slot + * digits, at the id's end; the system id is whatever stands before the + * world key). An id the board never posted (an OLD per-system save id — + * 'sq-\u003csys\u003e-\u003ccat\u003e-\u003cslot\u003e' — the world key's digits never stand — or a + * hand-edited string) yields null rather than a guess. + * @param {string} id the ledger's posting id + * @returns {?{systemId: string, worldKey: string, category: string, slot: number}} + */ +export function parseSideQuestId(id) { + if (typeof id !== 'string') return null; + const m = /^sq-(.+)-([a-z]\d{1,3})-([a-z]+)-(\d+)$/i.exec(id); + if (!m) return null; + const cat = m[3].toLowerCase(); + if (!SIDE_QUEST_CATEGORIES.includes(cat)) return null; // a world key is a letter + digits only + return { systemId: m[1], worldKey: m[2].toLowerCase(), category: cat, slot: Math.floor(Number(m[4])) }; +} + /** One posting from its pool variant + reward roll (null on a bad pool entry). */ -function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier = null) { +function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier = null, world = 'p1') { const meta = sideQuestCategoryMeta(cat); if (!meta) return null; @@ -1398,9 +1442,10 @@ function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier = const description = resolveSideQuestText(v?.body ?? '', ctx); return { - id: `sq-${rec.id}-${cat}-${slot}`, // stable for the run (save-safe ledger key) + id: `sq-${rec.id}-${world}-${cat}-${slot}`, // stable for the run (save-safe ledger key) systemId: rec.id, systemName: String(rec.name ?? rec.id), + worldKey: world, // the board's world (the id's world part — a planet's p / a station's s) faction: ctx.context === 'faction' ? rec.faction : null, category: cat, label: meta.label, @@ -1439,23 +1484,59 @@ function patrolTargetFields(rec, r, galaxy) { /** * 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 - * enabled set can post) × all four categories. Used by SideQuestState. - * fromJSON to drop ledger ids the save's galaxy never posted (a - * hand-edited or foreign ledger degrades instead of sticking). + * system × its SURFACE WORLDS (every planet — `p` — and every + * free-space station — the `s` settlement) × the full slot range + * (the range's max — a superset of what any enabled set can post) × all + * four categories. The worlds come from the system's CONTENT (the + * galaxy's ensureContent — lazy === eager, the same worlds the player + * lands on; a hand-built caller without one degrades to an empty + * space, which drops every ledger id — a foreign save rather than a + * broken one). Used by SideQuestState.fromJSON to drop ledger ids the + * save's galaxy never posted (a hand-edited or foreign ledger degrades + * instead of sticking) — and to shed the OLD per-system ids (the board + * went per-world; a contract a world never posted can't survive a load). * - * @param {object[]} records the galaxy's system records (Galaxy.records) + * @param {{records: object[], ensureContent: (id: string) => object}} galaxy the restored galaxy (Galaxy — records + ensureContent) * @returns {Set} */ -export function allSideQuestJobIds(records) { +export function allSideQuestJobIds(galaxy) { const set = new Set(); - if (!Array.isArray(records)) return set; + const records = galaxy && Array.isArray(galaxy.records) ? galaxy.records : null; + if (!records || typeof galaxy.ensureContent !== 'function') return set; const [, hi] = sideQuestCountRange(); for (const rec of records) { if (!rec || typeof rec.id !== 'string' || rec.id === '') continue; - for (let slot = 0; slot < hi; slot++) { - for (const cat of SIDE_QUEST_CATEGORIES) { - set.add(`sq-${rec.id}-${cat}-${slot}`); + let content = null; + try { + content = galaxy.ensureContent(rec.id) ?? null; + } catch { + content = null; // a degenerate system (a hand-built test) — no worlds stand + } + // The SURFACE WORLDS that can carry a board: every planet (the + // world's own surface) + every free-space station (its own surface). + // Planet-bound settlements sit ON their planet (the planet is the + // board's world — its `s` key is never posted). The key is the + // record id minus the system prefix (p / s — the + // reputation/trading key, stable per seed). + const worlds = []; + const keyOf = (w) => { + const id = w && typeof w.id === 'string' ? w.id : ''; + return id.startsWith(rec.id + '-') ? id.slice(rec.id.length + 1) : null; + }; + for (const p of (content && Array.isArray(content.planets) ? content.planets : [])) { + const k = keyOf(p); + if (k) worlds.push(k); + } + for (const s of (content && Array.isArray(content.settlements) ? content.settlements : [])) { + if (s?.anchor?.type !== 'space') continue; // a planet-bound settlement is its planet's board + const k = keyOf(s); + if (k) worlds.push(k); + } + for (const wk of worlds) { + for (let slot = 0; slot < hi; slot++) { + for (const cat of SIDE_QUEST_CATEGORIES) { + set.add(`sq-${rec.id}-${wk}-${cat}-${slot}`); + } } } } @@ -1468,7 +1549,8 @@ export function allSideQuestJobIds(records) { * 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---) — the postings themselves + * postings' ids (sq---- — the world's key: a + * planet's p / a station's s) — the postings themselves * regenerate from the seed, so the save carries only the marks (a save = * seed + player state). * diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 2ffb92c..bc94b6d 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -417,13 +417,18 @@ export function prepareLoad(registry, record) { // The run's SIDE QUEST ledger (the job board's accepted/completed // contract ids — a save predating the feature has no field → null; // the fresh run's GameScene starts an empty ledger; old saves load). - // Validated against the save's OWN job space (allSideQuestJobIds of the - // rebuilt galaxy) so a foreign or hand-edited id degrades instead of - // sticking; the completed ⊆ accepted invariant is re-established. + // Validated against the save's OWN job space (allSideQuestJobIds of + // the rebuilt galaxy — each system's SURFACE WORLDS × the slot range × + // the four types) so a foreign or hand-edited id degrades instead of + // sticking; the board is PER-WORLD now, so OLD per-system ids + // (sq-\u003csys\u003e-\u003ccat\u003e-\u003cslot\u003e — the pre-per-world shape) no longer stand in + // the space and drop (Path A's documented trade: an in-flight contract + // in an old save is lost, the rest of the run survives). The + // completed ⊆ accepted invariant is re-established. registry.set( 'sidequests', record.sidequests - ? SideQuestState.fromJSON(record.sidequests, allSideQuestJobIds(galaxy.records)) + ? SideQuestState.fromJSON(record.sidequests, allSideQuestJobIds(galaxy)) : null, ); // Lifetime asteroid mining (a save predating it has no field → 0). diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 35894a3..acf028a 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -67,6 +67,7 @@ import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from import { SideQuestState, sideQuestPostingsFor, + parseSideQuestId, buildPatrolRoute, derivePatrolEncounters, patrolEncounterScript, @@ -1539,13 +1540,15 @@ export class GameScene extends Phaser.Scene { } // ---- SIDE QUESTS (the JOB BOARD's ledger — data/sidequests.json) ---- - // The contracts the run has taken on the job board (every system + // The contracts the run has taken on the job board (every WORLD // except home — the Explore drawer's own gate): accepted + completed // ids (the postings themselves are seed derivations — a save = seed + - // player state, js/quests/SideQuests.js). The board's window renders - // sideQuestPostings() over the SLEEPING scene (the QuestWindow - // precedent — state + effects here, the view on the surface), and the - // ACCEPT lands here (acceptSideQuest below). + // player state, js/quests/SideQuests.js). The board is PER SURFACE + // WORLD (the postings stand on the world the surface stands on — + // sideQuestPostings(worldName, isStation) below), and the window + // renders them over the SLEEPING scene (the QuestWindow precedent — + // state + effects here, the view on the surface); the ACCEPT lands + // here (acceptSideQuest below). this.sideQuestState = this.registry.get('sidequests') ?? null; if (!this.sideQuestState) { this.sideQuestState = new SideQuestState(); @@ -4671,11 +4674,11 @@ export class GameScene extends Phaser.Scene { */ _sideQuestPosting(id) { if (typeof id !== 'string' || !id.startsWith('sq-') || !this.galaxy) return null; - const m = /^sq-(.+)-([a-z]+)-(\d+)$/i.exec(id); + const m = parseSideQuestId(id); if (!m) return null; - const rec = this.galaxy.byId.get(m[1]) ?? null; + const rec = this.galaxy.byId.get(m.systemId) ?? 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; + return sideQuestPostingsFor(this.galaxy.seed, rec, m.worldKey, this.galaxy).find((p) => p.id === id) ?? null; } /** @@ -5079,21 +5082,54 @@ export class GameScene extends Phaser.Scene { // ------------------------------------------------- SIDE QUESTS (board) /** - * The current system's LIVE postings (the job board's list — the - * surface's window renders this over the sleeping scene, the - * QuestWindow precedent). Deterministic in (galaxy seed, system - * record) — the same contracts stand here across visits, scene - * restarts, and saves (js/quests/SideQuests.js). [] when the feature - * is off or no category is live (the board's standby state). + * The WORLD's LIVE postings (the job board's list — the surface's window + * renders this over the sleeping scene, the QuestWindow precedent). + * The board is PER SURFACE WORLD: the `worldName` + `isStation` the + * caller stands on (the surface's own world — a planet's name, or a + * free-space station's name) resolve to that world's key (its record id + * minus the system prefix — p / s) and seed the board, so two + * worlds in one system stand two different boards. Deterministic in + * (galaxy seed, system record, world) — the same contracts stand here + * across visits, scene restarts, and saves (js/quests/SideQuests.js). + * A world that can't be resolved (a data gap) borrows the first world's + * board (the model's fallback) — the board never blanks. [] when the + * feature is off or no category is live (the board's standby state). + * @param {string} [worldName] the surface world's name (a planet / station) + * @param {boolean} [isStation] the surface is a free-space station * @returns {object[]} */ - sideQuestPostings() { + sideQuestPostings(worldName = null, isStation = false) { const rec = this.systemRecord ?? null; if (!this.galaxy || !rec) return []; + const worldKey = this.surfaceWorldKey(worldName, isStation) ?? 'p1'; // The player's combat level rides the read (the BOUNTY HUNT's gate): below // categories.bounty.minLevel the row still stands in its seeded slot, LOCKED // — the gate is presentation + accept, never generation. - return sideQuestPostingsFor(this.galaxy.seed, rec, this.galaxy, this.combatLevel()); + return sideQuestPostingsFor(this.galaxy.seed, rec, worldKey, this.galaxy, this.combatLevel()); + } + + /** + * The SURFACE WORLD's board key — the record id minus the system prefix + * (a planet's `p` / a free-space station's `s`), resolved off + * the system's content by the world's NAME. null when the name stands + * no record of its kind (the caller then borrows p1 — the model's + * fallback) — a board never blanks for a missing key. + * @param {string} [name] the surface world's name + * @param {boolean} [isStation] a free-space station (not a planet) + * @returns {string|null} + */ + surfaceWorldKey(name, isStation = false) { + const rec = this.systemRecord ?? null; + if (!rec || typeof rec.id !== 'string' || rec.id === '') return null; + if (typeof name !== 'string' || name === '') return null; + const content = this.systemContent ?? null; + if (!content) return null; + const recs = isStation + ? ((content.settlements ?? []).filter((s) => s?.anchor?.type === 'space')) + : (content.planets ?? []); + const hit = (Array.isArray(recs) ? recs : []).find((w) => w?.name === name); + if (!hit || typeof hit.id !== 'string' || !hit.id.startsWith(rec.id + '-')) return null; + return hit.id.slice(rec.id.length + 1); // 'p2' / 's4' — the record id's world part } /** The run's combat level (js/combat/XpManager.js); null when no XP bank stands. */ @@ -5108,14 +5144,19 @@ export class GameScene extends Phaser.Scene { * ledger + sfx are the scene's, the toast is the surface's). Takes the * contract ONTO the run's SideQuestState ledger exactly once (accept() * is idempotent — a double-press or a re-open can't double-take it) and - * returns the posting so the caller can note + repaint. A posting the - * board doesn't list (a foreign id, or a category since gone) is a - * refusal, not a record. + * returns the posting so the caller can note + repaint. The caller + * stands on a WORLD (its surface), so the accept validates the id + * against THAT world's board (sideQuestPostings(worldName, isStation)) + * — a posting another world posts (or a foreign id, or a category + * since gone) is a refusal, not a record. + * @param {string} jobId the contract's id + * @param {string} [worldName] the surface world's name (a planet / station) + * @param {boolean} [isStation] the surface is a free-space station * @returns {{ok:boolean, posting?:object}} */ - acceptSideQuest(jobId) { + acceptSideQuest(jobId, worldName = null, isStation = false) { if (!this.sideQuestState) return { ok: false }; - const posting = this.sideQuestPostings().find((p) => p.id === jobId) ?? null; + const posting = this.sideQuestPostings(worldName, isStation).find((p) => p.id === jobId) ?? null; if (!posting) return { ok: false }; // The LEVEL GATE (data/sidequests.json → categories.bounty.minLevel — the // hunt's 2): a locked contract is a REFUSAL, never a record. The board @@ -5357,12 +5398,12 @@ export class GameScene extends Phaser.Scene { } if (!credits) { // A category without a route: the posting's reward (the seed's - // contract stands — sq---). - const m = /^sq-(.+)-([a-z]+)-(\d+)$/i.exec(String(jobId)); + // contract stands — sq----). + const m = parseSideQuestId(String(jobId)); if (m) { - const rec = this.galaxy?.byId?.get(m[1]); + const rec = this.galaxy?.byId?.get(m.systemId); if (rec && this.galaxy) { - credits = sideQuestPostingsFor(this.galaxy.seed, rec, this.galaxy).find((p) => p.id === jobId)?.reward?.credits ?? 0; + credits = sideQuestPostingsFor(this.galaxy.seed, rec, m.worldKey, this.galaxy).find((p) => p.id === jobId)?.reward?.credits ?? 0; } } } diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 3c1ac69..577e6ad 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -1289,7 +1289,7 @@ export class SurfaceScene extends Phaser.Scene { this.jobBoardWindow = new JobBoardWindow(this, { planetName: this.planetName, onClose: () => this.leaveJobBoard(), - postings: () => this.gameScene?.sideQuestPostings?.() ?? [], + postings: () => this.gameScene?.sideQuestPostings?.(this.planetName, this.isStation) ?? [], isAccepted: (id) => this.gameScene?.sideQuestState?.isAccepted?.(id) ?? false, isCompleted: (id) => this.gameScene?.sideQuestState?.isCompleted?.(id) ?? false, isActive: (id) => this.gameScene?.sideQuestState?.isActive?.(id) ?? false, @@ -1601,7 +1601,7 @@ export class SurfaceScene extends Phaser.Scene { acceptSurfaceJob(jobId) { const g = this.gameScene; if (!g?.acceptSideQuest) return; - const res = g.acceptSideQuest(jobId); + const res = g.acceptSideQuest(jobId, this.planetName, this.isStation); const t = config.get('sidequests.ui.acceptToast', {}); if (res?.ok) { this.consoleNote( @@ -1644,9 +1644,10 @@ export class SurfaceScene extends Phaser.Scene { 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; + // The note names the contract (the posting's title — the id's own + // board + slot regenerated off the seed, world included — the + // acceptToast precedent). + const posting = g._sideQuestPosting?.(jobId) ?? null; const text = String(t.text ?? 'PATROL ACTIVE').replace('{title}', String(posting?.title ?? '').toUpperCase()); this.consoleNote(text, { glyph: t.glyph ?? '▸', diff --git a/js/ui/JobBoardWindow.js b/js/ui/JobBoardWindow.js index 53e1937..37032e4 100644 --- a/js/ui/JobBoardWindow.js +++ b/js/ui/JobBoardWindow.js @@ -28,7 +28,7 @@ * LOOPS MUTED while the console is up (the build feed's twin; * data/actionbar.json → surface.explore.video). Right: the AVAILABLE * JOBS section (the board's LIVE postings — the scene's seam: the - * current system's contracts) over the JOB DETAILS section (the + * current WORLD's contracts) over the JOB DETAILS section (the * selected job's contract + the ACCEPT door). The window is a pure * VIEW over js/quests/SideQuests.js: refresh() repaints both off the * seams (postings / isAccepted / isCompleted / onAccept); the ledger @@ -173,7 +173,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container { this.onClose = typeof o.onClose === 'function' ? o.onClose : null; // The board's data + state seams (the scene's — the QuestWindow // precedent: state + effects there, the view here): the current - // system's live postings (GameScene.sideQuestPostings), the ledger's + // WORLD's live postings (GameScene.sideQuestPostings), the ledger's // accepted/completed marks (GameScene.sideQuestState), and the ACCEPT // door (GameScene.acceptSideQuest via the surface's note). Missing // seams stand the board on its STANDBY plate (a test scene, or a @@ -407,7 +407,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container { // ── right: the two live sections ───────────────────────────────── /** - * AVAILABLE JOBS (the board's postings — the current system's live + * AVAILABLE JOBS (the board's postings — the current WORLD's live * contracts) over JOB DETAILS (the selected job's readout + the * ACCEPT door). The frames + headers are STATIC geometry; the content * inside each is DYNAMIC — refresh() repaints both off the seams