Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig 41f6dbbfd9 Make side quest job boards per-surface-world instead of per-system
- 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-<sys>-<world>-<cat>-<slot>`); 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
2026-09-22 19:14:33 -06:00
Brian Fertig 7aba525aec Gate the EXPLORE slot behind the Special Delivery quest
- Deck-locks the explore action until the 'Special Delivery' story quest is granted, reading the same shared QuestState ledger as the trade unlock so loaded saves see consistent state.
- Adds `_exploreUnlocked()` and `_syncExploreLock()` helpers; re-syncs on quest claim so granting the quest lifts the gate live.
- Refuses direct presses with a console note when the story gate is still closed.
2026-09-22 17:59:55 -06:00
Brian Fertig fededdf590 Fix combat end seam so break-off cull fires onActiveChange
- The last escort leaving the screen (encounter over without a kill) now calls `_setActive()`, which flips `active` to false and emits `onActiveChange`.
- Previously only kills triggered the flip, so GameScene's deck/soundtrack swap (`_setCombatBar`) never ran on a break-off ending.
- Add a test asserting the `true→false` active flip when the last escort is culled off-screen.
2026-09-22 17:52:06 -06:00
11 changed files with 412 additions and 159 deletions

View File

@ -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<ordinal> / a station's s<n> — 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-<sys>-<cat>-<slot>) 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, <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
"_comment": "perWorld = the postings-per-surface-world roll [min,max] (seeded per world — Rng.derive(seed, seedLabel, <sysId>, <world>); 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": [
{

View File

@ -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,

View File

@ -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-<sysId>-<cat>-<slot>) — the save's job space
const JOB = 'sq-sys-free-p1-bounty-0'; // a real posting id (sq-<sysId>-<world>-<cat>-<slot>) — 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) {

View File

@ -185,15 +185,19 @@ function harness() {
scene.physics.add.existing(ship);
ship.radius = 12;
ship.stop = () => {};
const combat = new CombatSystem(scene, { ship });
return { scene, ship, combat };
const activeFlips = [];
const combat = new CombatSystem(scene, {
ship,
onActiveChange: (a) => activeFlips.push(a),
});
return { scene, ship, combat, activeFlips };
}
const ids = neutralTypeIds();
// ---------------------------------------------------------------- roster + escort
{
const { ship, combat } = harness();
const { ship, combat, activeFlips } = harness();
const base = config.section('combat.neutral', {});
check('config: the escort window is data (neutral.escortMin/escortMax = 10–15 s)',
base.escortMin === 10 && base.escortMax === 15);
@ -240,6 +244,11 @@ const ids = neutralTypeIds();
check('despawn: broken escorts off-screen are collected (encounter ends)',
combat.allies.length === 0);
check('active: over when the last escort is off-screen', combat.active === false);
// The seam the scene hangs the deck/soundtrack swap on (GameScene
// _setCombatBar — combat bar out, normal deck + deep-space music back)
// must fire on the break-off cull, not only on a kill.
check('seam: onActiveChange flips true→false when the last escort leaves the screen',
JSON.stringify(activeFlips) === '[true,false]');
}
// ---------------------------------------------------------------- engage

View File

@ -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-<systemId>-patrol-<slot>), and its deterministic reward.
const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL);
// sq-<systemId>-p1-patrol-<slot>), 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 });

View File

@ -650,6 +650,11 @@ export class CombatSystem {
}
}
this.allies = this.allies.filter((a) => !a.gone && !a._removed);
// The last escort off-screen (with the pack spent) is the encounter
// OVER — the flip back to the normal deck/soundtrack rides the same
// seam as a kill (killEnemy/killAlly already fire it; the break-off
// cull is the ending that was missing it).
this._setActive();
}
// 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire).

View File

@ -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', <sysId>, …). 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', <sysId>,
* <world>, …). 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<ordinal>` / a station's `s<n>` 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, <sysId>) 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, <sysId>, <world>) 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-<systemId>-<world>-<category>-<slot>' (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<ordinal>` / a station's `s<n>`)
* @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<ordinal> / a station's s<n>)
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<ordinal>` — and every
* free-space station — the `s<n>` 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<string>}
*/
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<n>` key is never posted). The key is the
// record id minus the system prefix (p<ordinal> / s<n> — 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-<sysId>-<cat>-<slot>) — the postings themselves
* postings' ids (sq-<sysId>-<world>-<cat>-<slot> — the world's key: a
* planet's p<ordinal> / a station's s<n>) — the postings themselves
* regenerate from the seed, so the save carries only the marks (a save =
* seed + player state).
*

View File

@ -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).

View File

@ -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<ordinal> / s<n>) 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<ordinal>` / a free-space station's `s<n>`), 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-<sysId>-<cat>-<slot>).
const m = /^sq-(.+)-([a-z]+)-(\d+)$/i.exec(String(jobId));
// contract stands — sq-<sysId>-<world>-<cat>-<slot>).
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;
}
}
}

