1833 lines
83 KiB
JavaScript
1833 lines
83 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.
|
||
* - 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>).
|
||
*
|
||
* `minLevel` (the BOUNTY HUNT's gate — data → categories.bounty.minLevel)
|
||
* is the combat LEVEL the board demands (js/combat/XpManager.js): under
|
||
* it the posting still STANDS (its slot is seeded — a board that
|
||
* reshuffled as the player leveled would never settle), but the row
|
||
* renders LOCKED and the scene's accept refuses it. 1 = no gate
|
||
* (patrol's, and every category's, default). `contexts` (the BOUNTY
|
||
* HUNT's neutral-only posting) lists which boards post it — ['neutral'],
|
||
* ['faction'], or both (the default when a category says nothing).
|
||
*
|
||
* @returns {{id:string, enabled:boolean, label:string, accent:string, minLevel:number, contexts: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;
|
||
// An omitted minLevel demands NOTHING (0) — that is every legacy category,
|
||
// which stood open long before the hunt's gate existed. A fresh run's combat
|
||
// level is 0 (js/combat/XpManager.js), so a default of 1 would have rendered
|
||
// the standing PATROLS locked; only a category that SAYS so asks for a level.
|
||
const minLevel = Math.max(0, Math.floor(numOr(c.minLevel, 0)));
|
||
const contexts = (Array.isArray(c.contexts) ? c.contexts : [])
|
||
.map((v) => String(v).trim().toLowerCase())
|
||
.filter((v) => v === 'neutral' || v === 'faction');
|
||
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'),
|
||
minLevel,
|
||
contexts: contexts.length > 0 ? contexts : ['neutral', 'faction'],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* @param {string} [context] 'neutral' | 'faction' — when given, the
|
||
* category's own `contexts` list also gates it (a BOUNTY HUNT never
|
||
* stands on a faction board; omitting the argument is every category
|
||
* that says nothing about contexts, i.e. patrol unchanged)
|
||
* @returns {string[]}
|
||
*/
|
||
export function enabledSideQuestCategories(context = null) {
|
||
const ctx = context === 'neutral' || context === 'faction' ? context : null;
|
||
return SIDE_QUEST_CATEGORIES.filter((id) => {
|
||
const meta = sideQuestCategoryMeta(id);
|
||
if (!meta || meta.enabled !== true) return false;
|
||
return ctx === null || meta.contexts.includes(ctx);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Can this posting be ACCEPTED at this combat level? The gate is the
|
||
* category's `minLevel` (the scene's accept refuses with the data's deny
|
||
* toast, and the board renders the row LOCKED); a posting without a
|
||
* known level (a test, an old call) stands open.
|
||
* @param {object} posting sideQuestPostingsFor's shape
|
||
* @param {number|null} level the player's combat level
|
||
* @returns {{ok:boolean, locked:boolean, minLevel:number}}
|
||
*/
|
||
export function sideQuestAcceptable(posting, level = null) {
|
||
const meta = sideQuestCategoryMeta(posting?.category);
|
||
const minLevel = meta ? meta.minLevel : 0;
|
||
// UNKNOWN is not LEVEL 0. `null` / `undefined` (no XP bank in the boot, an
|
||
// older caller that never reads one) stands the contract OPEN — Number(null)
|
||
// is 0, which would otherwise lock every gated category on a missing read.
|
||
if (level === null || level === undefined || !Number.isFinite(Number(level))) {
|
||
return { ok: true, locked: false, minLevel };
|
||
}
|
||
const lvl = Math.floor(Number(level));
|
||
return { ok: lvl >= minLevel, locked: lvl < minLevel, minLevel };
|
||
}
|
||
|
||
/**
|
||
* 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 = [], opts = {}) {
|
||
// The placement LAW is shared (region inset, anchor clearance, separation);
|
||
// `opts` lets another contract type stand its OWN leg count + fork label on
|
||
// top of it — a BOUNTY HUNT asks for the plan's exact leg count under its
|
||
// 'bounty' fork (GameScene._establishBountyHunt). No opts = patrol exactly
|
||
// as it always was.
|
||
const rawRange = Array.isArray(opts?.waypoints) && opts.waypoints.length > 0
|
||
? opts.waypoints
|
||
: 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 fork = String(opts?.fork ?? 'patrol');
|
||
const r = Rng.derive(seed, seedLabel(), targetSystemId, fork, 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 BOUNTY HUNT (category 'bounty')
|
||
// The contract with a NAME ON IT (data/sidequests.json → bounty). A hunt is
|
||
// a patrol with its ending hidden: three legs along one pirate leader's
|
||
// travel lanes, flown STRICTLY IN ORDER, and the mark stands on ONE of them
|
||
// (20% on the first leg, else 20% of what is left on the second, else the
|
||
// third at 100%). The rolls happen ONCE at the accept on their own labelled
|
||
// fork and ride the route record — a signed contract is a signed contract —
|
||
// and NOTHING here is spoken to the player before it fires (the plan object
|
||
// is model-side only; the board, the compass, and the beacons never read
|
||
// `bountyAt`).
|
||
//
|
||
// The scene's share (js/scenes/GameScene.js → _establishBountyHunt /
|
||
// updateBountyHunt) uses these pure pieces:
|
||
// bountyRules(level) — the standing band's numbers (the data's,
|
||
// with `inherits` resolved and fallbacks)
|
||
// deriveBountyPlan(…) — the rolled chain (bountyAt + per-leg pickets)
|
||
// bountySkirmishScript / bountyEngagementScript — CombatSystem.startWaves shapes
|
||
// bountyHuntXp(plan) — the claim's XP beside the credits
|
||
// nextBountyWaypoint(route) — the SEQUENTIAL check-in rule (one leg at a time)
|
||
// bountyRouteComplete(route) — the kill is the whole job
|
||
|
||
/** The elite's fallback frames (a dossier naming a craft that isn't in combat.enemyTypes). */
|
||
const BOUNTY_FALLBACK_FRAMES = [
|
||
'bomber',
|
||
'spitter',
|
||
'stinger',
|
||
'hunter',
|
||
'gunner',
|
||
'barrage',
|
||
'warden',
|
||
];
|
||
|
||
/**
|
||
* The wanted leader's OWN identity, carried on the plan (the dossier's name +
|
||
* epithet). The comms name the mark only once its fight stands in front of the
|
||
* ship — the plan itself never reads out where it hides — and because the mark
|
||
* rides the route record it survives a save. Empty strings when the board had
|
||
* no dossier (a hand-edited pool): the hunt still flies, silent.
|
||
*/
|
||
function bountyMark(raw) {
|
||
const m = raw && typeof raw === 'object' ? raw : {};
|
||
return {
|
||
name: String(m.name ?? '').trim(),
|
||
epithet: String(m.epithet ?? '').trim(),
|
||
};
|
||
}
|
||
|
||
/** One wave shape, clamped (a hunt's fight is never smaller than one hostile). */
|
||
function bountyWaveShape(raw, fb) {
|
||
const w = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||
const kind = (w.kind ?? fb.kind) === 'fixed' ? 'fixed' : 'rolling';
|
||
const total = Math.max(1, Math.floor(numOr(w.total, fb.total)));
|
||
const out = {
|
||
kind,
|
||
total,
|
||
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, fb.maxConcurrent)))),
|
||
replacePerKill:
|
||
kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, fb.replacePerKill))) : 0,
|
||
};
|
||
if (Array.isArray(w.roster) && w.roster.length > 0) out.roster = w.roster.map(String);
|
||
if (w.raiderType != null) out.raiderType = String(w.raiderType);
|
||
if (w.raiders != null) out.raiders = Math.max(0, Math.floor(numOr(w.raiders, fb.raiders ?? 0)));
|
||
if (Array.isArray(w.eliteFrames) && w.eliteFrames.length > 0) out.eliteFrames = w.eliteFrames.map(String);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The standing band's RULES (data/sidequests.json → bounty + bounty.encounters).
|
||
* A BAND is a level window's tuning: the live 'hunter' band (levels 2–5) and
|
||
* the stubbed 'marksman' band (6+), which `inherits` the hunter's numbers so a
|
||
* player who outruns level 5 keeps getting hunts instead of losing the content
|
||
* mid-run. Resolution: the first band whose `levels` window holds the player's
|
||
* combat level, else the fallbackBand, else the first band; the chosen band's
|
||
* own overrides (bountyChance / skirmishChance / waypoints / skirmishWave /
|
||
* bountyWave) sit on top of the shared encounter block, and `inherits` pulls
|
||
* that band's numbers underneath them.
|
||
*
|
||
* @param {number} level the player's combat level (js/combat/XpManager.js)
|
||
* @returns {{id:string, legs:[number,number], visitRadius:number, bountyChance:number, skirmishChance:number, skirmishWave:object, bountyWave:object}}
|
||
*/
|
||
export function bountyRules(level = 0) {
|
||
const enc = config.get('sidequests.bounty.encounters', null);
|
||
const e = enc && typeof enc === 'object' && !Array.isArray(enc) ? enc : {};
|
||
const legRange = (() => {
|
||
const raw = config.get('sidequests.bounty.waypoints', [3, 3]);
|
||
let lo = Math.floor(numOr(Array.isArray(raw) ? raw[0] : 3, 3));
|
||
let hi = Math.floor(numOr(Array.isArray(raw) ? raw[1] : 3, 3));
|
||
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 3];
|
||
lo = Math.max(1, lo);
|
||
hi = Math.max(lo, hi);
|
||
return [lo, hi];
|
||
})();
|
||
const base = {
|
||
id: 'hunter',
|
||
legs: legRange,
|
||
visitRadius: Math.max(60, numOr(config.get('sidequests.bounty.visitRadius', 420), 420)),
|
||
bountyChance: Math.min(1, Math.max(0, numOr(e.bountyChance, 0.2))),
|
||
skirmishChance: Math.min(1, Math.max(0, numOr(e.skirmishChance, 0.5))),
|
||
skirmishWave: bountyWaveShape(e.skirmishWave, {
|
||
kind: 'rolling',
|
||
total: 6,
|
||
maxConcurrent: 3,
|
||
replacePerKill: 1,
|
||
roster: ['raider'],
|
||
}),
|
||
bountyWave: bountyWaveShape(e.bountyWave, {
|
||
kind: 'rolling',
|
||
total: 9,
|
||
raiders: 8,
|
||
maxConcurrent: 3,
|
||
replacePerKill: 1,
|
||
raiderType: 'raider',
|
||
eliteFrames: BOUNTY_FALLBACK_FRAMES.slice(),
|
||
}),
|
||
elite: {
|
||
healthScale: Math.max(1, numOr(e.bountyWave?.elite?.healthScale, 2)),
|
||
damageScale: Math.max(1, numOr(e.bountyWave?.elite?.damageScale, 2)),
|
||
},
|
||
enabled: e.enabled !== false,
|
||
};
|
||
// The elite's own scales ride the wave shape (the runner reads one object).
|
||
base.bountyWave.elite = { ...base.elite };
|
||
|
||
const bands = Array.isArray(e.bands) ? e.bands : [];
|
||
const byId = new Map();
|
||
for (const b of bands) {
|
||
if (b && typeof b === 'object' && !Array.isArray(b) && b.id != null) byId.set(String(b.id), b);
|
||
}
|
||
const lvl = numOr(level, 0);
|
||
let band = null;
|
||
for (const b of bands) {
|
||
if (!b || typeof b !== 'object' || Array.isArray(b)) continue;
|
||
const w = Array.isArray(b.levels) ? b.levels : null;
|
||
if (!w) continue;
|
||
const lo = numOr(w[0], NaN);
|
||
const hi = numOr(w[1] ?? w[0], lo);
|
||
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
|
||
if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi)) {
|
||
band = b; // the first window holding the level wins (list order)
|
||
break;
|
||
}
|
||
}
|
||
if (!band) band = byId.get(String(e.fallbackBand ?? '')) ?? bands[0] ?? null;
|
||
if (!band || typeof band !== 'object' || Array.isArray(band)) return base;
|
||
|
||
// The band's overrides, with `inherits` standing underneath them.
|
||
const layers = [];
|
||
const seen = new Set();
|
||
let cur = band;
|
||
while (cur && !seen.has(String(cur.id ?? ''))) {
|
||
seen.add(String(cur.id ?? ''));
|
||
layers.unshift(cur);
|
||
cur = cur.inherits != null ? byId.get(String(cur.inherits)) : null;
|
||
}
|
||
const out = { ...base, id: String(band.id ?? base.id) };
|
||
for (const layer of layers) {
|
||
if (Array.isArray(layer.waypoints)) {
|
||
const lo = Math.max(1, Math.floor(numOr(layer.waypoints[0], base.legs[0])));
|
||
const hi = Math.max(lo, Math.floor(numOr(layer.waypoints[1] ?? layer.waypoints[0], base.legs[1])));
|
||
out.legs = [lo, hi];
|
||
}
|
||
if (Number.isFinite(Number(layer.bountyChance))) out.bountyChance = Math.min(1, Math.max(0, Number(layer.bountyChance)));
|
||
if (Number.isFinite(Number(layer.skirmishChance))) out.skirmishChance = Math.min(1, Math.max(0, Number(layer.skirmishChance)));
|
||
if (Number.isFinite(Number(layer.visitRadius))) out.visitRadius = Math.max(60, Number(layer.visitRadius));
|
||
if (layer.skirmishWave) out.skirmishWave = bountyWaveShape(layer.skirmishWave, out.skirmishWave);
|
||
if (layer.bountyWave) {
|
||
out.bountyWave = bountyWaveShape(layer.bountyWave, out.bountyWave);
|
||
const sc = layer.bountyWave.elite || {};
|
||
out.bountyWave.elite = {
|
||
healthScale: Math.max(1, numOr(sc.healthScale, base.elite.healthScale)),
|
||
damageScale: Math.max(1, numOr(sc.damageScale, base.elite.damageScale)),
|
||
};
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The hunt's PLAN — the hidden shape of one contract, rolled at the accept
|
||
* and FIXED on the route record (never re-drawn; never shown).
|
||
*
|
||
* The chain rides its own labelled fork (seed,'sidequests',
|
||
* <targetSystemId>,'bounty',<jobId>,'chain'): legs 1..N-1 each roll
|
||
* `bountyChance` for the mark and the LAST leg stands it at 100% — with the
|
||
* data's 0.2 and three legs that reads 20% / 16% / 64%. Each leg BEFORE the
|
||
* mark independently rolls `skirmishChance` for the leader's pickets (the
|
||
* bounty leg never does — the mark IS that fight, and no leg after it owes
|
||
* anything).
|
||
*
|
||
* @param {string|number} seed the galaxy's seed
|
||
* @param {string} jobId the accepted contract's id (the fork's key)
|
||
* @param {string} targetSystemId the hunt's system (the fork's key)
|
||
* @param {number} level the player's combat level at the accept (the band)
|
||
* @param {object} [opts] { craft, mark } — the dossier's declared craft (its own
|
||
* story's frame id); an id outside combat.enemyTypes falls back to a SEEDED
|
||
* pick from the band's eliteFrames, so a data typo stands a fight instead of
|
||
* breaking one. `mark` ({ name, epithet }) is the wanted leader's OWN name —
|
||
* carried on the plan so the fight's comms can say who it was AFTER the fact
|
||
* (never before: the plan itself stays invisible), and it rides the save.
|
||
* @returns {{legs:number, bountyAt:number, pickets:boolean[], band:string, raiders:number, eliteType:string, skirmishWave:object, bountyWave:object}}
|
||
*/
|
||
export function deriveBountyPlan(seed, jobId, targetSystemId, level = 0, opts = {}) {
|
||
const rules = bountyRules(level);
|
||
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'bounty', String(jobId), 'chain');
|
||
const legs = Math.max(1, r.int(rules.legs[0], rules.legs[1]));
|
||
let bountyAt = legs - 1; // the LAST leg stands the mark (the chain's guarantee)
|
||
for (let i = 0; i < legs - 1; i++) {
|
||
if (r.chance(rules.bountyChance)) {
|
||
bountyAt = i;
|
||
break;
|
||
}
|
||
}
|
||
const pickets = [];
|
||
for (let i = 0; i < legs - 1; i++) {
|
||
if (i >= bountyAt) {
|
||
pickets.push(false); // the mark's own leg, and anything after it, never rolls
|
||
continue;
|
||
}
|
||
pickets.push(r.chance(rules.skirmishChance));
|
||
}
|
||
const valid = new Set(enemyTypeIds());
|
||
const declared = String(opts?.craft ?? '').trim();
|
||
const frames = (Array.isArray(rules.bountyWave.eliteFrames) ? rules.bountyWave.eliteFrames : BOUNTY_FALLBACK_FRAMES)
|
||
.map((t) => String(t).trim())
|
||
.filter((t) => valid.has(t));
|
||
let eliteType = declared !== '' && valid.has(declared) ? declared : null;
|
||
if (!eliteType) {
|
||
const pool = frames.length > 0 ? frames : BOUNTY_FALLBACK_FRAMES.filter((t) => valid.has(t));
|
||
eliteType = pool.length > 0 ? String(r.pick(pool)) : ESTABLISHED_ENEMY_TYPES[0];
|
||
}
|
||
const raiderType = valid.has(String(rules.bountyWave.raiderType ?? ''))
|
||
? String(rules.bountyWave.raiderType)
|
||
: ESTABLISHED_ENEMY_TYPES[0];
|
||
const raiders = Math.max(0, Math.floor(numOr(rules.bountyWave.raiders, rules.bountyWave.total - 1)));
|
||
return {
|
||
legs,
|
||
bountyAt,
|
||
pickets,
|
||
band: rules.id,
|
||
raiders,
|
||
raiderType,
|
||
eliteType,
|
||
mark: bountyMark(opts?.mark),
|
||
skirmishWave: { ...rules.skirmishWave },
|
||
bountyWave: { ...rules.bountyWave, total: Math.max(1, raiders + 1) },
|
||
visitRadius: rules.visitRadius,
|
||
};
|
||
}
|
||
|
||
/** A hunt's picket wave — one rolling wave of the established type, three at a time. */
|
||
export function bountySkirmishScript(plan = {}) {
|
||
const w = plan?.skirmishWave || bountyRules(0).skirmishWave;
|
||
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
|
||
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
|
||
const type = ESTABLISHED_ENEMY_TYPES[0];
|
||
const wave = {
|
||
kind,
|
||
roster: { [type]: 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 BOUNTY's wave — one rolling wave of `raiders + 1`: the escort plus the
|
||
* mark itself, declared on the wave as `elite` so js/combat/Waves.js deploys
|
||
* it with the OPENING group and js/combat/EnemyShip.js doubles its hull /
|
||
* shields and weapon damage (the data's healthScale / damageScale). The kill
|
||
* is the contract: the scene halts the run (CombatSystem.haltWaves) and waits
|
||
* out the survivors.
|
||
*/
|
||
export function bountyEngagementScript(plan = {}) {
|
||
const rules = bountyRules(0);
|
||
const w = plan?.bountyWave || rules.bountyWave;
|
||
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
|
||
const eliteType = String(plan?.eliteType ?? 'raider');
|
||
const raiders = Math.max(0, Math.floor(numOr(plan?.raiders, Math.max(0, numOr(w.total, 9) - 1))));
|
||
const raiderType = String(w.raiderType ?? ESTABLISHED_ENEMY_TYPES[0]);
|
||
const total = Math.max(1, raiders + 1); // the escort + exactly one mark
|
||
const roster = {};
|
||
if (raiders > 0) roster[raiderType] = raiders;
|
||
roster[eliteType] = (roster[eliteType] ?? 0) + 1;
|
||
const wave = {
|
||
kind,
|
||
roster,
|
||
total,
|
||
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
|
||
elite: {
|
||
type: eliteType,
|
||
healthScale: numOr(w.elite?.healthScale, rules.elite.healthScale),
|
||
damageScale: numOr(w.elite?.damageScale, rules.elite.damageScale),
|
||
// The name the KILL reads out (js/combat/Waves.js keeps it; EnemyShip
|
||
// tags it; GameScene's witness puts it in the comms line).
|
||
name: String(plan?.mark?.name ?? '').trim(),
|
||
},
|
||
};
|
||
if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1)));
|
||
return { waves: [wave] };
|
||
}
|
||
|
||
/**
|
||
* The hunt's XP payout — the worth of everything it throws at the ship: the
|
||
* mark (its own type's worth × its scale-up) plus the escort plus every
|
||
* rolled picket wave. Fixed at the accept beside the credits, so the claim
|
||
* pays what the plan promised even if the data is re-tuned mid-run.
|
||
*/
|
||
export function bountyHuntXp(plan = {}) {
|
||
const p = plan && typeof plan === 'object' ? plan : {};
|
||
const xpOf = (t) => Math.max(0, Number(enemyTypeConfig(String(t))?.xp) || 0);
|
||
const rules = bountyRules(0);
|
||
const raiders = Math.max(0, Math.floor(numOr(p.raiders, numOr(rules.bountyWave.raiders, 8))));
|
||
const raiderType = p.raiderType != null ? String(p.raiderType) : String(rules.bountyWave.raiderType ?? 'raider');
|
||
const eliteType = String(p.eliteType ?? ESTABLISHED_ENEMY_TYPES[0]);
|
||
const scale = numOr(p.bountyWave?.elite?.healthScale, rules.bountyWave.elite?.healthScale ?? 2);
|
||
let xp = raiders * xpOf(raiderType) + xpOf(eliteType) * Math.max(1, scale);
|
||
const skirmishTotal = Math.max(0, Math.floor(numOr(p.skirmishWave?.total, rules.skirmishWave.total)));
|
||
const pickets = (Array.isArray(p.pickets) ? p.pickets : []).filter(Boolean).length;
|
||
xp += pickets * skirmishTotal * xpOf(ESTABLISHED_ENEMY_TYPES[0]);
|
||
return Math.round(xp);
|
||
}
|
||
|
||
/**
|
||
* The SEQUENTIAL check-in rule — the hunt's one departure from patrol law.
|
||
* The chain's odds read an ORDER, so a hunt logs its legs one at a time: the
|
||
* NEXT leg is the lowest index not yet logged, and only that leg's beacon and
|
||
* compass point anywhere. `null` when every leg stands logged.
|
||
*
|
||
* @param {object} route the route record
|
||
* @returns {?{index:number, x:number, y:number, leg:number, of:number}}
|
||
*/
|
||
export function nextBountyWaypoint(route) {
|
||
if (!route || typeof route !== 'object') return null;
|
||
const wp = Array.isArray(route.waypoints) ? route.waypoints : [];
|
||
if (wp.length === 0) return null;
|
||
const visited = new Set(Array.isArray(route.visited) ? route.visited : []);
|
||
for (let i = 0; i < wp.length; i++) {
|
||
const w = wp[i];
|
||
if (!Array.isArray(w) || w.length < 2) continue;
|
||
if (visited.has(i)) continue;
|
||
return { index: i, x: Number(w[0]), y: Number(w[1]), leg: i + 1, of: wp.length };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The hunt is COMPLETE when the mark is DOWN (the contract's only witness —
|
||
* the legs are a formality the kill checks off at once). A malformed or
|
||
* missing bounty record falls back to the patrol law (every leg logged) so a
|
||
* degraded save can still finish.
|
||
*/
|
||
export function bountyRouteComplete(route) {
|
||
if (!route || typeof route !== 'object') return false;
|
||
const b = route.bounty && typeof route.bounty === 'object' ? route.bounty : null;
|
||
if (b?.down === true) return true;
|
||
if (!b) return patrolRouteComplete(route);
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* A bounty posting's DOSSIER (the wanted list's fields — data →
|
||
* descriptions.neutral.bounty[]). Resolved for the readout, with the craft
|
||
* VALIDATED against combat.enemyTypes and the copy placeholder-free. Returns
|
||
* null when the pool entry isn't a dossier (a hand-edited pool) — the posting
|
||
* still stands on its title/description alone.
|
||
*/
|
||
function buildBountyDossier(v, ctx) {
|
||
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
||
const name = resolveSideQuestText(String(v.name ?? ''), ctx).trim();
|
||
if (name === '') return null;
|
||
const valid = new Set(enemyTypeIds());
|
||
const craft = String(v.craft ?? '').trim();
|
||
return {
|
||
name,
|
||
epithet: resolveSideQuestText(String(v.epithet ?? ''), ctx).trim(),
|
||
crime: resolveSideQuestText(String(v.crime ?? ''), ctx).trim(),
|
||
craft: valid.has(craft) ? craft : null, // null = the plan picks a frame (seeded)
|
||
craftLabel: resolveSideQuestText(String(v.craftLabel ?? ''), ctx).trim(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One line of the hunt's UI copy (data/sidequests.json → ui.<key>) with its
|
||
* `{placeholders}` filled. A key can stand as a plain string or as
|
||
* `{ glyph, color, text }` (the toasts carry their own glyph + color); an
|
||
* unknown key falls back to `fb` so a data typo degrades to the design's own
|
||
* words instead of blanking a toast. Placeholders with no value blank out.
|
||
* @returns {{glyph:string,color:string,text:string}|null} null when neither the key nor a fallback stands
|
||
*/
|
||
function bountyUiLine(key, vars = {}, fb = '') {
|
||
const raw = config.get(`sidequests.ui.${key}`, null);
|
||
const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||
const text = obj ? String(obj.text ?? '') : typeof raw === 'string' ? raw : '';
|
||
if (text === '' && fb === '') return null;
|
||
let out = text === '' ? String(fb) : text;
|
||
for (const [k, value] of Object.entries(vars)) out = out.split(`{${k}}`).join(String(value));
|
||
out = out.replace(/\{[a-zA-Z]+\}/g, ''); // an unfilled placeholder never ships
|
||
return {
|
||
glyph: obj ? String(obj.glyph ?? '') : '',
|
||
color: obj ? String(obj.color ?? '') : '',
|
||
text: out,
|
||
};
|
||
}
|
||
|
||
/** The bounty gate's combat level (data → categories.bounty.minLevel; the design's 2). */
|
||
export function bountyMinLevel() {
|
||
return sideQuestCategoryMeta('bounty')?.minLevel ?? 2;
|
||
}
|
||
|
||
/** A locked row's badge (“LOCKED — REQUIRES LEVEL 2”); `sub` = the detail line under it. */
|
||
export function bountyLockCopy(sub = false) {
|
||
const level = bountyMinLevel();
|
||
return sub
|
||
? bountyUiLine('bountyLockSub', { level }, 'BRING YOUR RECORD UP TO LEVEL {level}')
|
||
: bountyUiLine('bountyLockBadge', { level }, 'LOCKED — REQUIRES LEVEL {level}');
|
||
}
|
||
|
||
/** The scene's refusal when a locked contract is pressed anyway. */
|
||
export function bountyDenyCopy() {
|
||
return bountyUiLine('bountyAcceptDenyToast', { level: bountyMinLevel() }, 'COMMISSION REFUSED — LEVEL {level} REQUIRED');
|
||
}
|
||
|
||
/** A hunt line's copy (a leg logged / pickets inbound / the mark sighted / down / complete). */
|
||
export function bountyText(key, vars = {}) {
|
||
return bountyUiLine(key, vars, '');
|
||
}
|
||
|
||
/** The readout's progress line (logged legs only — never what waits on them). */
|
||
export function bountyProgressCopy(route) {
|
||
const wp = Array.isArray(route?.waypoints) ? route.waypoints : [];
|
||
const visited = Array.isArray(route?.visited) ? new Set(route.visited).size : 0;
|
||
return bountyUiLine('bountyRouteProgress', { visited, total: wp.length }, 'WAYPOINT {visited} OF {total} LOGGED');
|
||
}
|
||
|
||
/** The contract readout's dossier lines (wanted / epithet / charges / craft). */
|
||
export function bountyDossierCopy(dossier) {
|
||
const d = dossier && typeof dossier === 'object' ? dossier : null;
|
||
if (!d || !d.name) return [];
|
||
const craftLine = bountyUiLine('dossierCraft', { craft: d.craftLabel ?? '' }, 'CRAFT — {craft}');
|
||
const lines = [
|
||
bountyUiLine('dossierWanted', { name: d.name }, 'WANTED — {name}'),
|
||
d.epithet ? bountyUiLine('dossierEpithet', { epithet: d.epithet }, '{epithet}') : null,
|
||
bountyUiLine('dossierCrime', { crime: d.crime ?? '' }, 'CHARGES — {crime}'),
|
||
d.craftLabel ? craftLine : null,
|
||
];
|
||
return lines.filter((l) => l && l.text !== '');
|
||
}
|
||
|
||
/**
|
||
* A bounty route record's PLAN, normalized (SideQuestState.setRoute's pass).
|
||
* The rolled shape is VALIDATED, never re-drawn: `bountyAt` inside the leg
|
||
* count, one `pickets` mark per leg with nothing standing on the mark's own
|
||
* leg or behind it, the elite a real combat.enemyTypes id, the wave shapes
|
||
* finite, and the runtime marks (`down`, the cleared picket legs) kept. A
|
||
* malformed record degrades to null — the route's waypoints still stand, the
|
||
* hunt simply never fires (a hand-edited save must not manufacture a fight).
|
||
*
|
||
* @param {*} raw deriveBountyPlan's result (or a save's)
|
||
* @param {number} wpCount the route's waypoint count
|
||
* @returns {?object}
|
||
*/
|
||
function normalizeBountyPlan(raw, wpCount) {
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||
const legs = Math.max(1, Math.floor(numOr(raw.legs, wpCount)));
|
||
const bountyAt = Math.floor(numOr(raw.bountyAt, NaN));
|
||
if (!Number.isInteger(bountyAt) || bountyAt < 0 || bountyAt >= legs) return null;
|
||
const valid = new Set(enemyTypeIds());
|
||
const eliteType = String(raw.eliteType ?? '').trim();
|
||
if (!valid.has(eliteType)) return null;
|
||
// The pickets: one mark per leg BEFORE the mark (the chain's rule — the leg
|
||
// that holds the hunt, and every leg behind it, never rolls).
|
||
const pickets = [];
|
||
for (let i = 0; i < legs - 1; i++) {
|
||
pickets.push(i < bountyAt && raw.pickets?.[i] === true);
|
||
}
|
||
const raiders = Math.max(0, Math.floor(numOr(raw.raiders, 8)));
|
||
const raiderType = valid.has(String(raw.raiderType ?? '').trim())
|
||
? String(raw.raiderType).trim()
|
||
: ESTABLISHED_ENEMY_TYPES[0];
|
||
const rules = bountyRules(0);
|
||
const mark = bountyMark(raw.mark);
|
||
const sw = raw.skirmishWave && typeof raw.skirmishWave === 'object' ? raw.skirmishWave : {};
|
||
const bw = raw.bountyWave && typeof raw.bountyWave === 'object' ? raw.bountyWave : {};
|
||
const el = bw.elite && typeof bw.elite === 'object' ? bw.elite : {};
|
||
return {
|
||
legs,
|
||
bountyAt,
|
||
pickets,
|
||
band: String(raw.band ?? 'hunter'),
|
||
raiders,
|
||
raiderType,
|
||
eliteType,
|
||
mark,
|
||
skirmishWave: bountyWaveShape(sw, rules.skirmishWave),
|
||
bountyWave: {
|
||
...bountyWaveShape({ ...bw, total: Math.max(1, raiders + 1) }, rules.bountyWave),
|
||
raiderType,
|
||
raiders,
|
||
elite: {
|
||
healthScale: Math.max(1, numOr(el.healthScale, 2)),
|
||
damageScale: Math.max(1, numOr(el.damageScale, 2)),
|
||
},
|
||
},
|
||
visitRadius: Math.max(60, numOr(raw.visitRadius, rules.visitRadius)),
|
||
// The runtime marks (the scene's witness marks — a fresh plan carries none).
|
||
down: raw.down === true,
|
||
skirmishCleared: [...new Set(
|
||
(Array.isArray(raw.skirmishCleared) ? raw.skirmishCleared : []).filter(
|
||
(i) => Number.isInteger(i) && i >= 0 && i < bountyAt && raw.pickets?.[i] === true,
|
||
),
|
||
)].sort((a, b) => a - b),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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, level = null) {
|
||
if (!sideQuestsEnabled()) return [];
|
||
if (!rec || typeof rec.id !== 'string' || rec.id === '') return [];
|
||
const ctx = postingContext(rec);
|
||
const cats = enabledSideQuestCategories(ctx.context); // a faction board never draws a neutral-only type
|
||
if (cats.length === 0) return [];
|
||
|
||
const [lo, hi] = sideQuestCountRange();
|
||
const base = Rng.derive(seed, seedLabel(), rec.id);
|
||
const count = base.int(lo, hi);
|
||
const order = base.shuffle(cats); // the system's slot order (seeded, stable)
|
||
|
||
const out = [];
|
||
const usedDossier = new Set(); // one board never posts the SAME wanted leader twice
|
||
for (let slot = 0; slot < count; slot++) {
|
||
const cat = order[slot % order.length];
|
||
const r = Rng.derive(seed, seedLabel(), rec.id, cat, slot); // per-posting stream
|
||
const posting = buildPosting(seed, rec, ctx, cat, slot, r, galaxy, usedDossier);
|
||
if (!posting) continue;
|
||
if (cat === 'bounty' && posting.dossier?.name) usedDossier.add(posting.dossier.name);
|
||
// The level gate is PRESENTATION + ACCEPT, never generation: the
|
||
// posting stands in its seeded slot below the gate too (a locked
|
||
// row is still the same row), and the scene refuses the accept.
|
||
const gate = sideQuestAcceptable(posting, level);
|
||
posting.locked = gate.locked === true;
|
||
posting.minLevel = gate.minLevel;
|
||
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, usedDossier = 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 : [];
|
||
let v = pool.length > 0 ? pool[r.int(0, pool.length - 1)] : null;
|
||
// A WANTED LIST never posts the same leader twice on one board: the seeded
|
||
// draw stands when it is fresh, otherwise the next entry forward (wrapping)
|
||
// takes it. Deterministic — the slots are visited in a fixed order and the
|
||
// used set is built by this loop alone, so a system's board never depends on
|
||
// call order or on any other system.
|
||
if (cat === 'bounty' && pool.length > 0 && usedDossier && usedDossier.size > 0) {
|
||
for (let step = 0; step < pool.length; step++) {
|
||
const cand = pool[(pool.indexOf(v) + step + pool.length) % pool.length];
|
||
if (!cand?.name || !usedDossier.has(cand.name)) {
|
||
v = cand;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
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 BOUNTY HUNT's DOSSIER (data → descriptions.neutral.bounty[]) — the
|
||
// wanted leader's name/epithet/charges and the CRAFT its own story names
|
||
// (`craft`, validated against combat.enemyTypes; null on a pool entry that
|
||
// doesn't declare one, where the plan picks a frame seeded). The hunt's
|
||
// plan (which leg holds the mark) is NOT here — it rolls at the accept and
|
||
// rides the route record, never the board.
|
||
...(cat === 'bounty' ? { dossier: buildBountyDossier(v, ctx), targetSystemId: rec.id, targetSystemName: String(rec.name ?? rec.id) } : {}),
|
||
};
|
||
}
|
||
|
||
/** 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 kind = route.kind === 'bounty' ? 'bounty' : 'patrol';
|
||
const encounters = normalizePatrolEncounters(route.encounters, waypoints.length);
|
||
const bounty = kind === 'bounty' ? normalizeBountyPlan(route.bounty, waypoints.length) : null;
|
||
this.routes.set(id, {
|
||
// The route's TYPE — 'patrol' (the default: every patrol, and every
|
||
// save written before the hunts existed) or 'bounty' (a hunt: SEQUENTIAL
|
||
// legs + the hidden plan). The type is what branches the scene's update;
|
||
// a patrol record normalizes exactly as it always did.
|
||
kind,
|
||
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)
|
||
: kind === 'bounty'
|
||
? bountyHuntXp(bounty)
|
||
: patrolEncounterXp(encounters),
|
||
waypoints,
|
||
visited,
|
||
encounters,
|
||
bounty,
|
||
});
|
||
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 {{kind:string, targetSystemId:string, credits:number, xp:number, waypoints:number[][], visited:number[], encounters:?object, bounty:?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, opts = {}) {
|
||
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;
|
||
// A HUNT flies its legs IN ORDER — the chain's odds read a first, second
|
||
// and third leg — so only the NEXT leg logs, unless the caller is the
|
||
// kill's own check-off (`opts.anyOrder`: the mark came down and what is
|
||
// left of the lane is signed off with it). A patrol keeps its any-order law.
|
||
if (route.kind === 'bounty' && opts.anyOrder !== true) {
|
||
const next = nextBountyWaypoint(route);
|
||
if (!next || next.index !== idx) return false;
|
||
}
|
||
route.visited.push(idx);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* A hunt's PICKET wave cleared (the leg's raid wave ran to completion — the
|
||
* witness is the completion, not the attempt, exactly as a patrol contact).
|
||
* Only a leg that ROLLED a picket can clear. @returns {boolean} true when NEWLY cleared
|
||
*/
|
||
bountySkirmishCleared(id, idx) {
|
||
const route = this.routes.get(id);
|
||
const b = route?.bounty;
|
||
if (!b || !Number.isInteger(idx) || idx < 0 || idx >= (b.pickets?.length ?? 0)) return false;
|
||
if (b.pickets[idx] !== true) return false;
|
||
if (b.skirmishCleared.includes(idx)) return false;
|
||
b.skirmishCleared.push(idx);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* THE MARK IS DOWN — the hunt's completion witness (the kill, then every
|
||
* survivor of its escort put down). It sets the plan's `down` mark and
|
||
* checks off EVERY remaining leg in the same breath (a hunt ends on the
|
||
* kill, not on the flying), so bountyRouteComplete stands from here and no
|
||
* further leg fires. Idempotent — a double witness marks once.
|
||
* @returns {boolean} true when NEWLY witnessed
|
||
*/
|
||
bountyDown(id) {
|
||
const route = this.routes.get(id);
|
||
const b = route?.bounty;
|
||
if (!b) return false;
|
||
for (let i = 0; i < route.waypoints.length; i++) {
|
||
if (!route.visited.includes(i)) route.visited.push(i);
|
||
}
|
||
if (b.down === true) return false;
|
||
b.down = true;
|
||
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;
|
||
}
|
||
}
|