752 lines
33 KiB
JavaScript
752 lines
33 KiB
JavaScript
/**
|
||
* 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.
|
||
*
|
||
* 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 SCENE (GameScene.acceptSideQuest / setActiveSideQuest /
|
||
* claimSideQuest — the claimSurfaceQuest precedent: state + effects
|
||
* there, the note on the surface) lands them on the SideQuestState
|
||
* ledger (registry 'sidequests' — rides the saves like the quest
|
||
* ledger, SaveData capture/prepareLoad);
|
||
* - this module owns the CONTRACTS: which ones stand, what they say,
|
||
* what they pay, the PATROL's route (buildPatrolRoute +
|
||
* patrolTargetSystem), and the accepted/active/completed/claimed
|
||
* record — pure, seeded, Node-testable (dev/sidequests.test.mjs).
|
||
*
|
||
* CATEGORIES — the four job types (data/sidequests.json → categories):
|
||
* patrol / delivery / bounty / escort. Every category is ACCOUNTED FOR —
|
||
* its slot machinery here, its description pool + reward band in the
|
||
* data, the window's row + contract readout, the ledger, the accept
|
||
* seam — behind its OWN `enabled` gate, and the gate is the contract:
|
||
* a category's jobs appear on the board ONLY when its rules land and
|
||
* its flag flips. PATROL is the first live type (enabled:true) — a
|
||
* patrol's objective is its ROUTE (below); the other three stay
|
||
* OFFLINE until their rule sets land.
|
||
*
|
||
* PATROL — the route the player flies (data/sidequests.json → patrol):
|
||
* - TARGET — patrolTargetSystem: the route's system. Default the
|
||
* posting's own system; in a FACTION system at or beyond the
|
||
* diagonal mark (neighborMinD — the '40% from home' rule), a seeded
|
||
* draw (neighborChance) may stand a NEIGHBOR — a system the
|
||
* posting's jump gates reach (galaxy.jumpGatesFor) controlled by
|
||
* ANOTHER faction (the push-the-factions-out voice made a route).
|
||
* - ROUTE — buildPatrolRoute: N isolated waypoints ([min,max] in the
|
||
* data) as plain world coordinates, inside the caller's REGION (the
|
||
* player's unlocked tether regions when the target is the system
|
||
* they stand in; one tether region around each anchor when it is a
|
||
* system not yet unlocked — the scene's job to assemble), keeping
|
||
* anchorClearance from every anchor (planet / station / gate)
|
||
* and minSeparation apart (relaxed, never the region — a patrol
|
||
* must always be completable). Seeded on (seed, 'sidequests',
|
||
* <targetSystemId>, 'patrol', <jobId>, 'route') — same contract +
|
||
* region ⇒ same route, across re-accepts, scene restarts, saves.
|
||
* - LEDGER — SideQuestState carries the accepted order, the ONE
|
||
* active contract (reconcileActive: the top accepted contract
|
||
* stands active until it is done and claimed), the route record
|
||
* (waypoints + visited), and the claimed marks. The scene's
|
||
* updatePatrol checks the ship in (visitRadius, any order),
|
||
* completes the contract, and pays the reward exactly once on
|
||
* claim.
|
||
*
|
||
* DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") —
|
||
* postings derive from the galaxy seed + the system record through
|
||
* 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
|
||
* variant pool keeps the galaxy's boards from reading as one repeated
|
||
* ad). Nothing here depends on call order.
|
||
*
|
||
* FLAVOR — the variant pools split by SYSTEM CONTEXT
|
||
* (data/sidequests.json → 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 faction pools carry
|
||
* placeholders, resolved at generation (never stored): {system} = the
|
||
* system's name, {faction} = the system's faction label
|
||
* (data/factions.json), {issuer} = that faction's board name
|
||
* (data/sidequests.json → factions), {enemies} = the other two
|
||
* factions' labels ("MindWeave and Zenith").
|
||
*/
|
||
import { config } from '../config/Config.js';
|
||
import { Rng } from '../utils/Rng.js';
|
||
import { Tether } from '../tether/Tether.js';
|
||
|
||
/** The four job types, canonical slot order (the data's category keys). */
|
||
export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort'];
|
||
|
||
/** The three player factions (data/factions.json minus 'neutral'). */
|
||
const FACTIONS = ['apex', 'mindweave', 'zenith'];
|
||
|
||
const clamp01 = (v) => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
|
||
|
||
/** The feature's master switch (data/sidequests.json → enabled). */
|
||
export function sideQuestsEnabled() {
|
||
return config.get('sidequests.enabled', true) !== false;
|
||
}
|
||
|
||
/**
|
||
* The postings-per-system roll range (data/sidequests.json →
|
||
* board.perSystem). A malformed range falls back to the design's 3–4.
|
||
* @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];
|
||
let lo = Math.floor(Number(raw[0]));
|
||
let hi = Math.floor(Number(raw[1]));
|
||
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 4];
|
||
lo = Math.max(1, lo);
|
||
hi = Math.max(lo, hi);
|
||
return [lo, hi];
|
||
}
|
||
|
||
/** The Rng fork label under the galaxy seed (data/sidequests.json → board.seedLabel). */
|
||
function seedLabel() {
|
||
const s = String(config.get('sidequests.board.seedLabel', 'sidequests') || 'sidequests').trim();
|
||
return s || 'sidequests';
|
||
}
|
||
|
||
/**
|
||
* A category's meta (data/sidequests.json → categories.<id>).
|
||
* @returns {{id:string, enabled:boolean, label:string, accent:string}|null} null when unknown
|
||
*/
|
||
export function sideQuestCategoryMeta(id) {
|
||
if (!SIDE_QUEST_CATEGORIES.includes(id)) return null;
|
||
const c = config.get(`sidequests.categories.${id}`, null);
|
||
if (!c || typeof c !== 'object') return null;
|
||
return {
|
||
id,
|
||
enabled: c.enabled === true, // strict — a missing flag is OFFLINE (never fabricate jobs)
|
||
label: String(c.label ?? id.toUpperCase()),
|
||
accent: String(c.accent ?? '#00e5ff'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The LIVE categories (enabled:true, canonical order). The board's gate:
|
||
* only these ever generate a posting — a category is accounted for but
|
||
* unavailable until its rules are built and its flag flipped.
|
||
* @returns {string[]}
|
||
*/
|
||
export function enabledSideQuestCategories() {
|
||
return SIDE_QUEST_CATEGORIES.filter((id) => sideQuestCategoryMeta(id)?.enabled === true);
|
||
}
|
||
|
||
/**
|
||
* Faction id → label (data/factions.json → factions), the three player
|
||
* factions only. A missing entry falls back to the id's own casing so a
|
||
* hand-edited table can't blank the board's copy.
|
||
* @returns {Record<string,string>}
|
||
*/
|
||
export function sideQuestFactionLabels() {
|
||
const table = config.get('factions.factions', null);
|
||
const out = {};
|
||
for (const id of FACTIONS) {
|
||
const entry = table && typeof table === 'object' ? table[id] : null;
|
||
const label = entry && typeof entry.label === 'string' && entry.label ? entry.label : id;
|
||
out[id] = label.charAt(0).toUpperCase() + label.slice(1);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Resolve the pool's placeholders in a line of copy (resolveSideQuestText's
|
||
* input context). {system} / {faction} / {issuer} / {enemies} — the
|
||
* neutral context leaves the faction-side keys empty (its pools don't
|
||
* carry them).
|
||
* @param {object} rec a system record ({ id, name, faction, … })
|
||
*/
|
||
function postingContext(rec) {
|
||
const system = String(rec?.name ?? rec?.id ?? 'this system');
|
||
const faction = typeof rec?.faction === 'string' && rec.faction !== '' ? rec.faction : null;
|
||
if (!faction) return { context: 'neutral', system, faction: '', issuer: '', enemies: '' };
|
||
const labels = sideQuestFactionLabels();
|
||
const label = labels[faction] ?? faction;
|
||
const issuer =
|
||
String(config.get(`sidequests.factions.${faction}`, '') || '').trim() || `${label} Authority`;
|
||
const others = FACTIONS.filter((f) => f !== faction).map((f) => labels[f] ?? f);
|
||
const enemies =
|
||
others.length === 2 ? `${others[0]} and ${others[1]}` : others.join(', ');
|
||
return { context: 'faction', system, faction: label, issuer, enemies };
|
||
}
|
||
|
||
/**
|
||
* The pool's placeholder resolution (the data's contract): {system},
|
||
* {faction}, {issuer}, {enemies} → the context's values. split/join (no
|
||
* $-quirks). A placeholder with no value in the context (a {faction} in
|
||
* a neutral pool — a data bug) resolves to '' rather than leaking its
|
||
* braces.
|
||
* @param {string} text the pool line
|
||
* @param {object} ctx the postingContext() shape
|
||
* @returns {string}
|
||
*/
|
||
export function resolveSideQuestText(text, ctx = null) {
|
||
if (typeof text !== 'string' || text === '') return '';
|
||
ctx = ctx && typeof ctx === 'object' ? ctx : {};
|
||
const map = {
|
||
system: ctx.system ?? '',
|
||
faction: ctx.faction ?? '',
|
||
issuer: ctx.issuer ?? '',
|
||
enemies: ctx.enemies ?? '',
|
||
};
|
||
let out = text;
|
||
for (const [key, value] of Object.entries(map)) {
|
||
if (!value) continue;
|
||
out = out.split(`{${key}}`).join(String(value));
|
||
}
|
||
// Any placeholder that still stands (no value for this context) is
|
||
// blanked — the board never ships a brace pair to the player.
|
||
out = out.replace(/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/g, '');
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The posting's CREDITS payout (data/sidequests.json → rewards): the
|
||
* category's band [min,max] rolled seeded per posting, scaled OUT with
|
||
* the system's home→far diagonal d as (1 + d * distanceScale) — the
|
||
* far systems' boards pay more for the same risk (the economy's 'a hop
|
||
* is a hop' rule). A missing band falls back to [40,80]; the result is
|
||
* rounded to 5 CR, floored at 5 CR.
|
||
* @param {string} cat the category id
|
||
* @param {number} d the system's home→far diagonal (0..1)
|
||
* @param {Rng} r the posting's seeded stream
|
||
* @returns {{credits:number}}
|
||
*/
|
||
export function rollSideQuestReward(cat, d, r) {
|
||
let band = config.get(`sidequests.rewards.${cat}`, null);
|
||
if (!Array.isArray(band) || band.length < 2 || !Number.isFinite(Number(band[0])) || !Number.isFinite(Number(band[1]))) {
|
||
band = [40, 80];
|
||
}
|
||
const scale = 1 + clamp01(d) * (Number(config.get('sidequests.rewards.distanceScale', 0.75)) || 0);
|
||
const lo = Math.min(Number(band[0]), Number(band[1]));
|
||
const hi = Math.max(Number(band[0]), Number(band[1]));
|
||
const credits = Math.max(5, Math.round((r.range(lo, hi) * scale) / 5) * 5);
|
||
return { credits };
|
||
}
|
||
|
||
/**
|
||
* The patrol's TARGET system — the system the route will be built in.
|
||
* Default (returns null): the posting's own system. The NEIGHBOR rule
|
||
* (data/sidequests.json → patrol): in a FACTION system at or beyond the
|
||
* diagonal mark (neighborMinD — the design's '40% from home'), a seeded
|
||
* draw (neighborChance) may stand a system the posting's jump gates
|
||
* reach (galaxy.jumpGatesFor) controlled by ANOTHER faction — the
|
||
* push-them-out voice made a route. The draw rides the posting's own
|
||
* seeded stream, so the same posting stands with the same target for
|
||
* the whole run (visits, scene restarts, saves); no galaxy, or no
|
||
* other-faction link, falls back to the posting system — never a null
|
||
* target.
|
||
*
|
||
* @param {object} rec the posting's system record ({ id, faction, d, … })
|
||
* @param {Rng} r the posting's seeded stream
|
||
* @param {object|null} [galaxy] the galaxy (jumpGatesFor + byId) — null in tests
|
||
* @returns {{id:string, name:string}|null} null = the posting system stands
|
||
*/
|
||
export function patrolTargetSystem(rec, r, galaxy = null) {
|
||
const faction = typeof rec?.faction === 'string' ? rec.faction : '';
|
||
if (faction === '' || faction === 'neutral') return null; // neutral space patrols its own system
|
||
const minD = Number(config.get('sidequests.patrol.neighborMinD', 0.4));
|
||
if (!Number.isFinite(minD) || clamp01(Number(rec?.d)) < minD) return null; // the '40% from home' mark
|
||
const chance = Number(config.get('sidequests.patrol.neighborChance', 0.5));
|
||
if (!r.chance(clamp01(Number.isFinite(chance) ? chance : 0.5))) return null; // the seeded draw (the posting system stands)
|
||
if (!galaxy || typeof galaxy.jumpGatesFor !== 'function') return null; // no galaxy (a test) — the posting system stands
|
||
const cands = [];
|
||
let links = null;
|
||
try {
|
||
links = galaxy.jumpGatesFor(rec.id);
|
||
} catch {
|
||
links = null; // an unknown id (a hand-built test record) — the posting system stands
|
||
}
|
||
for (const nb of links ?? []) {
|
||
if (!nb || typeof nb.id !== 'string' || nb.id === rec.id) continue;
|
||
const nf = typeof nb.faction === 'string' ? nb.faction : '';
|
||
if (nf !== '' && nf !== 'neutral' && nf !== faction) cands.push(nb); // a NEIGHBOR controlled by ANOTHER faction
|
||
}
|
||
if (cands.length === 0) return null; // no other-faction link — the posting system stands
|
||
const nb = r.pick(cands);
|
||
return nb ? { id: nb.id, name: String(nb.name ?? nb.id) } : null;
|
||
}
|
||
|
||
/**
|
||
* The patrol's ROUTE — N isolated waypoints (data/sidequests.json →
|
||
* patrol.waypoints [min,max]) as plain world coordinates inside the
|
||
* target system. The REGION argument is the contract's flight envelope
|
||
* (the scene assembles it: the player's UNLOCKED tether regions when
|
||
* the target is the system they stand in; one tether region — a level-1
|
||
* radius — around each of the target's planets / stations / gates when
|
||
* it is a system not yet unlocked). Waypoints stay inside the region
|
||
* (a rim inset off the tether line), anchorClearance px clear of every
|
||
* ANCHOR (a planet / station / gate center — the scene's list) and
|
||
* minSeparation px apart; a tight field relaxes the clearance and
|
||
* separation (never the region) until it gives, and a field that gives
|
||
* nowhere stands the point at its region's heart — a patrol must always
|
||
* be completable.
|
||
*
|
||
* Seeded on its own labelled fork (seed, 'sidequests',
|
||
* <targetSystemId>, 'patrol', <jobId>, 'route') — the worldgen
|
||
* determinism contract: same seed + contract + region ⇒ same route,
|
||
* across re-accepts, scene restarts, and saves; nothing here depends on
|
||
* call order.
|
||
*
|
||
* @param {string|number} seed the galaxy's seed
|
||
* @param {string} jobId the accepted contract's id (the fork's key)
|
||
* @param {string} targetSystemId the route's system (the posting's target)
|
||
* @param {object[]} region [{ x, y, radius }] the flight envelope
|
||
* @param {object[]} [anchors] [{ x, y }] the keep-out centers (planets / stations / gates)
|
||
* @returns {{targetSystemId:string, waypoints:number[][], visited:number[]}}
|
||
*/
|
||
export function buildPatrolRoute(seed, jobId, targetSystemId, region, anchors = []) {
|
||
const rawRange = config.get('sidequests.patrol.waypoints', [3, 5]);
|
||
let lo = Math.floor(Number(Array.isArray(rawRange) ? rawRange[0] : 3));
|
||
let hi = Math.floor(Number(Array.isArray(rawRange) ? rawRange[1] : 5));
|
||
if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
|
||
lo = 3;
|
||
hi = 5;
|
||
}
|
||
lo = Math.max(1, lo);
|
||
hi = Math.max(lo, hi);
|
||
const anchorClearance = Math.max(0, Number(config.get('sidequests.patrol.anchorClearance', 1100)) || 0);
|
||
const minSeparation = Math.max(0, Number(config.get('sidequests.patrol.minSeparation', 1500)) || 0);
|
||
const inset = Math.max(0, Number(config.get('sidequests.patrol.regionInset', 96)) || 0);
|
||
|
||
const regions = (Array.isArray(region) ? region : [])
|
||
.map((t) => ({ x: Number(t?.x), y: Number(t?.y), radius: Number(t?.radius) }))
|
||
.filter((t) => Number.isFinite(t.x) && Number.isFinite(t.y) && Number.isFinite(t.radius) && t.radius > 0);
|
||
if (regions.length === 0) {
|
||
// No usable region (a caller without tether data — a test, or a
|
||
// degenerate system): the system's heart at one tether's width, so
|
||
// the route still stands (a patrol must always be completable).
|
||
regions.push({ x: 0, y: 0, radius: Tether.radiusForLevel(1) });
|
||
}
|
||
const anchorPts = (Array.isArray(anchors) ? anchors : [])
|
||
.map((a) => ({ x: Number(a?.x), y: Number(a?.y) }))
|
||
.filter((a) => Number.isFinite(a.x) && Number.isFinite(a.y));
|
||
|
||
// The effective envelope: each region inset by the rim margin (a
|
||
// waypoint never sits on a tether's line). A region smaller than its
|
||
// inset gives up the inset rather than the region (kept at a 4 px
|
||
// floor so the sampler never weights a zero-disc).
|
||
const eff = regions.map((t) => ({ x: t.x, y: t.y, r: Math.max(4, t.radius - inset) }));
|
||
const areas = eff.map((t) => t.r * t.r); // the sampler's weights (disc area)
|
||
const inside = (x, y) => eff.some((t) => (x - t.x) ** 2 + (y - t.y) ** 2 <= t.r * t.r);
|
||
|
||
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', jobId, 'route');
|
||
const n = r.int(lo, hi);
|
||
const pickRegion = () => {
|
||
const total = areas.reduce((a, b) => a + b, 0);
|
||
let u = r.next() * total;
|
||
for (let i = 0; i < eff.length; i++) {
|
||
u -= areas[i];
|
||
if (u <= 0) return eff[i];
|
||
}
|
||
return eff[eff.length - 1];
|
||
};
|
||
|
||
const placed = [];
|
||
for (let i = 0; i < n; i++) {
|
||
let clear = anchorClearance;
|
||
let sep = minSeparation;
|
||
let pt = null;
|
||
for (let attempt = 0; attempt < 900 && !pt; attempt++) {
|
||
const t = pickRegion();
|
||
const rad = Math.sqrt(r.next()) * t.r; // uniform in the disc (the sqrt)
|
||
const ang = r.next() * Math.PI * 2;
|
||
const x = t.x + Math.cos(ang) * rad;
|
||
const y = t.y + Math.sin(ang) * rad;
|
||
if (!inside(x, y)) continue;
|
||
const okA = clear <= 0 || anchorPts.every((a) => Math.hypot(x - a.x, y - a.y) >= clear);
|
||
const okS = sep <= 0 || placed.every((p) => Math.hypot(x - p.x, y - p.y) >= sep);
|
||
if (okA && okS) pt = { x, y };
|
||
else if (attempt === 299 || attempt === 699) {
|
||
clear *= 0.5; // the field's giving — ease the clearance…
|
||
sep *= 0.5; // …and the separation (never the region)
|
||
}
|
||
}
|
||
if (!pt) {
|
||
// The field gave nowhere (a system of one crowded anchor): stand
|
||
// the point at the region's heart, half an inset off center on a
|
||
// seeded bearing — inside the envelope, best-effort isolated.
|
||
const t = pickRegion();
|
||
const ang = r.next() * Math.PI * 2;
|
||
const rad = Math.min(t.r * 0.5, Math.max(4, anchorClearance * 0.5));
|
||
pt = { x: t.x + Math.cos(ang) * rad, y: t.y + Math.sin(ang) * rad };
|
||
}
|
||
placed.push(pt);
|
||
}
|
||
|
||
return {
|
||
targetSystemId: String(targetSystemId),
|
||
waypoints: placed.map((p) => [Math.round(p.x), Math.round(p.y)]),
|
||
visited: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The system's LIVE postings (the board's list) — 3–4 by design
|
||
* (sideQuestCountRange), drawn from the LIVE categories only
|
||
* (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).
|
||
*
|
||
* 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.
|
||
*
|
||
* 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).
|
||
*
|
||
* @param {string|number} seed the galaxy's seed
|
||
* @param {object} rec a system record ({ id, name, faction, d, … })
|
||
* @param {object|null} [galaxy] the galaxy (the patrol's neighbor rule's jumpGatesFor) — null in tests
|
||
* @returns {object[]} [] when the feature is off or no category is live
|
||
*/
|
||
export function sideQuestPostingsFor(seed, rec, galaxy = null) {
|
||
if (!sideQuestsEnabled()) return [];
|
||
if (!rec || typeof rec.id !== 'string' || rec.id === '') return [];
|
||
const cats = enabledSideQuestCategories();
|
||
if (cats.length === 0) return [];
|
||
|
||
const [lo, hi] = sideQuestCountRange();
|
||
const base = Rng.derive(seed, seedLabel(), rec.id);
|
||
const count = base.int(lo, hi);
|
||
const order = base.shuffle(cats); // the system's slot order (seeded, stable)
|
||
const ctx = postingContext(rec);
|
||
|
||
const out = [];
|
||
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);
|
||
if (posting) out.push(posting);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** One posting from its pool variant + reward roll (null on a bad pool entry). */
|
||
function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null) {
|
||
const meta = sideQuestCategoryMeta(cat);
|
||
if (!meta) return null;
|
||
|
||
const poolPath = `sidequests.descriptions.${ctx.context}.${cat}`;
|
||
let pool = config.get(poolPath, null);
|
||
if (!Array.isArray(pool)) pool = config.get(`sidequests.descriptions.neutral.${cat}`, null); // the neutral voice as fallback
|
||
pool = Array.isArray(pool) ? pool : [];
|
||
const v = pool.length > 0 ? pool[r.int(0, pool.length - 1)] : null;
|
||
|
||
const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, ctx);
|
||
// The row's issuer line: the pool's own name (the neutral pools' —
|
||
// “The Harbormaster”), or the faction's board name (the faction pools'
|
||
// {issuer} placeholder — the data's sidequests.factions table), so a
|
||
// faction's postings read “ISSUER — ZENITH CONCORD”, not “The Board”.
|
||
const issuerRaw = v?.issuer ?? (ctx.issuer || 'The Board');
|
||
const issuer = resolveSideQuestText(issuerRaw, ctx) || 'The Board';
|
||
const description = resolveSideQuestText(v?.body ?? '', ctx);
|
||
|
||
return {
|
||
id: `sq-${rec.id}-${cat}-${slot}`, // stable for the run (save-safe ledger key)
|
||
systemId: rec.id,
|
||
systemName: String(rec.name ?? rec.id),
|
||
faction: ctx.context === 'faction' ? rec.faction : null,
|
||
category: cat,
|
||
label: meta.label,
|
||
accent: meta.accent,
|
||
title,
|
||
issuer,
|
||
description,
|
||
reward: rollSideQuestReward(cat, rec.d, r),
|
||
// The completion socket: the type's objective spec (its steps, its
|
||
// witness, its payout rule) is built WITH the type and rides here —
|
||
// the UI already renders objective.text when one stands.
|
||
objective: { type: cat },
|
||
// The PATROL's target system — the route's system (patrolTargetSystem:
|
||
// the posting system, or a NEIGHBOR the jump gates reach when the
|
||
// system's faction, diagonal, and seeded draw agree). Patrol-only —
|
||
// the other categories' routes (when they land) carry their own
|
||
// shape.
|
||
...(cat === 'patrol' ? patrolTargetFields(rec, r, galaxy) : {}),
|
||
};
|
||
}
|
||
|
||
/** The patrol posting's target fields (its own system, or the neighbor's). */
|
||
function patrolTargetFields(rec, r, galaxy) {
|
||
const t = patrolTargetSystem(rec, r, galaxy);
|
||
return t
|
||
? { targetSystemId: t.id, targetSystemName: t.name }
|
||
: { targetSystemId: rec.id, targetSystemName: String(rec.name ?? rec.id) };
|
||
}
|
||
|
||
/**
|
||
* The seed's JOB SPACE — every id the ledger could lawfully hold: each
|
||
* system × the full slot range (the range's max — a superset of what any
|
||
* 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).
|
||
*
|
||
* @param {object[]} records the galaxy's system records (Galaxy.records)
|
||
* @returns {Set<string>}
|
||
*/
|
||
export function allSideQuestJobIds(records) {
|
||
const set = new Set();
|
||
if (!Array.isArray(records)) 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}`);
|
||
}
|
||
}
|
||
}
|
||
return set;
|
||
}
|
||
|
||
/**
|
||
* The run's SIDE QUEST ledger (pure; save-ready): which posted contracts
|
||
* the player has ACCEPTED (in the order taken), which is the ONE ACTIVE
|
||
* contract being flown, which of the taken contracts have COMPLETED,
|
||
* which of those have been CLAIMED, and the PATROL route record (its
|
||
* target system, its waypoints, its visited marks). The ids are
|
||
* postings' ids (sq-<sysId>-<cat>-<slot>) — the postings themselves
|
||
* regenerate from the seed, so the save carries only the marks (a save =
|
||
* seed + player state).
|
||
*
|
||
* Invariants (the scene's flows lean on them):
|
||
* - completed ⊆ accepted, claimed ⊆ completed;
|
||
* - accept / complete / claim are idempotent (a double-press or a
|
||
* re-open can't double-take, double-pay, or re-open a contract);
|
||
* - ONE active contract at a time (activeId) — reconcileActive keeps
|
||
* a standing active valid, and otherwise stands the TOP accepted
|
||
* contract (acceptedOrder's head) that is not done + claimed; null
|
||
* when no contract is out;
|
||
* - a save predating the feature (no field) loads an empty ledger
|
||
* (old saves load); ids the save's galaxy never posted are dropped,
|
||
* not propagated; a route record only for a contract this ledger
|
||
* holds (a route for a foreign id degrades away).
|
||
*/
|
||
export class SideQuestState {
|
||
constructor() {
|
||
this.accepted = new Set();
|
||
this.acceptedOrder = []; // the order taken (the active slot's "top")
|
||
this.completed = new Set(); // completed ⊆ accepted
|
||
this.claimed = new Set(); // claimed ⊆ completed
|
||
this.activeId = null; // the ONE contract being flown (taken, not done, not claimed)
|
||
this.routes = new Map(); // id → { targetSystemId, credits, waypoints, visited }
|
||
}
|
||
|
||
/**
|
||
* Take the contract (idempotent — re-accepting is a no-op).
|
||
* @returns {boolean} true when NEWLY accepted
|
||
*/
|
||
accept(id) {
|
||
if (typeof id !== 'string' || id === '') return false;
|
||
if (this.accepted.has(id)) return false;
|
||
this.accepted.add(id);
|
||
this.acceptedOrder.push(id);
|
||
return true;
|
||
}
|
||
|
||
isAccepted(id) {
|
||
return this.accepted.has(id);
|
||
}
|
||
|
||
/** The taken order (the ledger's "top" is index 0). */
|
||
acceptedList() {
|
||
return [...this.acceptedOrder];
|
||
}
|
||
|
||
/**
|
||
* Mark a taken contract COMPLETE (the type's objective stood — the
|
||
* scene's call when a category lands; only ACCEPTED contracts can
|
||
* complete — completed ⊆ accepted). A done contract drops out of the
|
||
* ACTIVE slot (reconcileActive — the next top one stands).
|
||
* @returns {boolean} true when NEWLY completed
|
||
*/
|
||
complete(id) {
|
||
if (typeof id !== 'string' || id === '') return false;
|
||
if (!this.accepted.has(id)) return false;
|
||
if (this.completed.has(id)) return false;
|
||
this.completed.add(id);
|
||
this.reconcileActive();
|
||
return true;
|
||
}
|
||
|
||
isCompleted(id) {
|
||
return this.completed.has(id);
|
||
}
|
||
|
||
/**
|
||
* Mark a completed contract CLAIMED (the reward is paid exactly once —
|
||
* idempotent; claimed ⊆ completed, so an uncompleted contract can't
|
||
* be claimed). A claimed contract drops out of the ACTIVE slot.
|
||
* @returns {boolean} true when NEWLY claimed
|
||
*/
|
||
claim(id) {
|
||
if (typeof id !== 'string' || id === '') return false;
|
||
if (!this.completed.has(id)) return false;
|
||
if (this.claimed.has(id)) return false;
|
||
this.claimed.add(id);
|
||
this.reconcileActive();
|
||
return true;
|
||
}
|
||
|
||
isClaimed(id) {
|
||
return this.claimed.has(id);
|
||
}
|
||
|
||
isActive(id) {
|
||
return this.activeId === id;
|
||
}
|
||
|
||
/**
|
||
* Stand the ACTIVE contract — the ONE being flown. It must be taken,
|
||
* not done, not claimed (the board's door only offers it in that state
|
||
* anyway — this is the belt-and-braces pass).
|
||
* @returns {boolean} true when it stands
|
||
*/
|
||
setActive(id) {
|
||
if (typeof id !== 'string' || id === '') return false;
|
||
if (!this.accepted.has(id) || this.completed.has(id) || this.claimed.has(id)) return false;
|
||
this.activeId = id;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* The ACTIVE slot's truth: a standing active (taken, not done, not
|
||
* claimed) is kept; otherwise the TOP accepted contract (acceptedOrder's
|
||
* head — "if no quest is active and the player has accepted quests, the
|
||
* top accepted stands") that is not done + claimed takes the slot; null
|
||
* when no contract is out.
|
||
* @returns {string|null}
|
||
*/
|
||
reconcileActive() {
|
||
if (
|
||
this.activeId &&
|
||
this.accepted.has(this.activeId) &&
|
||
!this.completed.has(this.activeId) &&
|
||
!this.claimed.has(this.activeId)
|
||
) {
|
||
return this.activeId;
|
||
}
|
||
this.activeId =
|
||
this.acceptedOrder.find((id) => !this.completed.has(id) && !this.claimed.has(id)) ?? null;
|
||
return this.activeId;
|
||
}
|
||
|
||
// ------------------------------------------------- the patrol's ROUTE
|
||
|
||
/**
|
||
* Stand a contract's ROUTE record (the patrol's waypoints — the scene's
|
||
* buildPatrolRoute result, at the accept). Only a TAKEN contract can
|
||
* carry a route (a route for a foreign id degrades away); the record
|
||
* is validated + normalized (finite waypoint pairs, visited indices
|
||
* in range, no duplicates).
|
||
* @returns {boolean} true when it stands
|
||
*/
|
||
setRoute(id, route) {
|
||
if (typeof id !== 'string' || id === '') return false;
|
||
if (!this.accepted.has(id)) return false;
|
||
if (!route || typeof route !== 'object') return false;
|
||
const waypoints = (Array.isArray(route.waypoints) ? route.waypoints : [])
|
||
.filter((w) => Array.isArray(w) && w.length >= 2 && Number.isFinite(w[0]) && Number.isFinite(w[1]))
|
||
.map((w) => [Math.round(w[0]), Math.round(w[1])]);
|
||
const visited = [...new Set(
|
||
(Array.isArray(route.visited) ? route.visited : []).filter(
|
||
(i) => Number.isInteger(i) && i >= 0 && i < waypoints.length,
|
||
),
|
||
)];
|
||
this.routes.set(id, {
|
||
targetSystemId: String(route.targetSystemId ?? ''),
|
||
credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's payout — fixed at the accept
|
||
waypoints,
|
||
visited,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* A contract's route record (the scene's check-ins + the board's route
|
||
* line read it).
|
||
* @returns {{targetSystemId:string, credits:number, waypoints:number[][], visited:number[]}|null}
|
||
*/
|
||
routeFor(id) {
|
||
return this.routes.get(id) ?? null;
|
||
}
|
||
|
||
/**
|
||
* Check off a waypoint (idempotent — the scene's per-frame pass may
|
||
* see the ship inside the radius on several frames; only the FIRST
|
||
* logs). @returns {boolean} true when NEWLY logged
|
||
*/
|
||
visitWaypoint(id, idx) {
|
||
const route = this.routes.get(id);
|
||
if (!route) return false;
|
||
if (!Number.isInteger(idx) || idx < 0 || idx >= route.waypoints.length) return false;
|
||
if (route.visited.includes(idx)) return false;
|
||
route.visited.push(idx);
|
||
return true;
|
||
}
|
||
|
||
toJSON() {
|
||
return {
|
||
accepted: [...this.acceptedOrder],
|
||
completed: [...this.completed],
|
||
claimed: [...this.claimed],
|
||
activeId: this.activeId,
|
||
routes: Object.fromEntries(this.routes),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Restore a ledger from a save record. A malformed record yields an
|
||
* empty ledger rather than a throw (contracts are a progression
|
||
* nicety, not a crash vector — the QuestState precedent). With a
|
||
* `known` job space (allSideQuestJobIds of the restored galaxy) only
|
||
* ids inside it survive — a foreign or hand-edited id degrades. The
|
||
* active slot is restored through reconcileActive: a standing saved
|
||
* active is kept, else the top accepted stands (the save's order).
|
||
* @param {object} data the record.sidequests shape
|
||
* @param {Set<string>|null} [known] the save's galaxy job space
|
||
*/
|
||
static fromJSON(data, known = null) {
|
||
const st = new SideQuestState();
|
||
if (!data || typeof data !== 'object') return st;
|
||
const list = (arr) => (Array.isArray(arr) ? arr.filter((x) => typeof x === 'string' && x !== '') : []);
|
||
const ok = (id) => (known ? known.has(id) : true);
|
||
for (const id of list(data.accepted)) {
|
||
if (!ok(id) || st.accepted.has(id)) continue;
|
||
st.accepted.add(id);
|
||
st.acceptedOrder.push(id);
|
||
}
|
||
for (const id of list(data.completed)) {
|
||
if (!ok(id) || !st.accepted.has(id) || st.completed.has(id)) continue; // completed ⊆ accepted
|
||
st.completed.add(id);
|
||
}
|
||
for (const id of list(data.claimed)) {
|
||
if (!ok(id) || !st.completed.has(id) || st.claimed.has(id)) continue; // claimed ⊆ completed
|
||
st.claimed.add(id);
|
||
}
|
||
const routes = data.routes && typeof data.routes === 'object' && !Array.isArray(data.routes) ? data.routes : null;
|
||
if (routes) {
|
||
for (const [id, raw] of Object.entries(routes)) {
|
||
if (!ok(id) || !st.accepted.has(id)) continue; // a route only for a contract this ledger holds
|
||
st.setRoute(id, raw);
|
||
}
|
||
}
|
||
if (typeof data.activeId === 'string' && data.activeId !== '') st.activeId = data.activeId;
|
||
st.reconcileActive(); // a standing saved active is kept; else the top accepted stands; else null
|
||
return st;
|
||
}
|
||
}
|