View File

@ -1185,6 +1185,12 @@ export class SurfaceScene extends Phaser.Scene {
// ledger's capitalLanded, 'Special Delivery' step 1) sits on the
// shop's Buy/Sell tab (ShopView reads it from the GameScene's ledger,
// not this scene, so a save/load can't bypass it).
// The EXPLORE slot stays in deck-locked standby until the 'Special
// Delivery' story quest has begun (granted — the bar's scene is the
// quest's step 1, so the drawer that reaches it waits for the quest;
// the same shared-ledger seam as isTradeUnlocked, so a loaded save
// reads the same truth).
this._syncExploreLock();
// The MENU button keeps the GameScene's save system. The run's state
// is in the paused GameScene beneath us — SavePanel captures from it
@ -1283,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,
@ -1362,6 +1368,15 @@ export class SurfaceScene extends Phaser.Scene {
return;
}
if (id === 'explore') {
// The story gate (data/quests.json → special_delivery): the slot is
// deck-locked until the quest is granted, and this check is the
// second door — a press that sneaks past the standby paint still
// lands here and is refused in the console's voice.
if (!this._exploreUnlocked()) {
this.consoleNote('EXPLORATION OFFLINE — THE STORY COMES FIRST');
this.playSfx('ui_close');
return;
}
this.toggleExplore();
return;
}
@ -1393,6 +1408,27 @@ export class SurfaceScene extends Phaser.Scene {
this.menuSubBar.open();
}
/**
* Is the EXPLORE slot's story gate open? The 'Special Delivery' quest
* (data/quests.json → special_delivery) granted — QuestState.isGranted
* on the shared registry ledger (the isTradeUnlocked seam), so a
* loaded save reads the same truth as a live run.
*/
_exploreUnlocked() {
return this.gameScene?.questState?.isGranted?.('special_delivery') === true;
}
/**
* Apply the EXPLORE slot's story gate to the deck — standby until the
* 'Special Delivery' quest has begun, live from its grant. Idempotent
* (setSlotEnabled's contract), safe to re-run on a surface claim (the
* grant chain lands on claimQuest, and the surface's QUESTS slot can
* be the one that pays out 'Leaving the Nest').
*/
_syncExploreLock() {
this.actionBar?.setSlotEnabled?.('explore', this._exploreUnlocked());
}
/**
* The EXPLORE button: fold the full-width service drawer up / fold it
* back down (the shop tab bar's span, the same MenuSubBar drawer in
@ -1518,6 +1554,7 @@ export class SurfaceScene extends Phaser.Scene {
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)
this._syncExploreLock(); // the claim chain can GRANT 'Special Delivery' — lift the EXPLORE gate
const now = claimedOf(g);
if (!before && now) {
const t = isSide ? config.get('sidequests.ui.claimToast', {}) : config.get('quests.claimToast', {});
@ -1564,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(
@ -1607,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 ?? '▸',

View File

@ -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