orbit/js/quests/SideQuests.js

752 lines
33 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.
* - 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;
}
}