orbit/js/quests/SideQuests.js

1172 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.
* - ENCOUNTERS — the patrol's hostile CONTACTS (data →
* patrol.encounters): how many stand (counts — the odds: 1 on 20%
* of patrols, 2 on 20%, 3 on 60%), WHERE they stand (patrolEncounterPositions — the max-spread subset of the route's waypoints — the
* contacts spread across the route, never clustered), WHAT they fly
* (the band's roster — the player's LEVEL × the target's HOPS from
* home; today the low band, levels 1–5 at 1–2 hops, the established
* type only), and the fight's shape (the wave — one rolling wave of
* six, three at a time). derivePatrolEncounters rolls it all seeded
* at the accept (the labelled fork (seed, 'sidequests',
* <targetSystemId>, 'patrol', <jobId>, 'encounters')) and fixes it
* on the route record — a signed contract is a signed contract;
* patrolEncounterScript shapes the contact's wave script for
* CombatSystem.startWaves; the scene's updatePatrol fires the wave
* at the contact's waypoint and marks it cleared on the run's
* completion (the witness is the completion, not the attempt — the
* combatDone precedent), and patrolRouteComplete stands the
* contract: every waypoint logged AND every contact cleared.
* - 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 + the contract's payout — credits AND the
* XP reward, patrolEncounterXp), 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 (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
* 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';
import { enemyTypeConfig, enemyTypeIds } from '../combat/EnemyTypes.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 patrol's ENCOUNTERS
// The contract's hostile CONTACTS (data/sidequests.json → patrol.encounters):
// how many stand (counts), where they stand (positions — the max-spread
// subset of the route's waypoints), what they fly (the band's roster —
// the player's level × the target's hops from home), and the fight's shape
// (the wave — one rolling wave of six, three at a time). All pure and
// seeded — the scene's _establishPatrol rolls the spec at the accept and
// fixes it on the route record; the scene's updatePatrol fires it at the
// contact's waypoint and marks the clearance on the wave run's completion.
/** The established type (the base craft's named id) — the low band's roster and the bandless fallback alike. */
const ESTABLISHED_ENEMY_TYPES = ['raider'];
/** A finite number, else the fallback. */
function numOr(v, fb) {
const n = Number(v);
return Number.isFinite(n) ? n : fb;
}
/**
* The contact COUNT's odds (data/sidequests.json → patrol.encounters.counts
* — [count, odds] pairs; the design: 1 contact on 20% of patrols, 2 on
* 20%, 3 on 60%). A malformed table falls back to the design's own.
* @returns {Array<[number, number]>} sorted by count, positive weights
*/
export function patrolEncounterCounts() {
const raw = config.get('sidequests.patrol.encounters.counts', null);
const pairs = [];
if (Array.isArray(raw)) {
for (const [c, w] of raw) {
const count = Math.floor(numOr(c, NaN));
const weight = numOr(w, NaN);
if (Number.isFinite(count) && count >= 1 && Number.isFinite(weight) && weight > 0) pairs.push([count, weight]);
}
}
if (pairs.length === 0) return [[1, 0.2], [2, 0.2], [3, 0.6]];
return pairs.sort((a, b) => a[0] - b[0]);
}
/**
* A contact's WAVE shape (data/sidequests.json → patrol.encounters.wave —
* the design: one ROLLING wave of six, three at a time, each kill
* replaced — the combat.waves rolling mechanic, js/combat/Waves.js).
* @returns {{kind:string, total:number, maxConcurrent:number, replacePerKill:number}}
*/
export function patrolEncounterWave() {
const raw = config.get('sidequests.patrol.encounters.wave', null);
const w = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
return {
kind,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
replacePerKill: kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, 1))) : 0,
};
}
/**
* The standing band's ROSTER — the enemy types a patrol's contacts fly
* (data/sidequests.json → patrol.encounters.bands): the FIRST band in
* list order whose `levels` AND `hops` windows both contain the
* player's state — the difficulty's first axis (the player's COMBAT
* level × the target system's HOPS from home — the jump network's tree
* depth, the 'systems 1–2 hops out' rule). Today the low band — levels
* 1–5 at 1–2 hops from home — flies the ESTABLISHED type only (raider);
* the farther/higher bands — with the enemy types they introduce —
* append to the list when those types land. A roster's type ids are
* validated against combat.enemyTypes (js/combat/EnemyTypes.js) — an
* unknown id (a data typo) never ships; a roster that empties — or no
* band standing at all — takes the fallback (the established type).
*
* @param {number} level the player's combat level (data/progression.json — js/combat/XpManager.js)
* @param {number} hops the target system's jump-network depth from home (0 = home)
* @returns {string[]} the roster's type ids (never empty)
*/
export function patrolEnemyRoster(level, hops) {
const valid = new Set(enemyTypeIds());
const lvl = numOr(level, 0);
const hop = numOr(hops, 0);
const bands = config.get('sidequests.patrol.encounters.bands', null);
let pick = null;
if (Array.isArray(bands)) {
for (const b of bands) {
if (!b || typeof b !== 'object' || Array.isArray(b)) continue;
if (!Array.isArray(b.levels) || b.levels.length < 2 || !Array.isArray(b.hops) || b.hops.length < 2) continue;
const [lo, hi] = [numOr(b.levels[0], NaN), numOr(b.levels[1], NaN)];
const [ho, hh] = [numOr(b.hops[0], NaN), numOr(b.hops[1], NaN)];
if (![lo, hi, ho, hh].every(Number.isFinite)) continue;
if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi) && hop >= Math.min(ho, hh) && hop <= Math.max(ho, hh)) {
pick = b.enemyTypes; // the first standing band wins (list order)
break;
}
}
}
let raw = pick;
if (raw == null) raw = config.get('sidequests.patrol.encounters.fallbackEnemyTypes', null);
if (raw == null) raw = ESTABLISHED_ENEMY_TYPES;
const out = (Array.isArray(raw) ? raw : [raw])
.map((t) => String(t).trim())
.filter((t) => t !== '' && valid.has(t));
if (out.length > 0) return out;
return ESTABLISHED_ENEMY_TYPES.filter((t) => valid.has(t));
}
/**
* The contact's WAYPOINTS — the `count` of the route's `waypoints`
* (finite [x,y] pairs) the contacts stand on, SPREAD across the route
* (data/sidequests.json → patrol.encounters — POSITIONING): for count ≥
* 2, the most EVENLY SPREAD subset of the route's waypoints (scored by
* its MINIMUM pairwise separation — maximin — then its total: on a
* near-linear route that's the ends and the middle; a 3–5-point
* route's contacts never cluster); a single contact stands on the
* waypoint nearest the route's centroid (the patrol's
* middle). Pure in (waypoints, count) — deterministic, ties keep the
* lowest index order (the route itself is seeded, so the pair is stable
* for the run).
*
* @param {Array<[number, number]>} waypoints the route's waypoints
* @param {number} count the contacts' number (clamped to the route's length)
* @returns {number[]} the waypoint indices (distinct, ascending; [] for an empty route)
*/
export function patrolEncounterPositions(waypoints, count) {
const n = Array.isArray(waypoints) ? waypoints.length : 0;
if (n === 0) return [];
const k = Math.max(1, Math.min(Math.floor(numOr(count, 1)), n));
if (k === n) return waypoints.map((_, i) => i);
if (k === 1) {
// One contact: the patrol's MIDDLE — the waypoint nearest the
// route's centroid (the points' center of mass — the route is a set
// of points, not a path, so the centroid is its only 'middle').
let cx = 0, cy = 0, m = 0;
for (const w of waypoints) {
if (Array.isArray(w) && w.length >= 2 && Number.isFinite(w[0]) && Number.isFinite(w[1])) {
cx += w[0];
cy += w[1];
m++;
}
}
if (m === 0) return [0];
cx /= m;
cy /= m;
let best = 0;
let bestD = Infinity;
for (let i = 0; i < n; i++) {
const w = waypoints[i];
if (!Array.isArray(w) || w.length < 2) continue;
const d = (w[0] - cx) ** 2 + (w[1] - cy) ** 2;
if (d < bestD) {
bestD = d;
best = i;
}
}
return [best];
}
// k ≥ 2: every C(n, k) subset (n ≤ the route's 5 — a trivial space),
// scored for EVEN SPREAD: first the MINIMUM pairwise separation
// (maximin — the contacts are as far apart from each other as the
// route allows: on a near-linear route that's the ends and the middle,
// never a cluster), then the total pairwise separation, ties the
// lowest index order (the subsets enumerate in index order).
const dist = (a, b) => Math.hypot(waypoints[a][0] - waypoints[b][0], waypoints[a][1] - waypoints[b][1]);
const score = (idxs) => {
let min = Infinity;
let sum = 0;
for (let a = 0; a < idxs.length; a++) {
for (let b = a + 1; b < idxs.length; b++) {
const d = dist(idxs[a], idxs[b]);
if (d < min) min = d;
sum += d;
}
}
return { min, sum };
};
const combos = [];
const rec = (start, cur) => {
if (cur.length === k) {
combos.push(cur.slice());
return;
}
for (let i = start; i <= n - (k - cur.length); i++) rec(i + 1, [...cur, i]);
};
rec(0, []);
let best = null;
let bestMin = -1;
let bestSum = -1;
for (const c of combos) {
const s = score(c);
if (s.min > bestMin + 1e-9 || (Math.abs(s.min - bestMin) <= 1e-9 && s.sum > bestSum + 1e-9)) {
bestMin = s.min;
bestSum = s.sum;
best = c;
}
}
return best ?? [0];
}
/**
* The patrol's ENCOUNTERS — the contract's contact spec, rolled/derived
* at the accept (the scene's _establishPatrol) from the data's rules:
* the COUNT by its odds (patrolEncounterCounts — 1 on 20%, 2 on 20%, 3
* on 60%), the POSITIONS on the route's waypoints (patrolEncounter
* Positions — the max-spread subset, the single contact at the route's
* middle), the ROSTER from the standing band (patrolEnemyRoster — the
* player's level × the target's hops from home; today the low band's
* established type only), and the WAVE shape (patrolEncounterWave — one
* rolling wave of six, three at a time).
*
* Seeded on its own labelled fork (seed, 'sidequests',
* <targetSystemId>, 'patrol', <jobId>, 'encounters') — the worldgen
* contract: same seed + contract ⇒ the same encounters, across
* re-accepts, scene restarts, and saves — and the result is FIXED on
* the route record (SideQuestState.setRoute), so a later re-tune of
* the data's odds or bands can't re-roll a standing contract's combat.
*
* @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 fork's key)
* @param {Array<[number, number]>} waypoints the route's waypoints
* @param {number} level the player's combat level at the accept
* @param {number} hops the target system's jump-network depth from home
* @returns {?{at:number[], cleared:number[], roster:string[], wave:object}} null when the feature is off or the route is empty
*/
export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, level, hops) {
if (config.get('sidequests.patrol.encounters.enabled', true) === false) return null;
if (!Array.isArray(waypoints) || waypoints.length === 0) return null;
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', String(jobId), 'encounters');
const count = Math.max(1, Math.floor(numOr(r.weighted(patrolEncounterCounts(), 3), 3)));
return {
at: patrolEncounterPositions(waypoints, count),
cleared: [],
roster: patrolEnemyRoster(level, hops),
wave: patrolEncounterWave(),
};
}
/**
* A contact's WAVE SCRIPT (the shape for CombatSystem.startWaves —
* js/combat/CombatSystem.js; the same `{ waves: [...] }` shape the named
* scripts normalize to, js/combat/Waves.js): one wave of the contact's
* roster (equal weights — a single-type band is a pure type + total),
* the spec's wave shape (the data's: rolling six, three at a time). Pure
* — the scene's _firePatrolContact hands it to startWaves.
* @param {object} encounters the route record's encounters spec
* @returns {{waves: object[]}}
*/
export function patrolEncounterScript(encounters) {
const e = encounters && typeof encounters === 'object' && !Array.isArray(encounters) ? encounters : null;
const roster = (e?.roster && Array.isArray(e.roster) && e.roster.length > 0 ? e.roster : ESTABLISHED_ENEMY_TYPES)
.map((t) => String(t).trim())
.filter((t) => t !== '');
const w = { ...patrolEncounterWave(), ...(e?.wave && typeof e.wave === 'object' && !Array.isArray(e.wave) ? e.wave : {}) };
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
const wave = {
kind,
roster: Object.fromEntries(roster.map((t) => [t, 1])),
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
};
if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1)));
return { waves: [wave] };
}
/**
* The XP REWARD — the contract's SECOND payout (the claim pays it
* beside the credits): the experience WORTH of the patrol's defeated
* contacts. Completing the route requires every contact's wave to run
* to completion, so the enemies the player defeats during the quest
* ARE the contacts' waves — `at.length` × the wave's `total` — each
* enemy worth its type's `xp` (data/combat.json — the roster's weighted
* average; today's equal-weight specs are a plain average). Zero when
* the route carries no encounters (old saves, the feature off) — the
* reward degrades to credits-only, the route's waypoints alone
* completing it. Pure in the spec — it reads the SAME wave shape +
* roster patrolEncounterScript hands to the fight, so the reward and
* the combat can't drift apart (a re-tune of the bands re-tunes both).
*
* @param {object|null} encounters the route record's encounters spec
* @returns {number} the payout's XP (0 for a route without encounters)
*/
export function patrolEncounterXp(encounters) {
const e = encounters && typeof encounters === 'object' && !Array.isArray(encounters) ? encounters : null;
const at = e && Array.isArray(e.at) ? e.at.length : 0;
if (at === 0) return 0;
const wave = patrolEncounterScript(e).waves[0];
const total = Math.max(0, Math.floor(numOr(wave?.total, 0)));
if (total === 0) return 0;
const roster =
wave?.roster && typeof wave.roster === 'object' && !Array.isArray(wave.roster) ? wave.roster : null;
let weightSum = 0;
let xpWeighted = 0;
if (roster) {
for (const [typeId, w] of Object.entries(roster)) {
const weight = Math.max(0, Number(w) || 0);
if (weight === 0) continue;
weightSum += weight;
xpWeighted += weight * Math.max(0, Number(enemyTypeConfig(String(typeId))?.xp) || 0);
}
}
if (weightSum === 0) return 0;
return Math.round(at * total * (xpWeighted / weightSum));
}
/**
* A patrol's ROUTE is COMPLETE when every waypoint is LOGGED and every
* contact CLEARED (the contract's combat is part of the job — the
* reward's door opens on the last of the two). A route without
* encounters (a save predating the contacts — old saves load) completes
* on its waypoints alone.
* @param {object} route the route record (sideQuestState.routeFor's shape)
* @returns {boolean}
*/
export function patrolRouteComplete(route) {
if (!route || typeof route !== 'object') return false;
const wpCount = Array.isArray(route.waypoints) ? route.waypoints.length : 0;
const visited = Array.isArray(route.visited) ? route.visited.length : 0;
if (wpCount === 0 || visited < wpCount) return false;
const enc = route.encounters;
if (!enc || typeof enc !== 'object') return true;
const at = Array.isArray(enc.at) ? enc.at : [];
if (at.length === 0) return true;
const cleared = new Set(Array.isArray(enc.cleared) ? enc.cleared : []);
return at.every((i) => cleared.has(i));
}
/**
* 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).
*/
/**
* A route record's ENCOUNTERS spec, normalized (SideQuestState.setRoute's
* pass): the at/cleared indices valid for the route (distinct, sorted;
* cleared ⊆ at), the roster's type ids (combat.enemyTypes — unknown ids
* dropped, the established type standing when the roster empties), the
* wave shape finite. A malformed or missing spec degrades to null (an
* encounter-free route — the waypoints still stand the contract), so a
* hand-edited save or a future data shape can't break the patrol.
* @param {*} raw the raw spec (derivePatrolEncounters' result, or a save's)
* @param {number} wpCount the route's waypoint count
* @returns {?{at:number[], cleared:number[], roster:string[], wave:object}}
*/
function normalizePatrolEncounters(raw, wpCount) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const at = [...new Set(
(Array.isArray(raw.at) ? raw.at : []).filter(
(i) => Number.isInteger(i) && i >= 0 && i < wpCount,
),
)].sort((a, b) => a - b);
if (at.length === 0) return null; // no contact stands (the route's waypoints do)
const cleared = [...new Set(
(Array.isArray(raw.cleared) ? raw.cleared : []).filter(
(i) => Number.isInteger(i) && at.includes(i),
),
)].sort((a, b) => a - b);
const validTypes = new Set(enemyTypeIds());
const roster = (Array.isArray(raw.roster) ? raw.roster : [])
.map((t) => String(t).trim())
.filter((t) => t !== '' && validTypes.has(t));
const wave = patrolEncounterWave();
const w = raw.wave && typeof raw.wave === 'object' && !Array.isArray(raw.wave) ? raw.wave : {};
const kind = w.kind === 'fixed' ? 'fixed' : wave.kind;
const total = Math.max(1, Math.floor(numOr(w.total, wave.total)));
const out = {
at,
cleared,
roster: roster.length > 0 ? roster : ESTABLISHED_ENEMY_TYPES.slice(),
wave: {
kind,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, wave.maxConcurrent)))),
},
};
if (kind === 'rolling') out.wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, wave.replacePerKill)));
return out;
}
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, xp, waypoints, visited, encounters }
}
/**
* 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
* contract's ENCOUNTERS — the scene's buildPatrolRoute +
* derivePatrolEncounters results, 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; the encounters' at/cleared indices
* valid for the route, the roster the data's type ids, the wave shape
* finite — a malformed spec degrades to an encounter-free route rather
* than a broken one). @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,
),
)];
const encounters = normalizePatrolEncounters(route.encounters, waypoints.length);
this.routes.set(id, {
targetSystemId: String(route.targetSystemId ?? ''),
credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's CREDITS payout — fixed at the accept
// The contract's XP payout (the claim's second reward — beside the
// credits): the accept fixes it on the record (patrolEncounterXp,
// the scene's _establishPatrol); a route that stands without one
// (an old save) derives it from its own spec — the same contract,
// a signed contract is a signed contract.
xp: Number.isFinite(Number(route.xp)) && Number(route.xp) >= 0 ? Number(route.xp) : patrolEncounterXp(encounters),
waypoints,
visited,
encounters,
});
return true;
}
/**
* Mark a contract's contact CLEARED (the wave run completed — the
* scene's _firePatrolContact's onComplete). Idempotent — the run's
* completion fires once; a re-fire (a mid-fight quit re-arms the
* contact — the witness is the completion, not the attempt) marks it
* again without a second effect. A cleared contact's index is always a
* route waypoint index (validated). @returns {boolean} true when NEWLY cleared
*/
encounterCleared(id, idx) {
const route = this.routes.get(id);
if (!route || !route.encounters) return false;
if (!Number.isInteger(idx) || !route.encounters.at.includes(idx)) return false;
if (route.encounters.cleared.includes(idx)) return false;
route.encounters.cleared.push(idx);
return true;
}
/**
* A contract's route record (the scene's check-ins + the board's route
* line + the claim's payout read it).
* @returns {{targetSystemId:string, credits:number, xp:number, waypoints:number[][], visited:number[], encounters:?object}|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;
}
}