orbit/js/quests/SideQuests.js

2649 lines
125 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
* SURFACE WORLD (each planet and free-space station) EXCEPT in the
* player's home system (safe space — the Explore drawer's own gate,
* data/actionbar.json → surface.explore.items), and the run's ledger
* of the contracts it has taken. A world's board stands in that world
* (the board is a VIEW over the postings — two planets in one system
* post two different boards); the CONTRACTS themselves still fly in
* the system's space (a patrol's route is the system's, as always).
*
* LAYERING — the board is a VIEW over this (js/ui/JobBoardWindow.js):
* - the WINDOW renders sideQuestPostingsFor(seed, record, worldKey,
* galaxy) (the current WORLD's postings) and hands the player's
* intent to the scene (ACCEPT / SET ACTIVE / CLAIM REWARD);
* - the SCENE (GameScene.acceptSideQuest / setActiveSideQuest /
* claimSideQuest — the claimSurfaceQuest precedent: state + effects
* there, the note on the surface) lands them on the SideQuestState
* 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; the tiers: the low band (levels 1–5, 1–2 hops) the established
* type only, the level-6 jump a weighted mix (spitter + bomber beside
* the raiders) in a fatter wave, the higher tiers wider mixes — see
* data/sidequests.json → patrol.encounters.bands), and the fight's shape
* (the wave — the shared rolling shape, a band's `wave` overriding it —
* the tiers grow it, capped at 20 deep / six 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, IN ORDER
* — the route is a path flown 1, 2, 3, …), 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 + the WORLD
* through LABELLED forks: Rng.derive(seed, 'sidequests', <sysId>,
* <world>, …). The same contract stands on the same WORLD for the whole
* run — across visits, scene restarts, and saves — and a different world
* (another planet in the same system, another system) draws its own (the
* variant pool keeps the galaxy's boards from reading as one repeated
* ad). Nothing here depends on call order.
*
* 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, factionTypeIds } 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-world roll range (data/sidequests.json →
* board.perWorld). A malformed range falls back to the design's 1–3.
* @returns {[number, number]} [min, max] (integers, min ≤ max)
*/
export function sideQuestCountRange() {
const raw = config.get('sidequests.board.perWorld', null);
if (!Array.isArray(raw) || raw.length < 2) return [1, 3];
let lo = Math.floor(Number(raw[0]));
let hi = Math.floor(Number(raw[1]));
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [1, 3];
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}}
*/
/**
* Can this posting be ACCEPTED at this combat level? Two gates: the
* category's `minLevel` (the board's own demand), and the posting's
* `gateLevel` — the GATE ACCESS level for the contract's target system
* (bounty/escort in a hostile neighbor: the player's jump-gate capability
* must be unlocked INTO that system before the contract stands open; the
* pure rule is gateAccessLevel above). 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, gateLevel:number}}
*/
export function sideQuestAcceptable(posting, level = null) {
const meta = sideQuestCategoryMeta(posting?.category);
const minLevel = meta ? meta.minLevel : 0;
const gateLevel = Math.max(0, Math.floor(Number(posting?.gateLevel) || 0));
const floor = Math.max(minLevel, gateLevel);
// 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, gateLevel };
}
const lvl = Math.floor(Number(level));
return { ok: lvl >= floor, locked: lvl < floor, minLevel, gateLevel };
}
/**
* 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 ?? '',
enemy: ctx.enemy ?? '',
};
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\}|\{enemy\}/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.
* WAR MODEL (current): the patrol is ALWAYS the posting's own system —
* the board's own space, cleaned of the sighted rival's fighters (the
* enemy is ONE seeded opposing faction — the posting's `enemyFaction`,
* never both, never a pirate). Returns null = the posting system stands.
* (The old neighbor rule — a seeded draw could stand a gate-reachable
* other-faction system — is gone: the sighted faction is the enemy, the
* posting's system is the ground.)
*
* @param {object} rec the posting's system record ({ id, faction, d, … })
* @param {Rng} r the posting's seeded stream (unused — the rule is fixed)
* @param {object|null} [galaxy] the galaxy (unused — kept for the call sites)
* @returns {{id:string, name:string}|null} null = the posting system stands (always)
*/
export function patrolTargetSystem(rec, r, galaxy = null) {
return null;
}
// ------------------------------------------------------------ the war model
//
// The faction-board's three contracts share the same geography: the
// board's own system (friendly) and the NEIGHBORING systems it controls
// through its jump gates — the hostile ground (the two opposing
// factions' systems a gate-hop away). Patrol fights the sighted rival
// at home; the bounty and the escort stand on that hostile ground.
// Everything here is seeded on the posting's own stream, so the same
// posting holds the same enemy / ground for the whole run (accepts,
// scene restarts, saves).
/** The two OPPOSING factions (the FACTIONS roster minus the controller). */
export function opposingFactions(faction) {
const f = String(faction ?? '').trim();
if (f === '') return [];
return FACTIONS.filter((x) => x !== f);
}
/**
* One SEEDED opposing faction (the patrol's sighted enemy, the bounty's
* target's faction when the board names it, …): a draw over the two
* rivals on the posting's own stream — deterministic per posting.
* @param {string} faction the controller (a roster id)
* @param {Rng} r the posting's seeded stream
* @returns {string|null} a rival id, or null (a non-roster controller / no stream)
*/
export function seededOpposingFaction(faction, r) {
const opp = opposingFactions(faction);
if (opp.length === 0) return null;
if (!r || typeof r.int !== 'function') return opp[0];
return opp[r.int(0, opp.length - 1) % opp.length];
}
/**
* The posting's HOSTILE GROUND — a NEIGHBORING system (a jump-gate link,
* galaxy.jumpGatesFor) controlled by ONE of the two opposing factions
* (the bounty's mark's home, the escort's trapped transport's ground),
* picked seeded on the posting's stream. Null when the controller is
* unknown, there is no galaxy (a test), or no rival holds a gate-reachable
* neighbor — the board then leaves that slot EMPTY (a contract with no
* ground is never offered).
* @param {object} rec the posting's system record
* @param {Rng} r the posting's seeded stream
* @param {object|null} [galaxy] the galaxy (jumpGatesFor) — null in tests
* @returns {?{id:string, name:string, faction:string}}
*/
export function hostileNeighborSystem(rec, r, galaxy = null) {
const faction = typeof rec?.faction === 'string' ? rec.faction : '';
if (faction === '' || faction === 'neutral') return null;
if (!galaxy || typeof galaxy.jumpGatesFor !== 'function') return null;
const opp = new Set(opposingFactions(faction));
let links = null;
try {
links = galaxy.jumpGatesFor(rec.id);
} catch {
links = null;
}
const cands = [];
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 (opp.has(nf)) cands.push({ id: nb.id, name: String(nb.name ?? nb.id), faction: nf });
}
if (cands.length === 0) return null; // no rival holds a gate-reachable neighbor
if (!r || typeof r.int !== 'function') return cands[0];
return cands[r.int(0, cands.length - 1) % cands.length];
}
/**
* The GATE ACCESS LEVEL — the combat level the player must hold before a
* contract whose TARGET is a faction-controlled system can be accepted
* (data/gates.json → jump.factionLock). The rule is TARGET-DRIVEN: any
* faction-controlled target demands the lock's minLevel, because reaching
* it is a gate hop the player must be capable of — whether the board that
* posts it stands in neutral space (the classic neutral→faction hop) or in
* a rival faction's space (the war-model bounty/escort, whose mark's home /
* transport's ground is a hostile faction system). A NEUTRAL target is never
* locked. This is the bounty/escort lock: the posting stands greyed until
* the player's gate capability reaches the target's system.
* @param {object} rec the ISSUING system record
* @param {object} target the target system record
* @returns {number} the level that opens the gate (0 = no lock stands)
*/
export function gateAccessLevel(rec, target) {
const neutral = (x) => {
const f = x?.faction;
return f == null || f === '' || f === 'neutral';
};
if (target == null || typeof target !== 'object') return 0;
if (neutral(target)) return 0; // a NEUTRAL target — no gate lock stands
const enabled = config.get('gates.jump.factionLock.enabled', true);
if (enabled === false) return 0;
const minLevel = Number(config.get('gates.jump.factionLock.minLevel', 5));
return Number.isFinite(minLevel) ? minLevel : 5;
}
/**
* 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;
}
/**
* A wave's ROSTER as a type → weight table (Waves.js's composition
* contract — {raider: 2, bomber: 1} + total 6 is a 2:1 mix of six
* hostiles). From a data shape: a weighted `{ type: weight }` object, an
* array of type ids (equal weight — the legacy bands' `enemyTypes` shape),
* or null. Ids are validated against combat.enemyTypes (js/combat/
* EnemyTypes.js) — an unknown id (a data typo) never ships; a roster that
* empties yields `fb`.
* @param {object|string[]|string|null} raw the data's roster shape
* @param {?object} fb the fallback table (null = "nothing stands")
* @returns {?Record<string, number>}
*/
export function rosterWeights(raw, fb = null) {
const valid = new Set(enemyTypeIds());
const out = {};
if (Array.isArray(raw)) {
for (const t of raw) {
const k = String(t).trim();
if (k !== '' && valid.has(k)) out[k] = (out[k] ?? 0) + 1;
}
} else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
for (const [t, w] of Object.entries(raw)) {
const k = String(t).trim();
const wt = Math.max(0, Number(w) || 0);
if (k !== '' && valid.has(k) && wt > 0) out[k] = (out[k] ?? 0) + wt;
}
}
if (Object.keys(out).length > 0) return out;
return fb;
}
/** The established type's weighted table (every fallback's floor). */
function establishedRoster() {
return rosterWeights(ESTABLISHED_ENEMY_TYPES, { [ESTABLISHED_ENEMY_TYPES[0]]: 1 });
}
/**
* The standing PATROL band (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 axis — the player's
* COMBAT level × the target system's HOPS from home). A band may carry its
* own `roster` (weighted type mix), `wave` (the contact's fight shape)
* and `counts` (the contact-count odds) — each overriding the shared
* encounter block. Null when no band stands (the shared defaults + the
* fallback roster then rule).
* @param {number} level the player's combat level
* @param {number} hops the target system's jump-network depth from home
* @returns {?object}
*/
export function patrolEncounterBand(level, hops) {
const bands = config.get('sidequests.patrol.encounters.bands', null);
if (!Array.isArray(bands)) return null;
const lvl = numOr(level, 0);
const hop = numOr(hops, 0);
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)) {
return b; // the first standing band wins (list order)
}
}
return null;
}
/** The contact COUNT's odds table parsed — positive [count, weight] pairs sorted by count, or null. */
function patrolCountsTable(raw) {
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 null;
return pairs.sort((a, b) => a[0] - b[0]);
}
/**
* 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 standing band's own `counts` overrides the shared table
* (the higher tiers skew the odds toward every waypoint carrying a
* contact). A malformed table falls back to the design's own.
* @param {number} [level] the player's combat level (the band's axis)
* @param {number} [hops] the target system's hops from home
* @returns {Array<[number, number]>} sorted by count, positive weights
*/
export function patrolEncounterCounts(level = 0, hops = 0) {
const band = patrolEncounterBand(level, hops);
const fromBand = band ? patrolCountsTable(band.counts) : null;
if (fromBand) return fromBand;
return patrolCountsTable(config.get('sidequests.patrol.encounters.counts', null)) ?? [[1, 0.2], [2, 0.2], [3, 0.6]];
}
/**
* 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). A
* standing band's own `wave` overrides the shared block (the tiers scale
* the force — total grows ~60%/tier, the concurrent seats one/tier, the
* replace rule holds).
* @param {number} [level] the player's combat level (the band's axis)
* @param {number} [hops] the target system's hops from home
* @returns {{kind:string, total:number, maxConcurrent:number, replacePerKill:number}}
*/
export function patrolEncounterWave(level = 0, hops = 0) {
const shared = config.get('sidequests.patrol.encounters.wave', null);
const s = shared && typeof shared === 'object' && !Array.isArray(shared) ? shared : {};
const band = patrolEncounterBand(level, hops);
const w = { ...s, ...((band && band.wave && typeof band.wave === 'object' && !Array.isArray(band.wave)) ? band.wave : {}) };
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 XP SCALE (the subtle dial — data → patrol.encounters
* bands[].xpScale): a tier's fatter wave is worth more XP per contact, and
* the scale keeps the payout's pace with the curve rather than the body
* count (the tier's `xpScale`, a positive number; a band without one — the
* low band — pays full worth, 1).
* @returns {number} the multiplier (1 when the band carries none)
*/
export function patrolXpScale(level = 0, hops = 0) {
return Math.max(0, numOr(patrolEncounterBand(level, hops)?.xpScale, 1));
}
/**
* The standing band's ROSTER — the weighted type mix a patrol's contacts
* fly (data/sidequests.json → patrol.encounters.bands — the first standing
* band's `roster` (a `{ type: weight }` object) or legacy `enemyTypes`
* (equal weights), validated against combat.enemyTypes): the low tier
* (levels 1–5 at 1–2 hops) flies the ESTABLISHED type only (raider); the
* level-6 tier jumps to a three-type mix and fatter waves, and the higher
* tiers add types one at a time (stinger, gunner, hunter, barrage) as the
* level climbs. 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)
* @param {string|null} [enemyFaction] the WAR MODEL's sighted rival (the
* posting's enemyFaction): its OWN roster (combat.factions' type ids,
* equal weight, validated) stands for the data band — the patrol fights
* the faction it sighted, not a pirate
* @returns {Record<string, number>} the roster's type → weight table (never empty)
*/
export function patrolEnemyRoster(level, hops, enemyFaction = null) {
const opp = String(enemyFaction ?? '').trim();
if (opp !== '') {
const valid = new Set(enemyTypeIds());
const types = (factionTypeIds(opp) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t));
if (types.length > 0) {
const mix = {};
for (const t of types) mix[t] = 1;
return mix;
}
}
const band = patrolEncounterBand(level, hops);
const fallback = rosterWeights(config.get('sidequests.patrol.encounters.fallbackEnemyTypes', null), null) ?? establishedRoster();
if (!band) return fallback;
return rosterWeights(band.roster ?? band.enemyTypes, fallback) ?? fallback;
}
/**
* 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
* @param {string|null} [enemyFaction] the WAR MODEL's sighted rival (the
* posting's enemyFaction) — its roster stands for the data band
* (patrolEnemyRoster's first rule)
* @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, enemyFaction = null) {
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(level, hops), 3), 3)));
return {
at: patrolEncounterPositions(waypoints, count),
cleared: [],
roster: patrolEnemyRoster(level, hops, enemyFaction),
enemyFaction: enemyFaction || null,
wave: patrolEncounterWave(level, hops),
xpScale: patrolXpScale(level, hops),
};
}
/**
* 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 = rosterWeights(e?.roster, null) ?? establishedRoster();
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: { ...roster },
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;
const rw = rosterWeights(roster, null);
let weightSum = 0;
let xpWeighted = 0;
if (rw) {
for (const [typeId, w] of Object.entries(rw)) {
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;
// The band's XP SCALE (the tier's subtle dial — data → patrol.encounters
// bands[].xpScale; absent = 1): the fatter waves pay more XP worth, and
// the scale keeps the payout's pace with the curve rather than the count.
const scale = Math.max(0, numOr(e?.xpScale, 1));
return Math.round(at * total * (xpWeighted / weightSum) * scale);
}
/**
* 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 (w.roster != null) {
const rw = Array.isArray(w.roster) ? w.roster.map(String) : rosterWeights(w.roster, null);
if (rw != null) out.roster = rw;
}
// The BOUNTY's escort mix (weighted — a tier's escort flies a roster, not
// one type; the base craft's plain `raiderType` count is the fallback).
if (w.escortRoster != null) {
const er = rosterWeights(w.escortRoster, null);
if (er != null) out.escortRoster = er;
}
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 'hunter' band (levels 2–5) and the
* tuned 'marksman' (6–10), 'deadeye' (11–14), 'apex' (15+) bands — the tiers
* grow the pickets (6→10→14→18 at 3/4/5/6 at a time, their mixes widening
* — spitters/bombers at the level-6 jump, stingers/gunners/hunters/barrages
* higher) and the hunt (9→13→17→21 — the escort's mix via `escortRoster`,
* the mark's armor/damage scale 2×→3×→3.5×→4×), so a player who outruns a
* level window keeps getting hunts with the tier's pressure, not the same fight.
* 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
* a 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)),
},
xpScale: Math.max(0, numOr(e.xpScale, 1)),
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 (Number.isFinite(Number(layer.xpScale)) && Number(layer.xpScale) > 0) out.xpScale = Number(layer.xpScale);
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();
// THE WAR MODEL — a FACTION hunt (opts.faction = the mark's own
// faction): the escorts + the mark's frame draw from THAT faction's
// roster (combat.factions' type ids, validated) — the mark is a member
// of the faction that holds the target system, and its pickets are its
// own fighters. A faction without a registered roster (or a neutral
// hunt) keeps the data's pirate frames.
const factionTypes = (String(opts?.faction ?? '').trim() !== '' ? (factionTypeIds(opts.faction) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t)) : []);
const frames = (Array.isArray(rules.bountyWave.eliteFrames) ? rules.bountyWave.eliteFrames : BOUNTY_FALLBACK_FRAMES)
.map((t) => String(t).trim())
.filter((t) => valid.has(t));
const framePool = factionTypes.length > 0 ? factionTypes : frames;
let eliteType = declared !== '' && valid.has(declared) ? declared : null;
if (!eliteType) {
const pool = framePool.length > 0 ? framePool : BOUNTY_FALLBACK_FRAMES.filter((t) => valid.has(t));
eliteType = pool.length > 0 ? String(r.pick(pool)) : ESTABLISHED_ENEMY_TYPES[0];
}
const raiderType = factionTypes.length > 0 ? String(factionTypes[0]) : (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)));
// The picket waves ride the mark's OWN faction (the war model) — its
// fighters screen the lanes, equal weight over the roster.
const skirmishWave =
factionTypes.length > 0
? { ...rules.skirmishWave, roster: Object.fromEntries(factionTypes.map((t) => [t, 1])) }
: { ...rules.skirmishWave };
return {
legs,
bountyAt,
pickets,
band: rules.id,
faction: factionTypes.length > 0 ? String(opts.faction) : null,
raiders,
raiderType,
eliteType,
mark: bountyMark(opts?.mark),
skirmishWave,
bountyWave: { ...rules.bountyWave, total: Math.max(1, raiders + 1) },
xpScale: Math.max(0, numOr(rules.xpScale, 1)),
visitRadius: rules.visitRadius,
};
}
/**
* A hunt's picket wave — one rolling wave of the standing band's roster
* (a weighted mix — the tiers bring in spitters, bombers, stingers, and
* gunners beside the established type; a band with no roster is a pure
* type + total), the seats as the band says, each kill replaced.
*/
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 roster = rosterWeights(w.roster, null) ?? establishedRoster();
const wave = {
kind,
roster: { ...roster },
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 = {};
const escort = rosterWeights(w.escortRoster, null);
if (escort) {
for (const [t, wt] of Object.entries(escort)) roster[t] = wt; // the band's escort mix (weighted)
} else 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);
// The band's XP SCALE (the tier's subtle dial — data → bounty.encounters
// bands[].xpScale; a band without one — the hunter — pays full worth, 1):
// the tiers' fatter escorts + pickets pay more XP worth, and the scale
// keeps the payout's pace with the curve rather than the body count.
const xpScale = Math.max(0, numOr(p.xpScale, 1));
// The escort's XP — a band's escort mix (weighted roster) at its weighted
// mean; a band without one is the plain type × its count.
const escort = rosterWeights(p.bountyWave?.escortRoster, null);
let escortXp;
if (escort) {
let es = 0;
let ex = 0;
for (const [t, wt] of Object.entries(escort)) {
es += wt;
ex += wt * xpOf(t);
}
escortXp = es > 0 ? (ex / es) * raiders : raiders * xpOf(raiderType);
} else {
escortXp = raiders * xpOf(raiderType);
}
const xpMean = (weights) => {
let s = 0;
let x = 0;
for (const [t, wt] of Object.entries(weights)) {
s += wt;
x += wt * xpOf(t);
}
return s > 0 ? x / s : xpOf(ESTABLISHED_ENEMY_TYPES[0]);
};
let xp = escortXp + 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;
const skirmishRoster = rosterWeights(p.skirmishWave?.roster, null) ?? establishedRoster();
xp += pickets * skirmishTotal * xpMean(skirmishRoster);
return Math.round(xp * xpScale);
}
/**
* 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');
}
/**
* The system name the posting's GATE ACCESS lock stands on — the system the
* player needs access to FIRST: the BOUNTY's mark's home (targetSystemName)
or the ESCORT's trapped transport's ground (enemySystemName). Null when the
* posting names none (a neutral posting, a data gap — the copy then falls
* to the level gate's own words).
* @param {object} posting a sideQuestPostingsFor posting
* @returns {string|null}
*/
export function gateLockSystemName(posting) {
const p = posting && typeof posting === 'object' ? posting : {};
const raw = p.category === 'escort' ? p.enemySystemName : p.targetSystemName;
const name = String(raw ?? '').trim();
return name !== '' ? name : null;
}
/**
* Is the posting's binding door the GATE ACCESS lock (its target system sits
* behind a faction gate) rather than the category's own level gate? The
* gate level must stand AND outrun the level gate — then the lock's words
* are the gate's, with the system named (gateLockCopy).
* @param {object} posting a sideQuestPostingsFor posting
* @returns {boolean}
*/
export function postingGateLocked(posting) {
const gl = Math.max(0, Math.floor(Number(posting?.gateLevel) || 0));
if (gl <= 0) return false;
const minLevel = sideQuestCategoryMeta(posting?.category)?.minLevel ?? 0;
return gl >= minLevel;
}
/**
* The GATE ACCESS lock's copy — the faction board's hunt / rescue stands
* behind a gate into the hostile neighbor's system, and the player is told
* WHICH system it needs access to before the contract stands open (the
* data's {system} placeholder, resolved against the posting: bounty →
* targetSystemName, escort → enemySystemName; the {level} is the posting's
* gateLevel — the capability that opens the hop). The `which` forms: 'badge'
* (the row's greyed plate + the door's label), 'sub' (the detail's lock
* line), 'deny' (the scene's refusal when a locked contract is pressed).
* A posting with no named system (a data gap) degrades to the level gate's
* own words rather than shipping an empty brace pair.
* @param {object} posting a sideQuestPostingsFor posting
* @param {'badge'|'sub'|'deny'} [which] the form
* @returns {{glyph:string,color:string,text:string}}
*/
export function gateLockCopy(posting, which = 'badge') {
const isEscort = String(posting?.category ?? '') === 'escort';
const key = {
badge: isEscort ? 'escortGateLockBadge' : 'bountyGateLockBadge',
sub: isEscort ? 'escortGateLockSub' : 'bountyGateLockSub',
deny: isEscort ? 'escortGateDenyToast' : 'bountyGateDenyToast',
}[which] ?? (isEscort ? 'escortGateLockBadge' : 'bountyGateLockBadge');
const level =
Math.max(0, Math.floor(Number(posting?.gateLevel) || 0)) ||
Math.max(0, Math.floor(numOr(config.get('gates.jump.factionLock.minLevel', 5), 5)));
const name = gateLockSystemName(posting);
if (name === null) return which === 'deny' ? bountyDenyCopy() : bountyLockCopy(which === 'sub');
const fb =
which === 'deny' ? 'COMMISSION REFUSED — ACCESS TO {system} OPENS AT COMBAT LEVEL {level}' : 'LOCKED — ACCESS TO {system} REQUIRED';
return bountyUiLine(key, { system: String(name).toUpperCase(), level }, fb);
}
/** 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)),
},
},
xpScale: Math.max(0, numOr(raw.xpScale, 1)),
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 WORLD's LIVE postings (the board's list) — 1–3 by design
* (sideQuestCountRange), drawn from the LIVE categories only
* (enabledSideQuestCategories): distinct categories while they last,
* then repeats into the variant pools (the same category can hold more
* than one posting once the count outruns the live set). The board is
* PER SURFACE WORLD (a planet's `p<ordinal>` / a station's `s<n>` key —
* the record id minus the system prefix): two worlds in one system
* stand two DIFFERENT boards, while the flavor's {system} voice stays
* the system's (the contract is about keeping the SYSTEM safe — the
* board just sits on a world), and the contract still flies in the
* system's space (the patrol's route is the system's, as always).
*
* Deterministic in (seed, record, world[, galaxy]) — the labelled forks
* under Rng.derive(seed, seedLabel, <sysId>, <world>) make the roll, the
* category order, and every per-posting draw (the patrol's target
* included) independent of call order, so the board stands the same
* across visits, scene restarts, and saves.
*
* A posting's shape (the window's row + detail + the scene's accept):
* { id, systemId, systemName, worldKey, faction (null = neutral),
* category, label, accent, title, issuer, description,
* reward: { credits }, objective: { type } } — objective is the
* typed socket the category's completion rules fill in when the type
* lands. A PATROL posting additionally carries targetSystemId /
* targetSystemName (the route's system — patrolTargetSystem; default
* the posting's own system). The id's shape is
* 'sq-<systemId>-<world>-<category>-<slot>' (the save-safe ledger key —
* parseSideQuestId below splits it back apart).
*
* @param {string|number} seed the galaxy's seed
* @param {object} rec a system record ({ id, name, faction, d, … })
* @param {string} worldKey the board's world (a planet's `p<ordinal>` / a station's `s<n>`)
* @param {object|null} [galaxy] the galaxy (the patrol's neighbor rule's jumpGatesFor) — null in tests
* @param {number|null} [level] the player's combat level (the BOUNTY HUNT's gate stamp)
* @returns {object[]} [] when the feature is off or no category is live
*/
export function sideQuestPostingsFor(seed, rec, worldKey, galaxy = null, level = null) {
if (!sideQuestsEnabled()) return [];
if (!rec || typeof rec.id !== 'string' || rec.id === '') return [];
// A world the board can't name (a caller without world data — a test,
// a hand-built record) borrows the FIRST world's board — the board
// never blanks for a missing key, and the key is part of the seed, so
// the fallback is still deterministic.
const wk = String(worldKey ?? '').trim();
const world = /^[a-z][a-z0-9]{0,3}$/i.test(wk) ? wk.toLowerCase() : 'p1';
const ctx = postingContext(rec);
const cats = enabledSideQuestCategories(ctx.context); // a faction board never draws a neutral-only type
if (cats.length === 0) return [];
const [lo, hi] = sideQuestCountRange();
const base = Rng.derive(seed, seedLabel(), rec.id, world);
const count = base.int(lo, hi);
const order = base.shuffle(cats); // the world's slot order (seeded, stable)
const out = [];
const usedDossier = new Set(); // one board never posts the SAME wanted leader twice
for (let slot = 0; slot < count; slot++) {
const cat = order[slot % order.length];
const r = Rng.derive(seed, seedLabel(), rec.id, world, cat, slot); // per-posting stream
const posting = buildPosting(seed, rec, ctx, cat, slot, r, galaxy, usedDossier, world);
if (!posting) continue;
if (cat === 'bounty' && posting.dossier?.name) usedDossier.add(posting.dossier.name);
// The level gate is PRESENTATION + ACCEPT, never generation: the
// 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;
// The GATE ACCESS lock's mark (the posting's target system sits behind a
// faction gate and it is the binding door — the board's row / detail +
// the scene's refusal then speak in the gate's words, the system named,
// not the level gate's).
posting.gateLocked =
gate.locked === true && gate.gateLevel > 0 && gate.gateLevel >= gate.minLevel;
out.push(posting);
}
return out;
}
/**
* A POSTING id back apart (the scene's _sideQuestPosting / the claim's
* reward lookup parse the ledger's id to its board + slot, then
* regenerate the board from the seed and find the posting). The id's
* shape is 'sq-\u003csystemId\u003e-\u003cworld\u003e-\u003ccategory\u003e-\u003cslot\u003e' (a planet's world key
* `p\u003cordinal\u003e`, a station's `s\u003cn\u003e' — both a letter + digits, so the
* greedy split is the true one: the category is letters, the slot
* digits, at the id's end; the system id is whatever stands before the
* world key). An id the board never posted (an OLD per-system save id —
* 'sq-\u003csys\u003e-\u003ccat\u003e-\u003cslot\u003e' — the world key's digits never stand — or a
* hand-edited string) yields null rather than a guess.
* @param {string} id the ledger's posting id
* @returns {?{systemId: string, worldKey: string, category: string, slot: number}}
*/
export function parseSideQuestId(id) {
if (typeof id !== 'string') return null;
const m = /^sq-(.+)-([a-z]\d{1,3})-([a-z]+)-(\d+)$/i.exec(id);
if (!m) return null;
const cat = m[3].toLowerCase();
if (!SIDE_QUEST_CATEGORIES.includes(cat)) return null; // a world key is a letter + digits only
return { systemId: m[1], worldKey: m[2].toLowerCase(), category: cat, slot: Math.floor(Number(m[4])) };
}
/**
* The COPY POOL a posting draws from (data/sidequests.json → descriptions).
*
* THE CHAIN — the board's CONTEXT decides where it starts, and each link
* falls through to the next so a board always reads something:
* A FACTION BOARD (the war voice):
* 1. descriptions.faction.<factionId>.<category> — the FACTION'S OWN voice
* (apex / mindweave / zenith). Each of the nine pools is written in that
* faction's doctrine and names the rival it will actually fight through
* the {enemy} placeholder (the posting's seeded enemyFaction).
* 2. descriptions.faction.<category> — the shared war-voice pool: the
* documented fallback for a faction with no pool of its own (a new
* power, or a hand-edited data file).
* A NEUTRAL BOARD starts at its own pirate-safety pool instead — it never
* reads a faction's war copy, which would name powers a neutral board has
* no quarrel with.
* LAST RESORT (either context):
* 3. descriptions.neutral.<category> — the neutral voice, so a hole in the
* data posts a contract in plain words rather than posting nothing.
*
* A pool that stands but is not an array or is empty (a typo in the data)
* falls through to the next link rather than posting nothing. Pure in
* (context, category, faction): no RNG, so it never disturbs the posting's
* seeded draw.
*
* @param {object} ctx postingContext() shape ({ context: 'neutral'|'faction', … })
* @param {string} cat the category id
* @param {string|null} [factionId] the board's faction id (a faction board)
* @returns {object[]} the variant pool ([] = no copy at all)
*/
function descriptionPool(ctx, cat, factionId = null) {
const chain = [];
const own = String(factionId ?? '').trim();
if (ctx?.context === 'faction') {
// A token-safe path segment: a faction id is a bare slug in the data, and
// a stray dot would reach across the config tree.
if (/^[a-z0-9_]+$/i.test(own)) chain.push(`sidequests.descriptions.faction.${own}.${cat}`);
chain.push(`sidequests.descriptions.faction.${cat}`);
}
chain.push(`sidequests.descriptions.neutral.${cat}`);
for (const path of chain) {
const pool = config.get(path, null);
if (Array.isArray(pool) && pool.length > 0) return pool;
}
return [];
}
/** 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, world = 'p1') {
const meta = sideQuestCategoryMeta(cat);
if (!meta) return null;
const factionCtx = ctx.context === 'faction';
// THE WAR MODEL (a faction board — the contract's ground):
// patrol — the sighted enemy: ONE seeded opposing faction (the posting's
// own system is the ground — patrolTargetSystem stands home);
// bounty — the mark's home: a NEIGHBORING system a gate-hop away held by
// one of the two rivals (hostileNeighborSystem); no rival ground
// ⇒ the slot stands EMPTY;
// escort — the trapped transport's ground: the same hostile neighbor;
// the convoy's destination is the board's OWN system (the
// friendly territory the contract is issued from).
// The draw rides the posting's seeded stream, so the same posting holds
// the same enemy / ground for the whole run (accepts, restarts, saves).
let enemyFaction = null;
let ground = null;
if (factionCtx && cat === 'patrol') {
enemyFaction = seededOpposingFaction(rec.faction, r);
} else if (factionCtx && (cat === 'bounty' || cat === 'escort')) {
ground = hostileNeighborSystem(rec, r, galaxy);
if (!ground) return null; // no rival holds gate-reachable ground — the slot is empty
enemyFaction = ground.faction;
}
// ESCORT FEASIBILITY (the NEUTRAL board — the classic contract): only an
// escort that is actually COMPLETABLE (a NEUTRAL destination 1–2 gate
// hops away, the contract's signature, fixed at the accept). When the
// system has no gate-reachable neutral world the escort slot stands
// EMPTY. (The FACTION escort's feasibility is the hostile ground above
// — its destination is the board's own system, always deliverable.)
if (cat === 'escort' && !factionCtx && galaxy) {
const postingId = `sq-${rec.id}-${world}-${cat}-${slot}`;
if (!escortDestinationFor(seed, postingId, rec.id, galaxy)) return null;
}
// The copy pool: this faction's own pool when it has one (the three-deep
// fallback above), so an Apex board reads Apex and a Zenith board reads
// Zenith. The draw rides the posting's own seeded stream, exactly as the
// shared pool did — same seed, same slot, same variant index.
const pool = descriptionPool(ctx, cat, factionCtx ? rec.faction : null);
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;
}
}
}
// The posting's COPY context: the board's own (system/faction/issuer/
// enemies) plus `enemy` — the ONE named rival this posting is about
// (the sighted patrol faction, the mark's faction, the transport's
// captors — the pools' {enemy} placeholder resolves against it).
const labels = sideQuestFactionLabels();
const pctx = { ...ctx, enemy: enemyFaction ? (labels[enemyFaction] ?? enemyFaction) : '' };
const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, pctx);
// 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, pctx) || 'The Board';
const description = resolveSideQuestText(v?.body ?? '', pctx);
// The GATE ACCESS lock — the contract's target system must be
// gate-reachable at the player's level (bounty/escort in hostile ground;
// 0 = the hop is never locked — the scene's gateAccessLevel pure rule).
const gateLevel = ground ? gateAccessLevel(rec, ground) : 0;
return {
id: `sq-${rec.id}-${world}-${cat}-${slot}`, // stable for the run (save-safe ledger key)
systemId: rec.id,
systemName: String(rec.name ?? rec.id),
worldKey: world, // the board's world (the id's world part — a planet's p<ordinal> / a station's s<n>)
faction: ctx.context === 'faction' ? rec.faction : null,
category: cat,
label: meta.label,
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 WAR MODEL — the contract's named rival + hostile ground (a
// faction board's patrol/bounty/escort; neutral postings carry none):
// enemyFaction — the sighted / target / captor faction (one of the
// board's two rivals; the roster the combat draws);
// gateLevel — the jump-gate level that opens the hop to the
// target system (the accept's lock, beside minLevel);
// targetSystem* — the PATROL's ground (its own system —
// patrolTargetSystem) or the BOUNTY's mark's home
// (the hostile neighbor);
// enemySystem* — the ESCORT's trapped transport's ground (the
// hostile neighbor — the convoy's RENDEZVOUS system;
// its destination is the board's own system, here).
...(enemyFaction ? { enemyFaction } : {}),
...(gateLevel > 0 ? { gateLevel } : {}),
...(cat === 'patrol' ? patrolTargetFields(rec, r, galaxy) : {}),
...(cat === 'bounty'
? {
dossier: buildBountyDossier(v, pctx),
targetSystemId: ground ? ground.id : rec.id,
targetSystemName: ground ? ground.name : String(rec.name ?? rec.id),
}
: {}),
...(cat === 'escort' && factionCtx && ground
? {
enemySystemId: ground.id,
enemySystemName: ground.name,
destSystemId: rec.id, // the friendly territory: the board's own system
destSystemName: 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 × its SURFACE WORLDS (every planet — `p<ordinal>` — and every
* free-space station — the `s<n>` settlement) × the full slot range
* (the range's max — a superset of what any enabled set can post) × all
* four categories. The worlds come from the system's CONTENT (the
* galaxy's ensureContent — lazy === eager, the same worlds the player
* lands on; a hand-built caller without one degrades to an empty
* space, which drops every ledger id — a foreign save rather than a
* broken one). Used by SideQuestState.fromJSON to drop ledger ids the
* save's galaxy never posted (a hand-edited or foreign ledger degrades
* instead of sticking) — and to shed the OLD per-system ids (the board
* went per-world; a contract a world never posted can't survive a load).
*
* @param {{records: object[], ensureContent: (id: string) => object}} galaxy the restored galaxy (Galaxy — records + ensureContent)
* @returns {Set<string>}
*/
export function allSideQuestJobIds(galaxy) {
const set = new Set();
const records = galaxy && Array.isArray(galaxy.records) ? galaxy.records : null;
if (!records || typeof galaxy.ensureContent !== 'function') return set;
const [, hi] = sideQuestCountRange();
for (const rec of records) {
if (!rec || typeof rec.id !== 'string' || rec.id === '') continue;
let content = null;
try {
content = galaxy.ensureContent(rec.id) ?? null;
} catch {
content = null; // a degenerate system (a hand-built test) — no worlds stand
}
// The SURFACE WORLDS that can carry a board: every planet (the
// world's own surface) + every free-space station (its own surface).
// Planet-bound settlements sit ON their planet (the planet is the
// board's world — its `s<n>` key is never posted). The key is the
// record id minus the system prefix (p<ordinal> / s<n> — the
// reputation/trading key, stable per seed).
const worlds = [];
const keyOf = (w) => {
const id = w && typeof w.id === 'string' ? w.id : '';
return id.startsWith(rec.id + '-') ? id.slice(rec.id.length + 1) : null;
};
for (const p of (content && Array.isArray(content.planets) ? content.planets : [])) {
const k = keyOf(p);
if (k) worlds.push(k);
}
for (const s of (content && Array.isArray(content.settlements) ? content.settlements : [])) {
if (s?.anchor?.type !== 'space') continue; // a planet-bound settlement is its planet's board
const k = keyOf(s);
if (k) worlds.push(k);
}
for (const wk of worlds) {
for (let slot = 0; slot < hi; slot++) {
for (const cat of SIDE_QUEST_CATEGORIES) {
set.add(`sq-${rec.id}-${wk}-${cat}-${slot}`);
}
}
}
}
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>-<world>-<cat>-<slot> — the world's key: a
* planet's p<ordinal> / a station's s<n>) — the postings themselves
* regenerate from the seed, so the save carries only the marks (a save =
* seed + player state).
*
* 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 roster = rosterWeights(raw.roster, null);
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 ?? establishedRoster(),
xpScale: Math.max(0, numOr(raw.xpScale, 1)),
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;
}
// ------------------------------------------- the ESCORT (category 'escort')
// The convoy job (data/sidequests.json → escort). A neutral TRANSPORT (its own
// ship class — js/combat/TransportShip.js) plus 1–2 escort fighters stands at
// a single RENDEZVOUS waypoint in the posting's own system; the player links
// it and flies the convoy through 1–2 gate hops to a seeded DESTINATION
// (a planet or a free-space station) in a gate-reachable NEUTRAL system,
// through a heavily elevated lane of mixed-fighter waves (its own director,
// data → escort.encounters). If the transport is destroyed the contract fails
// (phase 'failed') and the player must LAND ON A PLANET to reset it (a fresh
// convoy stands at the rendezvous); arriving at the destination object with
// the convoy intact and the sky clear completes it.
//
// The scene's share (js/scenes/GameScene.js → _establishEscort / updateEscort)
// uses these pure pieces:
// escortDestinationFor(…) — the seeded 1–2 hop destination + object (fork
// 'dest'; a signed contract is a signed contract)
// escortEncounterXp(plan) — the claim's XP beside the credits (the lane's worth)
// escortWaveRoster(level) — the mixed roster for the player's combat level
// escortRouteComplete(route) — the delivery + sky-clear witness (scene-side)
/** A system record is NEUTRAL when it carries no faction (null/''/'neutral'). */
function isNeutralSystem(rec) {
if (!rec) return false;
const f = rec.faction;
return typeof f !== 'string' || f === '' || f.toLowerCase() === 'neutral';
}
/**
* The escort's DESTINATION — a planet or a free-space station 1–2 gate hops
* away in a gate-reachable NEUTRAL system (data → escort.destinationHops),
* rolled seeded on its own labelled fork (seed, 'sidequests', <origin>,
* 'escort', <jobId>, 'dest'). The origin is the posting's system (where the
* convoy stands); the walk takes NEUTRAL neighbors only (the neutral-only
* scope), the 2-hop leg falling back to a 1-hop when the far leg gives up.
* A destination object (planet first, then a free-space station, then any
* laid-down object) is picked seeded, so the same contract always points at
* the same world across re-accepts, jumps, and saves.
* @param {string|number} seed the galaxy's seed
* @param {string} jobId the accepted contract's id
* @param {string} originSystemId the convoy's standing system (the posting's)
* @param {object|null} [galaxy] the galaxy (jumpGatesFor / ensureContent)
* @returns {?{hops:number, path:string[], destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string}}
*/
export function escortDestinationFor(seed, jobId, originSystemId, galaxy = null) {
const origin = String(originSystemId ?? '').trim();
if (!origin || !galaxy || typeof galaxy.jumpGatesFor !== 'function') return null;
const hopsRange = config.get('sidequests.escort.destinationHops', [1, 2]);
let lo = Math.max(1, Math.floor(Number(Array.isArray(hopsRange) ? hopsRange[0] : 1)));
let hi = Math.max(lo, Math.floor(Number(Array.isArray(hopsRange) ? hopsRange[1] : 2)));
const r = Rng.derive(seed, seedLabel(), origin, 'escort', String(jobId), 'dest');
const hops = r.int(lo, hi);
const neighbors = (id) => {
let links = null;
try { links = galaxy.jumpGatesFor(id) ?? []; } catch { links = []; }
return (Array.isArray(links) ? links : [])
.filter((nb) => nb && typeof nb.id === 'string' && nb.id !== '' && nb.id !== origin && isNeutralSystem(nb));
};
// The 1-hop set (neutral neighbors of the origin, the origin itself out).
const first = neighbors(origin);
if (first.length === 0) return null; // a dead-end origin (a leaf with one gate back) — no destination stands
let dest = null;
let path = [origin];
if (hops >= 2) {
const mid = first[r.int(0, first.length - 1)];
const second = neighbors(mid).filter((nb) => nb.id !== mid.id);
if (second.length > 0) {
dest = second[r.int(0, second.length - 1)];
path = [origin, mid.id, dest.id];
}
}
if (!dest) {
// The 2-hop leg gave up (or asked for one): a 1-hop destination stands.
dest = first[r.int(0, first.length - 1)];
path = [origin, dest.id];
}
// The destination OBJECT — a planet first, then a free-space station, then
// any laid-down object (a leaf with no planets/stations still stands).
let content = null;
try { content = galaxy.ensureContent(dest.id) ?? null; } catch { content = null; }
const planets = (content && Array.isArray(content.planets) ? content.planets : [])
.filter((p) => Number.isFinite(p?.x) && Number.isFinite(p?.y));
const stations = (content && Array.isArray(content.settlements) ? content.settlements : [])
.filter((s) => s?.anchor?.type === 'space' && Number.isFinite(s?.x) && Number.isFinite(s?.y));
let obj = null;
let type = '';
if (planets.length > 0) {
obj = planets[r.int(0, planets.length - 1)];
type = 'planet';
} else if (stations.length > 0) {
obj = stations[r.int(0, stations.length - 1)];
type = 'station';
} else {
const any = (content ? [...(content.planets ?? []), ...(content.settlements ?? [])] : [])
.filter((o) => Number.isFinite(o?.x) && Number.isFinite(o?.y));
if (any.length === 0) return null; // a system with nothing laid down — no destination
obj = any[r.int(0, any.length - 1)];
type = obj?.anchor?.type === 'space' ? 'station' : 'planet';
}
return {
hops: path.length - 1,
path,
destSystemId: dest.id,
destSystemName: String(dest.name ?? dest.id),
destObjectType: type,
destObjectId: String(obj?.id ?? ''),
destObjectName: String(obj?.name ?? obj?.id ?? ''),
};
}
/**
* The WAR-MODEL ESCORT's DESTINATION OBJECT — the delivery world INSIDE the
* board's OWN (friendly) system: a planet first, then a free-space station,
* then any laid-down object, picked seeded on its own labelled fork (seed,
* 'sidequests', <friendly>, 'escort', <jobId>, 'friend-dest') so the same
* contract always points at the same world across re-accepts, jumps, and
* saves. (The convoy's RENDEZVOUS stands in the hostile neighbor; its
* DELIVERY is this friendly world — 0 hops, the board's own territory.)
* @param {string|number} seed the galaxy's seed
* @param {string} jobId the accepted contract's id
* @param {string} friendlySystemId the board's own (friendly) system
* @param {object|null} [galaxy] the galaxy (ensureContent)
* @returns {?{hops:number, path:string[], destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string}}
*/
export function escortFriendlyDestination(seed, jobId, friendlySystemId, galaxy = null) {
const id = String(friendlySystemId ?? '').trim();
if (!id || !galaxy || typeof galaxy.ensureContent !== 'function') return null;
const rec = galaxy.byId?.get?.(id) ?? null;
const r = Rng.derive(seed, seedLabel(), id, 'escort', String(jobId), 'friend-dest');
let content = null;
try { content = galaxy.ensureContent(id) ?? null; } catch { content = null; }
const planets = (content && Array.isArray(content.planets) ? content.planets : [])
.filter((p) => Number.isFinite(p?.x) && Number.isFinite(p?.y));
const stations = (content && Array.isArray(content.settlements) ? content.settlements : [])
.filter((s) => s?.anchor?.type === 'space' && Number.isFinite(s?.x) && Number.isFinite(s?.y));
let obj = null;
let type = '';
if (planets.length > 0) { obj = planets[r.int(0, planets.length - 1)]; type = 'planet'; }
else if (stations.length > 0) { obj = stations[r.int(0, stations.length - 1)]; type = 'station'; }
else {
const any = (content ? [...(content.planets ?? []), ...(content.settlements ?? [])] : [])
.filter((o) => Number.isFinite(o?.x) && Number.isFinite(o?.y));
if (any.length === 0) return null; // a system with nothing laid down — no destination
obj = any[r.int(0, any.length - 1)];
type = obj?.anchor?.type === 'space' ? 'station' : 'planet';
}
return {
hops: 0,
path: [id],
destSystemId: id,
destSystemName: String(rec?.name ?? id),
destObjectType: type,
destObjectId: String(obj?.id ?? ''),
destObjectName: String(obj?.name ?? obj?.id ?? ''),
};
}
/**
* The escort's mixed-fighter ROSTER at the player's combat level (data →
* escort.encounters.bands: a { type: weight } table over combat.enemyTypes
* ids, the unknown-id rule being the patrol's — a type that isn't in
* combat.enemyTypes never ships; a roster that empties takes the standing
* type). A band that doesn't stand falls back to the data's fallbackRoster.
* THE WAR MODEL (a faction escort — `enemyFaction`, the captors): the
* lane's waves are THAT faction's fighters (its registered roster, equal
* weight) — a trapped {faction} transport is hunted by its captors, not
* by pirates; a neutral escort (no faction) keeps the data bands.
* @param {number} level the player's combat level
* @param {string|null} [enemyFaction] the captors' faction (the posting's enemyFaction)
* @returns {{roster: Record<string,number>, total:number, maxConcurrent:number, replacePerKill:number}}
*/
export function escortWaveRoster(level = 0, enemyFaction = null) {
const valid = new Set(enemyTypeIds());
const factionMix = (String(enemyFaction ?? '').trim() !== '' ? (factionTypeIds(enemyFaction) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t)) : []);
if (factionMix.length > 0) {
const roster = {};
for (const t of factionMix) roster[t] = 1;
const w = config.get('sidequests.escort.encounters.wave', {});
const total = Math.max(1, Math.floor(numOr(w.total, 8)));
return {
roster,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 4)))),
replacePerKill: Math.max(0, Math.floor(numOr(w.replacePerKill, 1))),
};
}
const bands = config.get('sidequests.escort.encounters.bands', null);
const lv = Math.max(0, Math.floor(Number(level) || 0));
let band = null;
if (Array.isArray(bands)) {
for (const b of bands) {
if (!b) continue;
const lv2 = Array.isArray(b.levels) ? b.levels : [0, 999999];
if (lv >= (Number(lv2[0]) || 0) && lv <= (Number(lv2[1]) || 999999)) { band = b; break; }
}
}
const raw = band?.roster ?? config.get('sidequests.escort.encounters.fallbackRoster', { raider: 1 });
const roster = {};
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
for (const [t, w] of Object.entries(raw)) {
const key = String(t).trim();
if (key === '' || !valid.has(key)) continue;
const wt = Math.max(0, Number(w) || 0);
if (wt > 0) roster[key] = wt;
}
}
if (Object.keys(roster).length === 0) roster[ESTABLISHED_ENEMY_TYPES[0]] = 1;
const w = config.get('sidequests.escort.encounters.wave', {});
const total = Math.max(1, Math.floor(numOr(w.total, 8)));
return {
roster,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 4)))),
replacePerKill: Math.max(0, Math.floor(numOr(w.replacePerKill, 1))),
};
}
/**
* The escort contract's XP PAYOUT (the claim's second reward — beside the
* credits): the lane's experience WORTH, a flat worth scaled off the lane's
* wave (the data's wave total × the roster's mean xp × the hops). Fixed at
* the accept (a signed contract is a signed contract).
* @param {{hops?:number, level?:number}} [o]
* @returns {number}
*/
export function escortEncounterXp(o = {}) {
const w = escortWaveRoster(Number(o?.level) || 0);
const hops = Math.max(1, Math.floor(numOr(o?.hops, 1)));
let weightSum = 0;
let xpWeighted = 0;
for (const [t, wt] of Object.entries(w.roster)) {
weightSum += wt;
xpWeighted += wt * Math.max(0, Number(enemyTypeConfig(String(t))?.xp) || 0);
}
if (weightSum === 0) return 0;
const meanXp = xpWeighted / weightSum;
return Math.max(0, Math.round(w.total * meanXp * (1 + 0.25 * (hops - 1))));
}
/**
* An escort route's `escort` sub-record, normalized (SideQuestState.setRoute's
* pass): a valid phase, a destination system + object (finite), the transport's
* armor finite (clamped to a sane floor), the escort count a small int. A
* malformed record degrades to a fresh 'rendezvous' record on the same
* destination (the destination is the contract's signature — it is never
* dropped), so a hand-edited save or a future data shape can't break the
* convoy (it re-stands, at full armor, at the rendezvous).
* @param {*} raw the raw spec (_establishEscort's result, or a save's)
* @returns {{phase:string, originSystemId:string, originSystemName:string, destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string, hops:number, transportHull:number, transportShields:number, escortCount:number, name:string}}
*/
function normalizeEscort(raw) {
const PHASES = ['rendezvous', 'transit', 'failed', 'delivered', 'delivering'];
const phase = PHASES.includes(raw?.phase) ? raw.phase : 'rendezvous';
const escortCountRange = config.get('sidequests.escort.escortCount', [1, 2]);
const eLo = Math.max(0, Math.floor(Number(Array.isArray(escortCountRange) ? escortCountRange[0] : 1)));
const eHi = Math.max(eLo, Math.floor(Number(Array.isArray(escortCountRange) ? escortCountRange[1] : 2)));
const num = (v, fb) => (Number.isFinite(Number(v)) ? Math.max(0, Number(v)) : fb);
return {
phase,
originSystemId: String(raw?.originSystemId ?? ''),
originSystemName: String(raw?.originSystemName ?? ''),
destSystemId: String(raw?.destSystemId ?? ''),
destSystemName: String(raw?.destSystemName ?? ''),
destObjectType: raw?.destObjectType === 'station' ? 'station' : 'planet',
destObjectId: String(raw?.destObjectId ?? ''),
destObjectName: String(raw?.destObjectName ?? ''),
hops: Math.max(0, Math.floor(num(raw?.hops, 1))),
// THE ARMOR — the STANDING (max) hull is the reset's full armor; the LIVE
// hull is clamped to it. An old save (pre-max fields) carries only the
// live armor: the max falls back to it (a degraded record can't gain
// armor it never had). Live can't exceed max (a hand-edited save can't
// out-heal the standing hull).
transportMaxHull: (() => { const live = Math.max(1, Math.floor(num(raw?.transportHull, 1200))); return Math.max(1, Math.floor(num(raw?.transportMaxHull, live))); })(),
transportMaxShields: (() => { const live = Math.max(0, Math.floor(num(raw?.transportShields, 500))); return Math.max(0, Math.floor(num(raw?.transportMaxShields, live))); })(),
transportHull: Math.min(Math.max(1, Math.floor(num(raw?.transportHull, 1200))), Math.max(1, Math.floor(num(raw?.transportMaxHull, 1200)))),
transportShields: Math.min(Math.max(0, Math.floor(num(raw?.transportShields, 500))), Math.max(0, Math.floor(num(raw?.transportMaxShields, 500)))),
escortCount: Math.min(eHi, Math.max(eLo, Math.floor(num(raw?.escortCount, Math.floor((eLo + eHi) / 2))))),
name: String(raw?.name ?? ''),
// THE WAR MODEL — the captors' faction (the lane's roster; a neutral
// escort / an old save carries none → '' → the classic pirate-lane law).
enemyFaction: (() => { const f = String(raw?.enemyFaction ?? '').trim(); return f; })(),
};
}
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'
: route.kind === 'escort'
? 'escort'
: 'patrol';
const encounters = normalizePatrolEncounters(route.encounters, waypoints.length);
const bounty = kind === 'bounty' ? normalizeBountyPlan(route.bounty, waypoints.length) : null;
// The ESCORT's convoy record — its phase + destination + the transport's
// armor (the live read-out the scene syncs each frame) + the escort
// count + the convoy's name. Only an escort route carries one; the
// destination is the contract's signature and is never dropped (a
// malformed record re-stands a fresh 'rendezvous' on the same world).
const escort = kind === 'escort' ? normalizeEscort(route.escort ?? route) : null;
this.routes.set(id, {
// The route's TYPE — 'patrol' (the default: every patrol, and every
// save written before the hunts existed), 'bounty' (a hunt: SEQUENTIAL
// legs + the hidden plan), or 'escort' (a convoy: the rendezvous + the
// transport + the destination). 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 /
// escortEncounterXp, the scene's _establishPatrol / _establishEscort);
// 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)
: kind === 'escort'
? escortEncounterXp({ hops: escort?.hops ?? 1, level: 0 })
: patrolEncounterXp(encounters),
waypoints,
visited,
encounters,
bounty,
escort,
});
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). The route is a PATH, flown IN ORDER (1, 2, 3, …): only the
* NEXT waypoint — the first unvisited one — logs, a patrol exactly as
* a hunt. @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;
// The SEQUENTIAL LAW (both kinds — a patrol's waypoints and a hunt's
// legs): only the NEXT waypoint logs (the first unvisited, 1, 2, 3…).
// The ONE exception is the hunt's kill's own check-off (`opts.anyOrder`:
// the mark came down and what is left of the lane is signed off with it).
if (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;
}
}