Compare commits
3 Commits
2f80ddfdb1
...
41f6dbbfd9
| Author | SHA1 | Date |
|---|---|---|
|
|
41f6dbbfd9 | |
|
|
7aba525aec | |
|
|
fededdf590 |
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? '▸',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue