orbit/js/quests/SideQuests.js

1833 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* SideQuests — the job board's model: the contracts posted on every
* system EXCEPT the player's home (safe space — the Explore drawer's
* own gate, data/actionbar.json → surface.explore.items), and the run's
* ledger of the contracts it has taken.
*
* LAYERING — the board is a VIEW over this (js/ui/JobBoardWindow.js):
* - the WINDOW renders sideQuestPostingsFor(seed, record, galaxy) (the
* current system's postings) and hands the player's intent to the
* scene (ACCEPT / SET ACTIVE / CLAIM REWARD);
* - the SCENE (GameScene.acceptSideQuest / setActiveSideQuest /
* claimSideQuest — the claimSurfaceQuest precedent: state + effects
* there, the note on the surface) lands them on the SideQuestState
* ledger (registry 'sidequests' — rides the saves like the quest
* ledger, SaveData capture/prepareLoad);
* - this module owns the CONTRACTS: which ones stand, what they say,
* what they pay, the PATROL's route (buildPatrolRoute +
* patrolTargetSystem), and the accepted/active/completed/claimed
* record — pure, seeded, Node-testable (dev/sidequests.test.mjs).
*
* CATEGORIES — the four job types (data/sidequests.json → categories):
* patrol / delivery / bounty / escort. Every category is ACCOUNTED FOR —
* its slot machinery here, its description pool + reward band in the
* data, the window's row + contract readout, the ledger, the accept
* seam — behind its OWN `enabled` gate, and the gate is the contract:
* a category's jobs appear on the board ONLY when its rules land and
* its flag flips. PATROL is the first live type (enabled:true) — a
* patrol's objective is its ROUTE (below); the other three stay
* OFFLINE until their rule sets land.
*
* PATROL — the route the player flies (data/sidequests.json → patrol):
* - TARGET — patrolTargetSystem: the route's system. Default the
* posting's own system; in a FACTION system at or beyond the
* diagonal mark (neighborMinD — the '40% from home' rule), a seeded
* draw (neighborChance) may stand a NEIGHBOR — a system the
* posting's jump gates reach (galaxy.jumpGatesFor) controlled by
* ANOTHER faction (the push-the-factions-out voice made a route).
* - ROUTE — buildPatrolRoute: N isolated waypoints ([min,max] in the
* data) as plain world coordinates, inside the caller's REGION (the
* player's unlocked tether regions when the target is the system
* they stand in; one tether region around each anchor when it is a
* system not yet unlocked — the scene's job to assemble), keeping
* anchorClearance from every anchor (planet / station / gate)
* and minSeparation apart (relaxed, never the region — a patrol
* must always be completable). Seeded on (seed, 'sidequests',
* <targetSystemId>, 'patrol', <jobId>, 'route') — same contract +
* region ⇒ same route, across re-accepts, scene restarts, saves.
* - ENCOUNTERS — the patrol's hostile CONTACTS (data →
* patrol.encounters): how many stand (counts — the odds: 1 on 20%
* of patrols, 2 on 20%, 3 on 60%), WHERE they stand (patrolEncounterPositions — the max-spread subset of the route's waypoints — the
* contacts spread across the route, never clustered), WHAT they fly
* (the band's roster — the player's LEVEL × the target's HOPS from
* home; today the low band, levels 1–5 at 1–2 hops, the established
* type only), and the fight's shape (the wave — one rolling wave of
* six, three at a time). derivePatrolEncounters rolls it all seeded
* at the accept (the labelled fork (seed, 'sidequests',
* <targetSystemId>, 'patrol', <jobId>, 'encounters')) and fixes it
* on the route record — a signed contract is a signed contract;
* patrolEncounterScript shapes the contact's wave script for
* CombatSystem.startWaves; the scene's updatePatrol fires the wave
* at the contact's waypoint and marks it cleared on the run's
* completion (the witness is the completion, not the attempt — the
* combatDone precedent), and patrolRouteComplete stands the
* contract: every waypoint logged AND every contact cleared.
* - LEDGER — SideQuestState carries the accepted order, the ONE
* active contract (reconcileActive: the top accepted contract
* stands active until it is done and claimed), the route record
* (waypoints + visited + the contract's payout — credits AND the
* XP reward, patrolEncounterXp), and the claimed marks. The
* scene's updatePatrol checks the ship in (visitRadius, any
* order), completes the contract, and pays the reward exactly
* once on claim (the credits + the XP, the claim's two payouts).
*
* DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") —
* postings derive from the galaxy seed + the system record through
* LABELLED forks: Rng.derive(seed, 'sidequests', <sysId>, …). The same
* contract stands on the same system for the whole run — across visits,
* scene restarts, and saves — and a different system draws its own (the
* variant pool keeps the galaxy's boards from reading as one repeated
* ad). Nothing here depends on call order.
*
* FLAVOR — the variant pools split by SYSTEM CONTEXT
* (data/sidequests.json → descriptions): NEUTRAL systems post about
* keeping their system safe from PIRATES; a FACTION's systems post about
* pushing the OTHER factions out of their space. The faction pools carry
* placeholders, resolved at generation (never stored): {system} = the
* system's name, {faction} = the system's faction label
* (data/factions.json), {issuer} = that faction's board name
* (data/sidequests.json → factions), {enemies} = the other two
* factions' labels ("MindWeave and Zenith").
*/
import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { Tether } from '../tether/Tether.js';
import { enemyTypeConfig, enemyTypeIds } from '../combat/EnemyTypes.js';
/** The four job types, canonical slot order (the data's category keys). */
export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort'];
/** The three player factions (data/factions.json minus 'neutral'). */
const FACTIONS = ['apex', 'mindweave', 'zenith'];
const clamp01 = (v) => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
/** The feature's master switch (data/sidequests.json → enabled). */
export function sideQuestsEnabled() {
return config.get('sidequests.enabled', true) !== false;
}
/**
* The postings-per-system roll range (data/sidequests.json →
* board.perSystem). A malformed range falls back to the design's 3–4.
* @returns {[number, number]} [min, max] (integers, min ≤ max)
*/
export function sideQuestCountRange() {
const raw = config.get('sidequests.board.perSystem', null);
if (!Array.isArray(raw) || raw.length < 2) return [3, 4];
let lo = Math.floor(Number(raw[0]));
let hi = Math.floor(Number(raw[1]));
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 4];
lo = Math.max(1, lo);
hi = Math.max(lo, hi);
return [lo, hi];
}
/** The Rng fork label under the galaxy seed (data/sidequests.json → board.seedLabel). */
function seedLabel() {
const s = String(config.get('sidequests.board.seedLabel', 'sidequests') || 'sidequests').trim();
return s || 'sidequests';
}
/**
* A category's meta (data/sidequests.json → categories.<id>).
*
* `minLevel` (the BOUNTY HUNT's gate — data → categories.bounty.minLevel)
* is the combat LEVEL the board demands (js/combat/XpManager.js): under
* it the posting still STANDS (its slot is seeded — a board that
* reshuffled as the player leveled would never settle), but the row
* renders LOCKED and the scene's accept refuses it. 1 = no gate
* (patrol's, and every category's, default). `contexts` (the BOUNTY
* HUNT's neutral-only posting) lists which boards post it — ['neutral'],
* ['faction'], or both (the default when a category says nothing).
*
* @returns {{id:string, enabled:boolean, label:string, accent:string, minLevel:number, contexts:string[]}|null} null when unknown
*/
export function sideQuestCategoryMeta(id) {
if (!SIDE_QUEST_CATEGORIES.includes(id)) return null;
const c = config.get(`sidequests.categories.${id}`, null);
if (!c || typeof c !== 'object') return null;
// An omitted minLevel demands NOTHING (0) — that is every legacy category,
// which stood open long before the hunt's gate existed. A fresh run's combat
// level is 0 (js/combat/XpManager.js), so a default of 1 would have rendered
// the standing PATROLS locked; only a category that SAYS so asks for a level.
const minLevel = Math.max(0, Math.floor(numOr(c.minLevel, 0)));
const contexts = (Array.isArray(c.contexts) ? c.contexts : [])
.map((v) => String(v).trim().toLowerCase())
.filter((v) => v === 'neutral' || v === 'faction');
return {
id,
enabled: c.enabled === true, // strict — a missing flag is OFFLINE (never fabricate jobs)
label: String(c.label ?? id.toUpperCase()),
accent: String(c.accent ?? '#00e5ff'),
minLevel,
contexts: contexts.length > 0 ? contexts : ['neutral', 'faction'],
};
}
/**
* The LIVE categories (enabled:true, canonical order). The board's gate:
* only these ever generate a posting — a category is accounted for but
* unavailable until its rules are built and its flag flipped.
*
* @param {string} [context] 'neutral' | 'faction' — when given, the
* category's own `contexts` list also gates it (a BOUNTY HUNT never
* stands on a faction board; omitting the argument is every category
* that says nothing about contexts, i.e. patrol unchanged)
* @returns {string[]}
*/
export function enabledSideQuestCategories(context = null) {
const ctx = context === 'neutral' || context === 'faction' ? context : null;
return SIDE_QUEST_CATEGORIES.filter((id) => {
const meta = sideQuestCategoryMeta(id);
if (!meta || meta.enabled !== true) return false;
return ctx === null || meta.contexts.includes(ctx);
});
}
/**
* Can this posting be ACCEPTED at this combat level? The gate is the
* category's `minLevel` (the scene's accept refuses with the data's deny
* toast, and the board renders the row LOCKED); a posting without a
* known level (a test, an old call) stands open.
* @param {object} posting sideQuestPostingsFor's shape
* @param {number|null} level the player's combat level
* @returns {{ok:boolean, locked:boolean, minLevel:number}}
*/
export function sideQuestAcceptable(posting, level = null) {
const meta = sideQuestCategoryMeta(posting?.category);
const minLevel = meta ? meta.minLevel : 0;
// UNKNOWN is not LEVEL 0. `null` / `undefined` (no XP bank in the boot, an
// older caller that never reads one) stands the contract OPEN — Number(null)
// is 0, which would otherwise lock every gated category on a missing read.
if (level === null || level === undefined || !Number.isFinite(Number(level))) {
return { ok: true, locked: false, minLevel };
}
const lvl = Math.floor(Number(level));
return { ok: lvl >= minLevel, locked: lvl < minLevel, minLevel };
}
/**
* Faction id → label (data/factions.json → factions), the three player
* factions only. A missing entry falls back to the id's own casing so a
* hand-edited table can't blank the board's copy.
* @returns {Record<string,string>}
*/
export function sideQuestFactionLabels() {
const table = config.get('factions.factions', null);
const out = {};
for (const id of FACTIONS) {
const entry = table && typeof table === 'object' ? table[id] : null;
const label = entry && typeof entry.label === 'string' && entry.label ? entry.label : id;
out[id] = label.charAt(0).toUpperCase() + label.slice(1);
}
return out;
}
/**
* Resolve the pool's placeholders in a line of copy (resolveSideQuestText's
* input context). {system} / {faction} / {issuer} / {enemies} — the
* neutral context leaves the faction-side keys empty (its pools don't
* carry them).
* @param {object} rec a system record ({ id, name, faction, … })
*/
function postingContext(rec) {
const system = String(rec?.name ?? rec?.id ?? 'this system');
const faction = typeof rec?.faction === 'string' && rec.faction !== '' ? rec.faction : null;
if (!faction) return { context: 'neutral', system, faction: '', issuer: '', enemies: '' };
const labels = sideQuestFactionLabels();
const label = labels[faction] ?? faction;
const issuer =
String(config.get(`sidequests.factions.${faction}`, '') || '').trim() || `${label} Authority`;
const others = FACTIONS.filter((f) => f !== faction).map((f) => labels[f] ?? f);
const enemies =
others.length === 2 ? `${others[0]} and ${others[1]}` : others.join(', ');
return { context: 'faction', system, faction: label, issuer, enemies };
}
/**
* The pool's placeholder resolution (the data's contract): {system},
* {faction}, {issuer}, {enemies} → the context's values. split/join (no
* $-quirks). A placeholder with no value in the context (a {faction} in
* a neutral pool — a data bug) resolves to '' rather than leaking its
* braces.
* @param {string} text the pool line
* @param {object} ctx the postingContext() shape
* @returns {string}
*/
export function resolveSideQuestText(text, ctx = null) {
if (typeof text !== 'string' || text === '') return '';
ctx = ctx && typeof ctx === 'object' ? ctx : {};
const map = {
system: ctx.system ?? '',
faction: ctx.faction ?? '',
issuer: ctx.issuer ?? '',
enemies: ctx.enemies ?? '',
};
let out = text;
for (const [key, value] of Object.entries(map)) {
if (!value) continue;
out = out.split(`{${key}}`).join(String(value));
}
// Any placeholder that still stands (no value for this context) is
// blanked — the board never ships a brace pair to the player.
out = out.replace(/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/g, '');
return out;
}
/**
* The posting's CREDITS payout (data/sidequests.json → rewards): the
* category's band [min,max] rolled seeded per posting, scaled OUT with
* the system's home→far diagonal d as (1 + d * distanceScale) — the
* far systems' boards pay more for the same risk (the economy's 'a hop
* is a hop' rule). A missing band falls back to [40,80]; the result is
* rounded to 5 CR, floored at 5 CR.
* @param {string} cat the category id
* @param {number} d the system's home→far diagonal (0..1)
* @param {Rng} r the posting's seeded stream
* @returns {{credits:number}}
*/
export function rollSideQuestReward(cat, d, r) {
let band = config.get(`sidequests.rewards.${cat}`, null);
if (!Array.isArray(band) || band.length < 2 || !Number.isFinite(Number(band[0])) || !Number.isFinite(Number(band[1]))) {
band = [40, 80];
}
const scale = 1 + clamp01(d) * (Number(config.get('sidequests.rewards.distanceScale', 0.75)) || 0);
const lo = Math.min(Number(band[0]), Number(band[1]));
const hi = Math.max(Number(band[0]), Number(band[1]));
const credits = Math.max(5, Math.round((r.range(lo, hi) * scale) / 5) * 5);
return { credits };
}
/**
* The patrol's TARGET system — the system the route will be built in.
* Default (returns null): the posting's own system. The NEIGHBOR rule
* (data/sidequests.json → patrol): in a FACTION system at or beyond the
* diagonal mark (neighborMinD — the design's '40% from home'), a seeded
* draw (neighborChance) may stand a system the posting's jump gates
* reach (galaxy.jumpGatesFor) controlled by ANOTHER faction — the
* push-them-out voice made a route. The draw rides the posting's own
* seeded stream, so the same posting stands with the same target for
* the whole run (visits, scene restarts, saves); no galaxy, or no
* other-faction link, falls back to the posting system — never a null
* target.
*
* @param {object} rec the posting's system record ({ id, faction, d, … })
* @param {Rng} r the posting's seeded stream
* @param {object|null} [galaxy] the galaxy (jumpGatesFor + byId) — null in tests
* @returns {{id:string, name:string}|null} null = the posting system stands
*/
export function patrolTargetSystem(rec, r, galaxy = null) {
const faction = typeof rec?.faction === 'string' ? rec.faction : '';
if (faction === '' || faction === 'neutral') return null; // neutral space patrols its own system
const minD = Number(config.get('sidequests.patrol.neighborMinD', 0.4));
if (!Number.isFinite(minD) || clamp01(Number(rec?.d)) < minD) return null; // the '40% from home' mark
const chance = Number(config.get('sidequests.patrol.neighborChance', 0.5));
if (!r.chance(clamp01(Number.isFinite(chance) ? chance : 0.5))) return null; // the seeded draw (the posting system stands)
if (!galaxy || typeof galaxy.jumpGatesFor !== 'function') return null; // no galaxy (a test) — the posting system stands
const cands = [];
let links = null;
try {
links = galaxy.jumpGatesFor(rec.id);
} catch {
links = null; // an unknown id (a hand-built test record) — the posting system stands
}
for (const nb of links ?? []) {
if (!nb || typeof nb.id !== 'string' || nb.id === rec.id) continue;
const nf = typeof nb.faction === 'string' ? nb.faction : '';
if (nf !== '' && nf !== 'neutral' && nf !== faction) cands.push(nb); // a NEIGHBOR controlled by ANOTHER faction
}
if (cands.length === 0) return null; // no other-faction link — the posting system stands
const nb = r.pick(cands);
return nb ? { id: nb.id, name: String(nb.name ?? nb.id) } : null;
}
/**
* The patrol's ROUTE — N isolated waypoints (data/sidequests.json →
* patrol.waypoints [min,max]) as plain world coordinates inside the
* target system. The REGION argument is the contract's flight envelope
* (the scene assembles it: the player's UNLOCKED tether regions when
* the target is the system they stand in; one tether region — a level-1
* radius — around each of the target's planets / stations / gates when
* it is a system not yet unlocked). Waypoints stay inside the region
* (a rim inset off the tether line), anchorClearance px clear of every
* ANCHOR (a planet / station / gate center — the scene's list) and
* minSeparation px apart; a tight field relaxes the clearance and
* separation (never the region) until it gives, and a field that gives
* nowhere stands the point at its region's heart — a patrol must always
* be completable.
*
* Seeded on its own labelled fork (seed, 'sidequests',
* <targetSystemId>, 'patrol', <jobId>, 'route') — the worldgen
* determinism contract: same seed + contract + region ⇒ same route,
* across re-accepts, scene restarts, and saves; nothing here depends on
* call order.
*
* @param {string|number} seed the galaxy's seed
* @param {string} jobId the accepted contract's id (the fork's key)
* @param {string} targetSystemId the route's system (the posting's target)
* @param {object[]} region [{ x, y, radius }] the flight envelope
* @param {object[]} [anchors] [{ x, y }] the keep-out centers (planets / stations / gates)
* @returns {{targetSystemId:string, waypoints:number[][], visited:number[]}}
*/
export function buildPatrolRoute(seed, jobId, targetSystemId, region, anchors = [], opts = {}) {
// The placement LAW is shared (region inset, anchor clearance, separation);
// `opts` lets another contract type stand its OWN leg count + fork label on
// top of it — a BOUNTY HUNT asks for the plan's exact leg count under its
// 'bounty' fork (GameScene._establishBountyHunt). No opts = patrol exactly
// as it always was.
const rawRange = Array.isArray(opts?.waypoints) && opts.waypoints.length > 0
? opts.waypoints
: config.get('sidequests.patrol.waypoints', [3, 5]);
let lo = Math.floor(Number(Array.isArray(rawRange) ? rawRange[0] : 3));
let hi = Math.floor(Number(Array.isArray(rawRange) ? rawRange[1] : 5));
if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
lo = 3;
hi = 5;
}
lo = Math.max(1, lo);
hi = Math.max(lo, hi);
const anchorClearance = Math.max(0, Number(config.get('sidequests.patrol.anchorClearance', 1100)) || 0);
const minSeparation = Math.max(0, Number(config.get('sidequests.patrol.minSeparation', 1500)) || 0);
const inset = Math.max(0, Number(config.get('sidequests.patrol.regionInset', 96)) || 0);
const regions = (Array.isArray(region) ? region : [])
.map((t) => ({ x: Number(t?.x), y: Number(t?.y), radius: Number(t?.radius) }))
.filter((t) => Number.isFinite(t.x) && Number.isFinite(t.y) && Number.isFinite(t.radius) && t.radius > 0);
if (regions.length === 0) {
// No usable region (a caller without tether data — a test, or a
// degenerate system): the system's heart at one tether's width, so
// the route still stands (a patrol must always be completable).
regions.push({ x: 0, y: 0, radius: Tether.radiusForLevel(1) });
}
const anchorPts = (Array.isArray(anchors) ? anchors : [])
.map((a) => ({ x: Number(a?.x), y: Number(a?.y) }))
.filter((a) => Number.isFinite(a.x) && Number.isFinite(a.y));
// The effective envelope: each region inset by the rim margin (a
// waypoint never sits on a tether's line). A region smaller than its
// inset gives up the inset rather than the region (kept at a 4 px
// floor so the sampler never weights a zero-disc).
const eff = regions.map((t) => ({ x: t.x, y: t.y, r: Math.max(4, t.radius - inset) }));
const areas = eff.map((t) => t.r * t.r); // the sampler's weights (disc area)
const inside = (x, y) => eff.some((t) => (x - t.x) ** 2 + (y - t.y) ** 2 <= t.r * t.r);
const fork = String(opts?.fork ?? 'patrol');
const r = Rng.derive(seed, seedLabel(), targetSystemId, fork, jobId, 'route');
const n = r.int(lo, hi);
const pickRegion = () => {
const total = areas.reduce((a, b) => a + b, 0);
let u = r.next() * total;
for (let i = 0; i < eff.length; i++) {
u -= areas[i];
if (u <= 0) return eff[i];
}
return eff[eff.length - 1];
};
const placed = [];
for (let i = 0; i < n; i++) {
let clear = anchorClearance;
let sep = minSeparation;
let pt = null;
for (let attempt = 0; attempt < 900 && !pt; attempt++) {
const t = pickRegion();
const rad = Math.sqrt(r.next()) * t.r; // uniform in the disc (the sqrt)
const ang = r.next() * Math.PI * 2;
const x = t.x + Math.cos(ang) * rad;
const y = t.y + Math.sin(ang) * rad;
if (!inside(x, y)) continue;
const okA = clear <= 0 || anchorPts.every((a) => Math.hypot(x - a.x, y - a.y) >= clear);
const okS = sep <= 0 || placed.every((p) => Math.hypot(x - p.x, y - p.y) >= sep);
if (okA && okS) pt = { x, y };
else if (attempt === 299 || attempt === 699) {
clear *= 0.5; // the field's giving — ease the clearance…
sep *= 0.5; // …and the separation (never the region)
}
}
if (!pt) {
// The field gave nowhere (a system of one crowded anchor): stand
// the point at the region's heart, half an inset off center on a
// seeded bearing — inside the envelope, best-effort isolated.
const t = pickRegion();
const ang = r.next() * Math.PI * 2;
const rad = Math.min(t.r * 0.5, Math.max(4, anchorClearance * 0.5));
pt = { x: t.x + Math.cos(ang) * rad, y: t.y + Math.sin(ang) * rad };
}
placed.push(pt);
}
return {
targetSystemId: String(targetSystemId),
waypoints: placed.map((p) => [Math.round(p.x), Math.round(p.y)]),
visited: [],
};
}
// ------------------------------------------------- the patrol's ENCOUNTERS
// The contract's hostile CONTACTS (data/sidequests.json → patrol.encounters):
// how many stand (counts), where they stand (positions — the max-spread
// subset of the route's waypoints), what they fly (the band's roster —
// the player's level × the target's hops from home), and the fight's shape
// (the wave — one rolling wave of six, three at a time). All pure and
// seeded — the scene's _establishPatrol rolls the spec at the accept and
// fixes it on the route record; the scene's updatePatrol fires it at the
// contact's waypoint and marks the clearance on the wave run's completion.
/** The established type (the base craft's named id) — the low band's roster and the bandless fallback alike. */
const ESTABLISHED_ENEMY_TYPES = ['raider'];
/** A finite number, else the fallback. */
function numOr(v, fb) {
const n = Number(v);
return Number.isFinite(n) ? n : fb;
}
/**
* The contact COUNT's odds (data/sidequests.json → patrol.encounters.counts
* — [count, odds] pairs; the design: 1 contact on 20% of patrols, 2 on
* 20%, 3 on 60%). A malformed table falls back to the design's own.
* @returns {Array<[number, number]>} sorted by count, positive weights
*/
export function patrolEncounterCounts() {
const raw = config.get('sidequests.patrol.encounters.counts', null);
const pairs = [];
if (Array.isArray(raw)) {
for (const [c, w] of raw) {
const count = Math.floor(numOr(c, NaN));
const weight = numOr(w, NaN);
if (Number.isFinite(count) && count >= 1 && Number.isFinite(weight) && weight > 0) pairs.push([count, weight]);
}
}
if (pairs.length === 0) return [[1, 0.2], [2, 0.2], [3, 0.6]];
return pairs.sort((a, b) => a[0] - b[0]);
}
/**
* A contact's WAVE shape (data/sidequests.json → patrol.encounters.wave —
* the design: one ROLLING wave of six, three at a time, each kill
* replaced — the combat.waves rolling mechanic, js/combat/Waves.js).
* @returns {{kind:string, total:number, maxConcurrent:number, replacePerKill:number}}
*/
export function patrolEncounterWave() {
const raw = config.get('sidequests.patrol.encounters.wave', null);
const w = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
return {
kind,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
replacePerKill: kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, 1))) : 0,
};
}
/**
* The standing band's ROSTER — the enemy types a patrol's contacts fly
* (data/sidequests.json → patrol.encounters.bands): the FIRST band in
* list order whose `levels` AND `hops` windows both contain the
* player's state — the difficulty's first axis (the player's COMBAT
* level × the target system's HOPS from home — the jump network's tree
* depth, the 'systems 1–2 hops out' rule). Today the low band — levels
* 1–5 at 1–2 hops from home — flies the ESTABLISHED type only (raider);
* the farther/higher bands — with the enemy types they introduce —
* append to the list when those types land. A roster's type ids are
* validated against combat.enemyTypes (js/combat/EnemyTypes.js) — an
* unknown id (a data typo) never ships; a roster that empties — or no
* band standing at all — takes the fallback (the established type).
*
* @param {number} level the player's combat level (data/progression.json — js/combat/XpManager.js)
* @param {number} hops the target system's jump-network depth from home (0 = home)
* @returns {string[]} the roster's type ids (never empty)
*/
export function patrolEnemyRoster(level, hops) {
const valid = new Set(enemyTypeIds());
const lvl = numOr(level, 0);
const hop = numOr(hops, 0);
const bands = config.get('sidequests.patrol.encounters.bands', null);
let pick = null;
if (Array.isArray(bands)) {
for (const b of bands) {
if (!b || typeof b !== 'object' || Array.isArray(b)) continue;
if (!Array.isArray(b.levels) || b.levels.length < 2 || !Array.isArray(b.hops) || b.hops.length < 2) continue;
const [lo, hi] = [numOr(b.levels[0], NaN), numOr(b.levels[1], NaN)];
const [ho, hh] = [numOr(b.hops[0], NaN), numOr(b.hops[1], NaN)];
if (![lo, hi, ho, hh].every(Number.isFinite)) continue;
if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi) && hop >= Math.min(ho, hh) && hop <= Math.max(ho, hh)) {
pick = b.enemyTypes; // the first standing band wins (list order)
break;
}
}
}
let raw = pick;
if (raw == null) raw = config.get('sidequests.patrol.encounters.fallbackEnemyTypes', null);
if (raw == null) raw = ESTABLISHED_ENEMY_TYPES;
const out = (Array.isArray(raw) ? raw : [raw])
.map((t) => String(t).trim())
.filter((t) => t !== '' && valid.has(t));
if (out.length > 0) return out;
return ESTABLISHED_ENEMY_TYPES.filter((t) => valid.has(t));
}
/**
* The contact's WAYPOINTS — the `count` of the route's `waypoints`
* (finite [x,y] pairs) the contacts stand on, SPREAD across the route
* (data/sidequests.json → patrol.encounters — POSITIONING): for count ≥
* 2, the most EVENLY SPREAD subset of the route's waypoints (scored by
* its MINIMUM pairwise separation — maximin — then its total: on a
* near-linear route that's the ends and the middle; a 3–5-point
* route's contacts never cluster); a single contact stands on the
* waypoint nearest the route's centroid (the patrol's
* middle). Pure in (waypoints, count) — deterministic, ties keep the
* lowest index order (the route itself is seeded, so the pair is stable
* for the run).
*
* @param {Array<[number, number]>} waypoints the route's waypoints
* @param {number} count the contacts' number (clamped to the route's length)
* @returns {number[]} the waypoint indices (distinct, ascending; [] for an empty route)
*/
export function patrolEncounterPositions(waypoints, count) {
const n = Array.isArray(waypoints) ? waypoints.length : 0;
if (n === 0) return [];
const k = Math.max(1, Math.min(Math.floor(numOr(count, 1)), n));
if (k === n) return waypoints.map((_, i) => i);
if (k === 1) {
// One contact: the patrol's MIDDLE — the waypoint nearest the
// route's centroid (the points' center of mass — the route is a set
// of points, not a path, so the centroid is its only 'middle').
let cx = 0, cy = 0, m = 0;
for (const w of waypoints) {
if (Array.isArray(w) && w.length >= 2 && Number.isFinite(w[0]) && Number.isFinite(w[1])) {
cx += w[0];
cy += w[1];
m++;
}
}
if (m === 0) return [0];
cx /= m;
cy /= m;
let best = 0;
let bestD = Infinity;
for (let i = 0; i < n; i++) {
const w = waypoints[i];
if (!Array.isArray(w) || w.length < 2) continue;
const d = (w[0] - cx) ** 2 + (w[1] - cy) ** 2;
if (d < bestD) {
bestD = d;
best = i;
}
}
return [best];
}
// k ≥ 2: every C(n, k) subset (n ≤ the route's 5 — a trivial space),
// scored for EVEN SPREAD: first the MINIMUM pairwise separation
// (maximin — the contacts are as far apart from each other as the
// route allows: on a near-linear route that's the ends and the middle,
// never a cluster), then the total pairwise separation, ties the
// lowest index order (the subsets enumerate in index order).
const dist = (a, b) => Math.hypot(waypoints[a][0] - waypoints[b][0], waypoints[a][1] - waypoints[b][1]);
const score = (idxs) => {
let min = Infinity;
let sum = 0;
for (let a = 0; a < idxs.length; a++) {
for (let b = a + 1; b < idxs.length; b++) {
const d = dist(idxs[a], idxs[b]);
if (d < min) min = d;
sum += d;
}
}
return { min, sum };
};
const combos = [];
const rec = (start, cur) => {
if (cur.length === k) {
combos.push(cur.slice());
return;
}
for (let i = start; i <= n - (k - cur.length); i++) rec(i + 1, [...cur, i]);
};
rec(0, []);
let best = null;
let bestMin = -1;
let bestSum = -1;
for (const c of combos) {
const s = score(c);
if (s.min > bestMin + 1e-9 || (Math.abs(s.min - bestMin) <= 1e-9 && s.sum > bestSum + 1e-9)) {
bestMin = s.min;
bestSum = s.sum;
best = c;
}
}
return best ?? [0];
}
/**
* The patrol's ENCOUNTERS — the contract's contact spec, rolled/derived
* at the accept (the scene's _establishPatrol) from the data's rules:
* the COUNT by its odds (patrolEncounterCounts — 1 on 20%, 2 on 20%, 3
* on 60%), the POSITIONS on the route's waypoints (patrolEncounter
* Positions — the max-spread subset, the single contact at the route's
* middle), the ROSTER from the standing band (patrolEnemyRoster — the
* player's level × the target's hops from home; today the low band's
* established type only), and the WAVE shape (patrolEncounterWave — one
* rolling wave of six, three at a time).
*
* Seeded on its own labelled fork (seed, 'sidequests',
* <targetSystemId>, 'patrol', <jobId>, 'encounters') — the worldgen
* contract: same seed + contract ⇒ the same encounters, across
* re-accepts, scene restarts, and saves — and the result is FIXED on
* the route record (SideQuestState.setRoute), so a later re-tune of
* the data's odds or bands can't re-roll a standing contract's combat.
*
* @param {string|number} seed the galaxy's seed
* @param {string} jobId the accepted contract's id (the fork's key)
* @param {string} targetSystemId the route's system (the fork's key)
* @param {Array<[number, number]>} waypoints the route's waypoints
* @param {number} level the player's combat level at the accept
* @param {number} hops the target system's jump-network depth from home
* @returns {?{at:number[], cleared:number[], roster:string[], wave:object}} null when the feature is off or the route is empty
*/
export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, level, hops) {
if (config.get('sidequests.patrol.encounters.enabled', true) === false) return null;
if (!Array.isArray(waypoints) || waypoints.length === 0) return null;
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', String(jobId), 'encounters');
const count = Math.max(1, Math.floor(numOr(r.weighted(patrolEncounterCounts(), 3), 3)));
return {
at: patrolEncounterPositions(waypoints, count),
cleared: [],
roster: patrolEnemyRoster(level, hops),
wave: patrolEncounterWave(),
};
}
/**
* A contact's WAVE SCRIPT (the shape for CombatSystem.startWaves —
* js/combat/CombatSystem.js; the same `{ waves: [...] }` shape the named
* scripts normalize to, js/combat/Waves.js): one wave of the contact's
* roster (equal weights — a single-type band is a pure type + total),
* the spec's wave shape (the data's: rolling six, three at a time). Pure
* — the scene's _firePatrolContact hands it to startWaves.
* @param {object} encounters the route record's encounters spec
* @returns {{waves: object[]}}
*/
export function patrolEncounterScript(encounters) {
const e = encounters && typeof encounters === 'object' && !Array.isArray(encounters) ? encounters : null;
const roster = (e?.roster && Array.isArray(e.roster) && e.roster.length > 0 ? e.roster : ESTABLISHED_ENEMY_TYPES)
.map((t) => String(t).trim())
.filter((t) => t !== '');
const w = { ...patrolEncounterWave(), ...(e?.wave && typeof e.wave === 'object' && !Array.isArray(e.wave) ? e.wave : {}) };
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
const wave = {
kind,
roster: Object.fromEntries(roster.map((t) => [t, 1])),
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
};
if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1)));
return { waves: [wave] };
}
/**
* The XP REWARD — the contract's SECOND payout (the claim pays it
* beside the credits): the experience WORTH of the patrol's defeated
* contacts. Completing the route requires every contact's wave to run
* to completion, so the enemies the player defeats during the quest
* ARE the contacts' waves — `at.length` × the wave's `total` — each
* enemy worth its type's `xp` (data/combat.json — the roster's weighted
* average; today's equal-weight specs are a plain average). Zero when
* the route carries no encounters (old saves, the feature off) — the
* reward degrades to credits-only, the route's waypoints alone
* completing it. Pure in the spec — it reads the SAME wave shape +
* roster patrolEncounterScript hands to the fight, so the reward and
* the combat can't drift apart (a re-tune of the bands re-tunes both).
*
* @param {object|null} encounters the route record's encounters spec
* @returns {number} the payout's XP (0 for a route without encounters)
*/
export function patrolEncounterXp(encounters) {
const e = encounters && typeof encounters === 'object' && !Array.isArray(encounters) ? encounters : null;
const at = e && Array.isArray(e.at) ? e.at.length : 0;
if (at === 0) return 0;
const wave = patrolEncounterScript(e).waves[0];
const total = Math.max(0, Math.floor(numOr(wave?.total, 0)));
if (total === 0) return 0;
const roster =
wave?.roster && typeof wave.roster === 'object' && !Array.isArray(wave.roster) ? wave.roster : null;
let weightSum = 0;
let xpWeighted = 0;
if (roster) {
for (const [typeId, w] of Object.entries(roster)) {
const weight = Math.max(0, Number(w) || 0);
if (weight === 0) continue;
weightSum += weight;
xpWeighted += weight * Math.max(0, Number(enemyTypeConfig(String(typeId))?.xp) || 0);
}
}
if (weightSum === 0) return 0;
return Math.round(at * total * (xpWeighted / weightSum));
}
/**
* A patrol's ROUTE is COMPLETE when every waypoint is LOGGED and every
* contact CLEARED (the contract's combat is part of the job — the
* reward's door opens on the last of the two). A route without
* encounters (a save predating the contacts — old saves load) completes
* on its waypoints alone.
* @param {object} route the route record (sideQuestState.routeFor's shape)
* @returns {boolean}
*/
export function patrolRouteComplete(route) {
if (!route || typeof route !== 'object') return false;
const wpCount = Array.isArray(route.waypoints) ? route.waypoints.length : 0;
const visited = Array.isArray(route.visited) ? route.visited.length : 0;
if (wpCount === 0 || visited < wpCount) return false;
const enc = route.encounters;
if (!enc || typeof enc !== 'object') return true;
const at = Array.isArray(enc.at) ? enc.at : [];
if (at.length === 0) return true;
const cleared = new Set(Array.isArray(enc.cleared) ? enc.cleared : []);
return at.every((i) => cleared.has(i));
}
// --------------------------------------------- the BOUNTY HUNT (category 'bounty')
// The contract with a NAME ON IT (data/sidequests.json → bounty). A hunt is
// a patrol with its ending hidden: three legs along one pirate leader's
// travel lanes, flown STRICTLY IN ORDER, and the mark stands on ONE of them
// (20% on the first leg, else 20% of what is left on the second, else the
// third at 100%). The rolls happen ONCE at the accept on their own labelled
// fork and ride the route record — a signed contract is a signed contract —
// and NOTHING here is spoken to the player before it fires (the plan object
// is model-side only; the board, the compass, and the beacons never read
// `bountyAt`).
//
// The scene's share (js/scenes/GameScene.js → _establishBountyHunt /
// updateBountyHunt) uses these pure pieces:
// bountyRules(level) — the standing band's numbers (the data's,
// with `inherits` resolved and fallbacks)
// deriveBountyPlan(…) — the rolled chain (bountyAt + per-leg pickets)
// bountySkirmishScript / bountyEngagementScript — CombatSystem.startWaves shapes
// bountyHuntXp(plan) — the claim's XP beside the credits
// nextBountyWaypoint(route) — the SEQUENTIAL check-in rule (one leg at a time)
// bountyRouteComplete(route) — the kill is the whole job
/** The elite's fallback frames (a dossier naming a craft that isn't in combat.enemyTypes). */
const BOUNTY_FALLBACK_FRAMES = [
'bomber',
'spitter',
'stinger',
'hunter',
'gunner',
'barrage',
'warden',
];
/**
* The wanted leader's OWN identity, carried on the plan (the dossier's name +
* epithet). The comms name the mark only once its fight stands in front of the
* ship — the plan itself never reads out where it hides — and because the mark
* rides the route record it survives a save. Empty strings when the board had
* no dossier (a hand-edited pool): the hunt still flies, silent.
*/
function bountyMark(raw) {
const m = raw && typeof raw === 'object' ? raw : {};
return {
name: String(m.name ?? '').trim(),
epithet: String(m.epithet ?? '').trim(),
};
}
/** One wave shape, clamped (a hunt's fight is never smaller than one hostile). */
function bountyWaveShape(raw, fb) {
const w = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
const kind = (w.kind ?? fb.kind) === 'fixed' ? 'fixed' : 'rolling';
const total = Math.max(1, Math.floor(numOr(w.total, fb.total)));
const out = {
kind,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, fb.maxConcurrent)))),
replacePerKill:
kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, fb.replacePerKill))) : 0,
};
if (Array.isArray(w.roster) && w.roster.length > 0) out.roster = w.roster.map(String);
if (w.raiderType != null) out.raiderType = String(w.raiderType);
if (w.raiders != null) out.raiders = Math.max(0, Math.floor(numOr(w.raiders, fb.raiders ?? 0)));
if (Array.isArray(w.eliteFrames) && w.eliteFrames.length > 0) out.eliteFrames = w.eliteFrames.map(String);
return out;
}
/**
* The standing band's RULES (data/sidequests.json → bounty + bounty.encounters).
* A BAND is a level window's tuning: the live 'hunter' band (levels 2–5) and
* the stubbed 'marksman' band (6+), which `inherits` the hunter's numbers so a
* player who outruns level 5 keeps getting hunts instead of losing the content
* mid-run. Resolution: the first band whose `levels` window holds the player's
* combat level, else the fallbackBand, else the first band; the chosen band's
* own overrides (bountyChance / skirmishChance / waypoints / skirmishWave /
* bountyWave) sit on top of the shared encounter block, and `inherits` pulls
* that band's numbers underneath them.
*
* @param {number} level the player's combat level (js/combat/XpManager.js)
* @returns {{id:string, legs:[number,number], visitRadius:number, bountyChance:number, skirmishChance:number, skirmishWave:object, bountyWave:object}}
*/
export function bountyRules(level = 0) {
const enc = config.get('sidequests.bounty.encounters', null);
const e = enc && typeof enc === 'object' && !Array.isArray(enc) ? enc : {};
const legRange = (() => {
const raw = config.get('sidequests.bounty.waypoints', [3, 3]);
let lo = Math.floor(numOr(Array.isArray(raw) ? raw[0] : 3, 3));
let hi = Math.floor(numOr(Array.isArray(raw) ? raw[1] : 3, 3));
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 3];
lo = Math.max(1, lo);
hi = Math.max(lo, hi);
return [lo, hi];
})();
const base = {
id: 'hunter',
legs: legRange,
visitRadius: Math.max(60, numOr(config.get('sidequests.bounty.visitRadius', 420), 420)),
bountyChance: Math.min(1, Math.max(0, numOr(e.bountyChance, 0.2))),
skirmishChance: Math.min(1, Math.max(0, numOr(e.skirmishChance, 0.5))),
skirmishWave: bountyWaveShape(e.skirmishWave, {
kind: 'rolling',
total: 6,
maxConcurrent: 3,
replacePerKill: 1,
roster: ['raider'],
}),
bountyWave: bountyWaveShape(e.bountyWave, {
kind: 'rolling',
total: 9,
raiders: 8,
maxConcurrent: 3,
replacePerKill: 1,
raiderType: 'raider',
eliteFrames: BOUNTY_FALLBACK_FRAMES.slice(),
}),
elite: {
healthScale: Math.max(1, numOr(e.bountyWave?.elite?.healthScale, 2)),
damageScale: Math.max(1, numOr(e.bountyWave?.elite?.damageScale, 2)),
},
enabled: e.enabled !== false,
};
// The elite's own scales ride the wave shape (the runner reads one object).
base.bountyWave.elite = { ...base.elite };
const bands = Array.isArray(e.bands) ? e.bands : [];
const byId = new Map();
for (const b of bands) {
if (b && typeof b === 'object' && !Array.isArray(b) && b.id != null) byId.set(String(b.id), b);
}
const lvl = numOr(level, 0);
let band = null;
for (const b of bands) {
if (!b || typeof b !== 'object' || Array.isArray(b)) continue;
const w = Array.isArray(b.levels) ? b.levels : null;
if (!w) continue;
const lo = numOr(w[0], NaN);
const hi = numOr(w[1] ?? w[0], lo);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi)) {
band = b; // the first window holding the level wins (list order)
break;
}
}
if (!band) band = byId.get(String(e.fallbackBand ?? '')) ?? bands[0] ?? null;
if (!band || typeof band !== 'object' || Array.isArray(band)) return base;
// The band's overrides, with `inherits` standing underneath them.
const layers = [];
const seen = new Set();
let cur = band;
while (cur && !seen.has(String(cur.id ?? ''))) {
seen.add(String(cur.id ?? ''));
layers.unshift(cur);
cur = cur.inherits != null ? byId.get(String(cur.inherits)) : null;
}
const out = { ...base, id: String(band.id ?? base.id) };
for (const layer of layers) {
if (Array.isArray(layer.waypoints)) {
const lo = Math.max(1, Math.floor(numOr(layer.waypoints[0], base.legs[0])));
const hi = Math.max(lo, Math.floor(numOr(layer.waypoints[1] ?? layer.waypoints[0], base.legs[1])));
out.legs = [lo, hi];
}
if (Number.isFinite(Number(layer.bountyChance))) out.bountyChance = Math.min(1, Math.max(0, Number(layer.bountyChance)));
if (Number.isFinite(Number(layer.skirmishChance))) out.skirmishChance = Math.min(1, Math.max(0, Number(layer.skirmishChance)));
if (Number.isFinite(Number(layer.visitRadius))) out.visitRadius = Math.max(60, Number(layer.visitRadius));
if (layer.skirmishWave) out.skirmishWave = bountyWaveShape(layer.skirmishWave, out.skirmishWave);
if (layer.bountyWave) {
out.bountyWave = bountyWaveShape(layer.bountyWave, out.bountyWave);
const sc = layer.bountyWave.elite || {};
out.bountyWave.elite = {
healthScale: Math.max(1, numOr(sc.healthScale, base.elite.healthScale)),
damageScale: Math.max(1, numOr(sc.damageScale, base.elite.damageScale)),
};
}
}
return out;
}
/**
* The hunt's PLAN — the hidden shape of one contract, rolled at the accept
* and FIXED on the route record (never re-drawn; never shown).
*
* The chain rides its own labelled fork (seed,'sidequests',
* <targetSystemId>,'bounty',<jobId>,'chain'): legs 1..N-1 each roll
* `bountyChance` for the mark and the LAST leg stands it at 100% — with the
* data's 0.2 and three legs that reads 20% / 16% / 64%. Each leg BEFORE the
* mark independently rolls `skirmishChance` for the leader's pickets (the
* bounty leg never does — the mark IS that fight, and no leg after it owes
* anything).
*
* @param {string|number} seed the galaxy's seed
* @param {string} jobId the accepted contract's id (the fork's key)
* @param {string} targetSystemId the hunt's system (the fork's key)
* @param {number} level the player's combat level at the accept (the band)
* @param {object} [opts] { craft, mark } — the dossier's declared craft (its own
* story's frame id); an id outside combat.enemyTypes falls back to a SEEDED
* pick from the band's eliteFrames, so a data typo stands a fight instead of
* breaking one. `mark` ({ name, epithet }) is the wanted leader's OWN name —
* carried on the plan so the fight's comms can say who it was AFTER the fact
* (never before: the plan itself stays invisible), and it rides the save.
* @returns {{legs:number, bountyAt:number, pickets:boolean[], band:string, raiders:number, eliteType:string, skirmishWave:object, bountyWave:object}}
*/
export function deriveBountyPlan(seed, jobId, targetSystemId, level = 0, opts = {}) {
const rules = bountyRules(level);
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'bounty', String(jobId), 'chain');
const legs = Math.max(1, r.int(rules.legs[0], rules.legs[1]));
let bountyAt = legs - 1; // the LAST leg stands the mark (the chain's guarantee)
for (let i = 0; i < legs - 1; i++) {
if (r.chance(rules.bountyChance)) {
bountyAt = i;
break;
}
}
const pickets = [];
for (let i = 0; i < legs - 1; i++) {
if (i >= bountyAt) {
pickets.push(false); // the mark's own leg, and anything after it, never rolls
continue;
}
pickets.push(r.chance(rules.skirmishChance));
}
const valid = new Set(enemyTypeIds());
const declared = String(opts?.craft ?? '').trim();
const frames = (Array.isArray(rules.bountyWave.eliteFrames) ? rules.bountyWave.eliteFrames : BOUNTY_FALLBACK_FRAMES)
.map((t) => String(t).trim())
.filter((t) => valid.has(t));
let eliteType = declared !== '' && valid.has(declared) ? declared : null;
if (!eliteType) {
const pool = frames.length > 0 ? frames : BOUNTY_FALLBACK_FRAMES.filter((t) => valid.has(t));
eliteType = pool.length > 0 ? String(r.pick(pool)) : ESTABLISHED_ENEMY_TYPES[0];
}
const raiderType = valid.has(String(rules.bountyWave.raiderType ?? ''))
? String(rules.bountyWave.raiderType)
: ESTABLISHED_ENEMY_TYPES[0];
const raiders = Math.max(0, Math.floor(numOr(rules.bountyWave.raiders, rules.bountyWave.total - 1)));
return {
legs,
bountyAt,
pickets,
band: rules.id,
raiders,
raiderType,
eliteType,
mark: bountyMark(opts?.mark),
skirmishWave: { ...rules.skirmishWave },
bountyWave: { ...rules.bountyWave, total: Math.max(1, raiders + 1) },
visitRadius: rules.visitRadius,
};
}
/** A hunt's picket wave — one rolling wave of the established type, three at a time. */
export function bountySkirmishScript(plan = {}) {
const w = plan?.skirmishWave || bountyRules(0).skirmishWave;
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
const type = ESTABLISHED_ENEMY_TYPES[0];
const wave = {
kind,
roster: { [type]: 1 },
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
};
if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1)));
return { waves: [wave] };
}
/**
* The BOUNTY's wave — one rolling wave of `raiders + 1`: the escort plus the
* mark itself, declared on the wave as `elite` so js/combat/Waves.js deploys
* it with the OPENING group and js/combat/EnemyShip.js doubles its hull /
* shields and weapon damage (the data's healthScale / damageScale). The kill
* is the contract: the scene halts the run (CombatSystem.haltWaves) and waits
* out the survivors.
*/
export function bountyEngagementScript(plan = {}) {
const rules = bountyRules(0);
const w = plan?.bountyWave || rules.bountyWave;
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
const eliteType = String(plan?.eliteType ?? 'raider');
const raiders = Math.max(0, Math.floor(numOr(plan?.raiders, Math.max(0, numOr(w.total, 9) - 1))));
const raiderType = String(w.raiderType ?? ESTABLISHED_ENEMY_TYPES[0]);
const total = Math.max(1, raiders + 1); // the escort + exactly one mark
const roster = {};
if (raiders > 0) roster[raiderType] = raiders;
roster[eliteType] = (roster[eliteType] ?? 0) + 1;
const wave = {
kind,
roster,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
elite: {
type: eliteType,
healthScale: numOr(w.elite?.healthScale, rules.elite.healthScale),
damageScale: numOr(w.elite?.damageScale, rules.elite.damageScale),
// The name the KILL reads out (js/combat/Waves.js keeps it; EnemyShip
// tags it; GameScene's witness puts it in the comms line).
name: String(plan?.mark?.name ?? '').trim(),
},
};
if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1)));
return { waves: [wave] };
}
/**
* The hunt's XP payout — the worth of everything it throws at the ship: the
* mark (its own type's worth × its scale-up) plus the escort plus every
* rolled picket wave. Fixed at the accept beside the credits, so the claim
* pays what the plan promised even if the data is re-tuned mid-run.
*/
export function bountyHuntXp(plan = {}) {
const p = plan && typeof plan === 'object' ? plan : {};
const xpOf = (t) => Math.max(0, Number(enemyTypeConfig(String(t))?.xp) || 0);
const rules = bountyRules(0);
const raiders = Math.max(0, Math.floor(numOr(p.raiders, numOr(rules.bountyWave.raiders, 8))));
const raiderType = p.raiderType != null ? String(p.raiderType) : String(rules.bountyWave.raiderType ?? 'raider');
const eliteType = String(p.eliteType ?? ESTABLISHED_ENEMY_TYPES[0]);
const scale = numOr(p.bountyWave?.elite?.healthScale, rules.bountyWave.elite?.healthScale ?? 2);
let xp = raiders * xpOf(raiderType) + xpOf(eliteType) * Math.max(1, scale);
const skirmishTotal = Math.max(0, Math.floor(numOr(p.skirmishWave?.total, rules.skirmishWave.total)));
const pickets = (Array.isArray(p.pickets) ? p.pickets : []).filter(Boolean).length;
xp += pickets * skirmishTotal * xpOf(ESTABLISHED_ENEMY_TYPES[0]);
return Math.round(xp);
}
/**
* The SEQUENTIAL check-in rule — the hunt's one departure from patrol law.
* The chain's odds read an ORDER, so a hunt logs its legs one at a time: the
* NEXT leg is the lowest index not yet logged, and only that leg's beacon and
* compass point anywhere. `null` when every leg stands logged.
*
* @param {object} route the route record
* @returns {?{index:number, x:number, y:number, leg:number, of:number}}
*/
export function nextBountyWaypoint(route) {
if (!route || typeof route !== 'object') return null;
const wp = Array.isArray(route.waypoints) ? route.waypoints : [];
if (wp.length === 0) return null;
const visited = new Set(Array.isArray(route.visited) ? route.visited : []);
for (let i = 0; i < wp.length; i++) {
const w = wp[i];
if (!Array.isArray(w) || w.length < 2) continue;
if (visited.has(i)) continue;
return { index: i, x: Number(w[0]), y: Number(w[1]), leg: i + 1, of: wp.length };
}
return null;
}
/**
* The hunt is COMPLETE when the mark is DOWN (the contract's only witness —
* the legs are a formality the kill checks off at once). A malformed or
* missing bounty record falls back to the patrol law (every leg logged) so a
* degraded save can still finish.
*/
export function bountyRouteComplete(route) {
if (!route || typeof route !== 'object') return false;
const b = route.bounty && typeof route.bounty === 'object' ? route.bounty : null;
if (b?.down === true) return true;
if (!b) return patrolRouteComplete(route);
return false;
}
/**
* A bounty posting's DOSSIER (the wanted list's fields — data →
* descriptions.neutral.bounty[]). Resolved for the readout, with the craft
* VALIDATED against combat.enemyTypes and the copy placeholder-free. Returns
* null when the pool entry isn't a dossier (a hand-edited pool) — the posting
* still stands on its title/description alone.
*/
function buildBountyDossier(v, ctx) {
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
const name = resolveSideQuestText(String(v.name ?? ''), ctx).trim();
if (name === '') return null;
const valid = new Set(enemyTypeIds());
const craft = String(v.craft ?? '').trim();
return {
name,
epithet: resolveSideQuestText(String(v.epithet ?? ''), ctx).trim(),
crime: resolveSideQuestText(String(v.crime ?? ''), ctx).trim(),
craft: valid.has(craft) ? craft : null, // null = the plan picks a frame (seeded)
craftLabel: resolveSideQuestText(String(v.craftLabel ?? ''), ctx).trim(),
};
}
/**
* One line of the hunt's UI copy (data/sidequests.json → ui.<key>) with its
* `{placeholders}` filled. A key can stand as a plain string or as
* `{ glyph, color, text }` (the toasts carry their own glyph + color); an
* unknown key falls back to `fb` so a data typo degrades to the design's own
* words instead of blanking a toast. Placeholders with no value blank out.
* @returns {{glyph:string,color:string,text:string}|null} null when neither the key nor a fallback stands
*/
function bountyUiLine(key, vars = {}, fb = '') {
const raw = config.get(`sidequests.ui.${key}`, null);
const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
const text = obj ? String(obj.text ?? '') : typeof raw === 'string' ? raw : '';
if (text === '' && fb === '') return null;
let out = text === '' ? String(fb) : text;
for (const [k, value] of Object.entries(vars)) out = out.split(`{${k}}`).join(String(value));
out = out.replace(/\{[a-zA-Z]+\}/g, ''); // an unfilled placeholder never ships
return {
glyph: obj ? String(obj.glyph ?? '') : '',
color: obj ? String(obj.color ?? '') : '',
text: out,
};
}
/** The bounty gate's combat level (data → categories.bounty.minLevel; the design's 2). */
export function bountyMinLevel() {
return sideQuestCategoryMeta('bounty')?.minLevel ?? 2;
}
/** A locked row's badge (“LOCKED — REQUIRES LEVEL 2”); `sub` = the detail line under it. */
export function bountyLockCopy(sub = false) {
const level = bountyMinLevel();
return sub
? bountyUiLine('bountyLockSub', { level }, 'BRING YOUR RECORD UP TO LEVEL {level}')
: bountyUiLine('bountyLockBadge', { level }, 'LOCKED — REQUIRES LEVEL {level}');
}
/** The scene's refusal when a locked contract is pressed anyway. */
export function bountyDenyCopy() {
return bountyUiLine('bountyAcceptDenyToast', { level: bountyMinLevel() }, 'COMMISSION REFUSED — LEVEL {level} REQUIRED');
}
/** A hunt line's copy (a leg logged / pickets inbound / the mark sighted / down / complete). */
export function bountyText(key, vars = {}) {
return bountyUiLine(key, vars, '');
}
/** The readout's progress line (logged legs only — never what waits on them). */
export function bountyProgressCopy(route) {
const wp = Array.isArray(route?.waypoints) ? route.waypoints : [];
const visited = Array.isArray(route?.visited) ? new Set(route.visited).size : 0;
return bountyUiLine('bountyRouteProgress', { visited, total: wp.length }, 'WAYPOINT {visited} OF {total} LOGGED');
}
/** The contract readout's dossier lines (wanted / epithet / charges / craft). */
export function bountyDossierCopy(dossier) {
const d = dossier && typeof dossier === 'object' ? dossier : null;
if (!d || !d.name) return [];
const craftLine = bountyUiLine('dossierCraft', { craft: d.craftLabel ?? '' }, 'CRAFT — {craft}');
const lines = [
bountyUiLine('dossierWanted', { name: d.name }, 'WANTED — {name}'),
d.epithet ? bountyUiLine('dossierEpithet', { epithet: d.epithet }, '{epithet}') : null,
bountyUiLine('dossierCrime', { crime: d.crime ?? '' }, 'CHARGES — {crime}'),
d.craftLabel ? craftLine : null,
];
return lines.filter((l) => l && l.text !== '');
}
/**
* A bounty route record's PLAN, normalized (SideQuestState.setRoute's pass).
* The rolled shape is VALIDATED, never re-drawn: `bountyAt` inside the leg
* count, one `pickets` mark per leg with nothing standing on the mark's own
* leg or behind it, the elite a real combat.enemyTypes id, the wave shapes
* finite, and the runtime marks (`down`, the cleared picket legs) kept. A
* malformed record degrades to null — the route's waypoints still stand, the
* hunt simply never fires (a hand-edited save must not manufacture a fight).
*
* @param {*} raw deriveBountyPlan's result (or a save's)
* @param {number} wpCount the route's waypoint count
* @returns {?object}
*/
function normalizeBountyPlan(raw, wpCount) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const legs = Math.max(1, Math.floor(numOr(raw.legs, wpCount)));
const bountyAt = Math.floor(numOr(raw.bountyAt, NaN));
if (!Number.isInteger(bountyAt) || bountyAt < 0 || bountyAt >= legs) return null;
const valid = new Set(enemyTypeIds());
const eliteType = String(raw.eliteType ?? '').trim();
if (!valid.has(eliteType)) return null;
// The pickets: one mark per leg BEFORE the mark (the chain's rule — the leg
// that holds the hunt, and every leg behind it, never rolls).
const pickets = [];
for (let i = 0; i < legs - 1; i++) {
pickets.push(i < bountyAt && raw.pickets?.[i] === true);
}
const raiders = Math.max(0, Math.floor(numOr(raw.raiders, 8)));
const raiderType = valid.has(String(raw.raiderType ?? '').trim())
? String(raw.raiderType).trim()
: ESTABLISHED_ENEMY_TYPES[0];
const rules = bountyRules(0);
const mark = bountyMark(raw.mark);
const sw = raw.skirmishWave && typeof raw.skirmishWave === 'object' ? raw.skirmishWave : {};
const bw = raw.bountyWave && typeof raw.bountyWave === 'object' ? raw.bountyWave : {};
const el = bw.elite && typeof bw.elite === 'object' ? bw.elite : {};
return {
legs,
bountyAt,
pickets,
band: String(raw.band ?? 'hunter'),
raiders,
raiderType,
eliteType,
mark,
skirmishWave: bountyWaveShape(sw, rules.skirmishWave),
bountyWave: {
...bountyWaveShape({ ...bw, total: Math.max(1, raiders + 1) }, rules.bountyWave),
raiderType,
raiders,
elite: {
healthScale: Math.max(1, numOr(el.healthScale, 2)),
damageScale: Math.max(1, numOr(el.damageScale, 2)),
},
},
visitRadius: Math.max(60, numOr(raw.visitRadius, rules.visitRadius)),
// The runtime marks (the scene's witness marks — a fresh plan carries none).
down: raw.down === true,
skirmishCleared: [...new Set(
(Array.isArray(raw.skirmishCleared) ? raw.skirmishCleared : []).filter(
(i) => Number.isInteger(i) && i >= 0 && i < bountyAt && raw.pickets?.[i] === true,
),
)].sort((a, b) => a - b),
};
}
/**
* The system's LIVE postings (the board's list) — 3–4 by design
* (sideQuestCountRange), drawn from the LIVE categories only
* (enabledSideQuestCategories): distinct categories while they last,
* then repeats into the variant pools (the same category can hold more
* than one posting once the count outruns the live set).
*
* Deterministic in (seed, record[, galaxy]) — the labelled forks under
* Rng.derive(seed, seedLabel, <sysId>) make the roll, the category
* order, and every per-posting draw (the patrol's target included) independent
* of call order, so the board stands the same across visits, scene
* restarts, and saves.
*
* A posting's shape (the window's row + detail + the scene's accept):
* { id, systemId, systemName, faction (null = neutral), category,
* label, accent, title, issuer, description, reward: { credits },
* objective: { type } } — objective is the typed socket the
* category's completion rules fill in when the type lands. A PATROL
* posting additionally carries targetSystemId / targetSystemName (the
* route's system — patrolTargetSystem; default the posting's own
* system).
*
* @param {string|number} seed the galaxy's seed
* @param {object} rec a system record ({ id, name, faction, d, … })
* @param {object|null} [galaxy] the galaxy (the patrol's neighbor rule's jumpGatesFor) — null in tests
* @returns {object[]} [] when the feature is off or no category is live
*/
export function sideQuestPostingsFor(seed, rec, galaxy = null, level = null) {
if (!sideQuestsEnabled()) return [];
if (!rec || typeof rec.id !== 'string' || rec.id === '') return [];
const ctx = postingContext(rec);
const cats = enabledSideQuestCategories(ctx.context); // a faction board never draws a neutral-only type
if (cats.length === 0) return [];
const [lo, hi] = sideQuestCountRange();
const base = Rng.derive(seed, seedLabel(), rec.id);
const count = base.int(lo, hi);
const order = base.shuffle(cats); // the system's slot order (seeded, stable)
const out = [];
const usedDossier = new Set(); // one board never posts the SAME wanted leader twice
for (let slot = 0; slot < count; slot++) {
const cat = order[slot % order.length];
const r = Rng.derive(seed, seedLabel(), rec.id, cat, slot); // per-posting stream
const posting = buildPosting(seed, rec, ctx, cat, slot, r, galaxy, usedDossier);
if (!posting) continue;
if (cat === 'bounty' && posting.dossier?.name) usedDossier.add(posting.dossier.name);
// The level gate is PRESENTATION + ACCEPT, never generation: the
// posting stands in its seeded slot below the gate too (a locked
// row is still the same row), and the scene refuses the accept.
const gate = sideQuestAcceptable(posting, level);
posting.locked = gate.locked === true;
posting.minLevel = gate.minLevel;
out.push(posting);
}
return out;
}
/** One posting from its pool variant + reward roll (null on a bad pool entry). */
function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier = null) {
const meta = sideQuestCategoryMeta(cat);
if (!meta) return null;
const poolPath = `sidequests.descriptions.${ctx.context}.${cat}`;
let pool = config.get(poolPath, null);
if (!Array.isArray(pool)) pool = config.get(`sidequests.descriptions.neutral.${cat}`, null); // the neutral voice as fallback
pool = Array.isArray(pool) ? pool : [];
let v = pool.length > 0 ? pool[r.int(0, pool.length - 1)] : null;
// A WANTED LIST never posts the same leader twice on one board: the seeded
// draw stands when it is fresh, otherwise the next entry forward (wrapping)
// takes it. Deterministic — the slots are visited in a fixed order and the
// used set is built by this loop alone, so a system's board never depends on
// call order or on any other system.
if (cat === 'bounty' && pool.length > 0 && usedDossier && usedDossier.size > 0) {
for (let step = 0; step < pool.length; step++) {
const cand = pool[(pool.indexOf(v) + step + pool.length) % pool.length];
if (!cand?.name || !usedDossier.has(cand.name)) {
v = cand;
break;
}
}
}
const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, ctx);
// The row's issuer line: the pool's own name (the neutral pools' —
// “The Harbormaster”), or the faction's board name (the faction pools'
// {issuer} placeholder — the data's sidequests.factions table), so a
// faction's postings read “ISSUER — ZENITH CONCORD”, not “The Board”.
const issuerRaw = v?.issuer ?? (ctx.issuer || 'The Board');
const issuer = resolveSideQuestText(issuerRaw, ctx) || 'The Board';
const description = resolveSideQuestText(v?.body ?? '', ctx);
return {
id: `sq-${rec.id}-${cat}-${slot}`, // stable for the run (save-safe ledger key)
systemId: rec.id,
systemName: String(rec.name ?? rec.id),
faction: ctx.context === 'faction' ? rec.faction : null,
category: cat,
label: meta.label,
accent: meta.accent,
title,
issuer,
description,
reward: rollSideQuestReward(cat, rec.d, r),
// The completion socket: the type's objective spec (its steps, its
// witness, its payout rule) is built WITH the type and rides here —
// the UI already renders objective.text when one stands.
objective: { type: cat },
// The PATROL's target system — the route's system (patrolTargetSystem:
// the posting system, or a NEIGHBOR the jump gates reach when the
// system's faction, diagonal, and seeded draw agree). Patrol-only —
// the other categories' routes (when they land) carry their own
// shape.
...(cat === 'patrol' ? patrolTargetFields(rec, r, galaxy) : {}),
// The BOUNTY HUNT's DOSSIER (data → descriptions.neutral.bounty[]) — the
// wanted leader's name/epithet/charges and the CRAFT its own story names
// (`craft`, validated against combat.enemyTypes; null on a pool entry that
// doesn't declare one, where the plan picks a frame seeded). The hunt's
// plan (which leg holds the mark) is NOT here — it rolls at the accept and
// rides the route record, never the board.
...(cat === 'bounty' ? { dossier: buildBountyDossier(v, ctx), targetSystemId: rec.id, targetSystemName: String(rec.name ?? rec.id) } : {}),
};
}
/** The patrol posting's target fields (its own system, or the neighbor's). */
function patrolTargetFields(rec, r, galaxy) {
const t = patrolTargetSystem(rec, r, galaxy);
return t
? { targetSystemId: t.id, targetSystemName: t.name }
: { targetSystemId: rec.id, targetSystemName: String(rec.name ?? rec.id) };
}
/**
* The seed's JOB SPACE — every id the ledger could lawfully hold: each
* system × the full slot range (the range's max — a superset of what any
* enabled set can post) × all four categories. Used by SideQuestState.
* fromJSON to drop ledger ids the save's galaxy never posted (a
* hand-edited or foreign ledger degrades instead of sticking).
*
* @param {object[]} records the galaxy's system records (Galaxy.records)
* @returns {Set<string>}
*/
export function allSideQuestJobIds(records) {
const set = new Set();
if (!Array.isArray(records)) return set;
const [, hi] = sideQuestCountRange();
for (const rec of records) {
if (!rec || typeof rec.id !== 'string' || rec.id === '') continue;
for (let slot = 0; slot < hi; slot++) {
for (const cat of SIDE_QUEST_CATEGORIES) {
set.add(`sq-${rec.id}-${cat}-${slot}`);
}
}
}
return set;
}
/**
* The run's SIDE QUEST ledger (pure; save-ready): which posted contracts
* the player has ACCEPTED (in the order taken), which is the ONE ACTIVE
* contract being flown, which of the taken contracts have COMPLETED,
* which of those have been CLAIMED, and the PATROL route record (its
* target system, its waypoints, its visited marks). The ids are
* postings' ids (sq-<sysId>-<cat>-<slot>) — the postings themselves
* regenerate from the seed, so the save carries only the marks (a save =
* seed + player state).
*
* Invariants (the scene's flows lean on them):
* - completed ⊆ accepted, claimed ⊆ completed;
* - accept / complete / claim are idempotent (a double-press or a
* re-open can't double-take, double-pay, or re-open a contract);
* - ONE active contract at a time (activeId) — reconcileActive keeps
* a standing active valid, and otherwise stands the TOP accepted
* contract (acceptedOrder's head) that is not done + claimed; null
* when no contract is out;
* - a save predating the feature (no field) loads an empty ledger
* (old saves load); ids the save's galaxy never posted are dropped,
* not propagated; a route record only for a contract this ledger
* holds (a route for a foreign id degrades away).
*/
/**
* A route record's ENCOUNTERS spec, normalized (SideQuestState.setRoute's
* pass): the at/cleared indices valid for the route (distinct, sorted;
* cleared ⊆ at), the roster's type ids (combat.enemyTypes — unknown ids
* dropped, the established type standing when the roster empties), the
* wave shape finite. A malformed or missing spec degrades to null (an
* encounter-free route — the waypoints still stand the contract), so a
* hand-edited save or a future data shape can't break the patrol.
* @param {*} raw the raw spec (derivePatrolEncounters' result, or a save's)
* @param {number} wpCount the route's waypoint count
* @returns {?{at:number[], cleared:number[], roster:string[], wave:object}}
*/
function normalizePatrolEncounters(raw, wpCount) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const at = [...new Set(
(Array.isArray(raw.at) ? raw.at : []).filter(
(i) => Number.isInteger(i) && i >= 0 && i < wpCount,
),
)].sort((a, b) => a - b);
if (at.length === 0) return null; // no contact stands (the route's waypoints do)
const cleared = [...new Set(
(Array.isArray(raw.cleared) ? raw.cleared : []).filter(
(i) => Number.isInteger(i) && at.includes(i),
),
)].sort((a, b) => a - b);
const validTypes = new Set(enemyTypeIds());
const roster = (Array.isArray(raw.roster) ? raw.roster : [])
.map((t) => String(t).trim())
.filter((t) => t !== '' && validTypes.has(t));
const wave = patrolEncounterWave();
const w = raw.wave && typeof raw.wave === 'object' && !Array.isArray(raw.wave) ? raw.wave : {};
const kind = w.kind === 'fixed' ? 'fixed' : wave.kind;
const total = Math.max(1, Math.floor(numOr(w.total, wave.total)));
const out = {
at,
cleared,
roster: roster.length > 0 ? roster : ESTABLISHED_ENEMY_TYPES.slice(),
wave: {
kind,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, wave.maxConcurrent)))),
},
};
if (kind === 'rolling') out.wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, wave.replacePerKill)));
return out;
}
export class SideQuestState {
constructor() {
this.accepted = new Set();
this.acceptedOrder = []; // the order taken (the active slot's "top")
this.completed = new Set(); // completed ⊆ accepted
this.claimed = new Set(); // claimed ⊆ completed
this.activeId = null; // the ONE contract being flown (taken, not done, not claimed)
this.routes = new Map(); // id → { targetSystemId, credits, xp, waypoints, visited, encounters }
}
/**
* Take the contract (idempotent — re-accepting is a no-op).
* @returns {boolean} true when NEWLY accepted
*/
accept(id) {
if (typeof id !== 'string' || id === '') return false;
if (this.accepted.has(id)) return false;
this.accepted.add(id);
this.acceptedOrder.push(id);
return true;
}
isAccepted(id) {
return this.accepted.has(id);
}
/** The taken order (the ledger's "top" is index 0). */
acceptedList() {
return [...this.acceptedOrder];
}
/**
* Mark a taken contract COMPLETE (the type's objective stood — the
* scene's call when a category lands; only ACCEPTED contracts can
* complete — completed ⊆ accepted). A done contract drops out of the
* ACTIVE slot (reconcileActive — the next top one stands).
* @returns {boolean} true when NEWLY completed
*/
complete(id) {
if (typeof id !== 'string' || id === '') return false;
if (!this.accepted.has(id)) return false;
if (this.completed.has(id)) return false;
this.completed.add(id);
this.reconcileActive();
return true;
}
isCompleted(id) {
return this.completed.has(id);
}
/**
* Mark a completed contract CLAIMED (the reward is paid exactly once —
* idempotent; claimed ⊆ completed, so an uncompleted contract can't
* be claimed). A claimed contract drops out of the ACTIVE slot.
* @returns {boolean} true when NEWLY claimed
*/
claim(id) {
if (typeof id !== 'string' || id === '') return false;
if (!this.completed.has(id)) return false;
if (this.claimed.has(id)) return false;
this.claimed.add(id);
this.reconcileActive();
return true;
}
isClaimed(id) {
return this.claimed.has(id);
}
isActive(id) {
return this.activeId === id;
}
/**
* Stand the ACTIVE contract — the ONE being flown. It must be taken,
* not done, not claimed (the board's door only offers it in that state
* anyway — this is the belt-and-braces pass).
* @returns {boolean} true when it stands
*/
setActive(id) {
if (typeof id !== 'string' || id === '') return false;
if (!this.accepted.has(id) || this.completed.has(id) || this.claimed.has(id)) return false;
this.activeId = id;
return true;
}
/**
* The ACTIVE slot's truth: a standing active (taken, not done, not
* claimed) is kept; otherwise the TOP accepted contract (acceptedOrder's
* head — "if no quest is active and the player has accepted quests, the
* top accepted stands") that is not done + claimed takes the slot; null
* when no contract is out.
* @returns {string|null}
*/
reconcileActive() {
if (
this.activeId &&
this.accepted.has(this.activeId) &&
!this.completed.has(this.activeId) &&
!this.claimed.has(this.activeId)
) {
return this.activeId;
}
this.activeId =
this.acceptedOrder.find((id) => !this.completed.has(id) && !this.claimed.has(id)) ?? null;
return this.activeId;
}
// ------------------------------------------------- the patrol's ROUTE
/**
* Stand a contract's ROUTE record (the patrol's waypoints + the
* contract's ENCOUNTERS — the scene's buildPatrolRoute +
* derivePatrolEncounters results, at the accept). Only a TAKEN
* contract can carry a route (a route for a foreign id degrades away);
* the record is validated + normalized (finite waypoint pairs, visited
* indices in range, no duplicates; the encounters' at/cleared indices
* valid for the route, the roster the data's type ids, the wave shape
* finite — a malformed spec degrades to an encounter-free route rather
* than a broken one). @returns {boolean} true when it stands
*/
setRoute(id, route) {
if (typeof id !== 'string' || id === '') return false;
if (!this.accepted.has(id)) return false;
if (!route || typeof route !== 'object') return false;
const waypoints = (Array.isArray(route.waypoints) ? route.waypoints : [])
.filter((w) => Array.isArray(w) && w.length >= 2 && Number.isFinite(w[0]) && Number.isFinite(w[1]))
.map((w) => [Math.round(w[0]), Math.round(w[1])]);
const visited = [...new Set(
(Array.isArray(route.visited) ? route.visited : []).filter(
(i) => Number.isInteger(i) && i >= 0 && i < waypoints.length,
),
)];
const kind = route.kind === 'bounty' ? 'bounty' : 'patrol';
const encounters = normalizePatrolEncounters(route.encounters, waypoints.length);
const bounty = kind === 'bounty' ? normalizeBountyPlan(route.bounty, waypoints.length) : null;
this.routes.set(id, {
// The route's TYPE — 'patrol' (the default: every patrol, and every
// save written before the hunts existed) or 'bounty' (a hunt: SEQUENTIAL
// legs + the hidden plan). The type is what branches the scene's update;
// a patrol record normalizes exactly as it always did.
kind,
targetSystemId: String(route.targetSystemId ?? ''),
credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's CREDITS payout — fixed at the accept
// The contract's XP payout (the claim's second reward — beside the
// credits): the accept fixes it on the record (patrolEncounterXp,
// the scene's _establishPatrol); a route that stands without one
// (an old save) derives it from its own spec — the same contract,
// a signed contract is a signed contract.
xp:
Number.isFinite(Number(route.xp)) && Number(route.xp) >= 0
? Number(route.xp)
: kind === 'bounty'
? bountyHuntXp(bounty)
: patrolEncounterXp(encounters),
waypoints,
visited,
encounters,
bounty,
});
return true;
}
/**
* Mark a contract's contact CLEARED (the wave run completed — the
* scene's _firePatrolContact's onComplete). Idempotent — the run's
* completion fires once; a re-fire (a mid-fight quit re-arms the
* contact — the witness is the completion, not the attempt) marks it
* again without a second effect. A cleared contact's index is always a
* route waypoint index (validated). @returns {boolean} true when NEWLY cleared
*/
encounterCleared(id, idx) {
const route = this.routes.get(id);
if (!route || !route.encounters) return false;
if (!Number.isInteger(idx) || !route.encounters.at.includes(idx)) return false;
if (route.encounters.cleared.includes(idx)) return false;
route.encounters.cleared.push(idx);
return true;
}
/**
* A contract's route record (the scene's check-ins + the board's route
* line + the claim's payout read it).
* @returns {{kind:string, targetSystemId:string, credits:number, xp:number, waypoints:number[][], visited:number[], encounters:?object, bounty:?object}|null}
*/
routeFor(id) {
return this.routes.get(id) ?? null;
}
/**
* Check off a waypoint (idempotent — the scene's per-frame pass may
* see the ship inside the radius on several frames; only the FIRST
* logs). @returns {boolean} true when NEWLY logged
*/
visitWaypoint(id, idx, opts = {}) {
const route = this.routes.get(id);
if (!route) return false;
if (!Number.isInteger(idx) || idx < 0 || idx >= route.waypoints.length) return false;
if (route.visited.includes(idx)) return false;
// A HUNT flies its legs IN ORDER — the chain's odds read a first, second
// and third leg — so only the NEXT leg logs, unless the caller is the
// kill's own check-off (`opts.anyOrder`: the mark came down and what is
// left of the lane is signed off with it). A patrol keeps its any-order law.
if (route.kind === 'bounty' && opts.anyOrder !== true) {
const next = nextBountyWaypoint(route);
if (!next || next.index !== idx) return false;
}
route.visited.push(idx);
return true;
}
/**
* A hunt's PICKET wave cleared (the leg's raid wave ran to completion — the
* witness is the completion, not the attempt, exactly as a patrol contact).
* Only a leg that ROLLED a picket can clear. @returns {boolean} true when NEWLY cleared
*/
bountySkirmishCleared(id, idx) {
const route = this.routes.get(id);
const b = route?.bounty;
if (!b || !Number.isInteger(idx) || idx < 0 || idx >= (b.pickets?.length ?? 0)) return false;
if (b.pickets[idx] !== true) return false;
if (b.skirmishCleared.includes(idx)) return false;
b.skirmishCleared.push(idx);
return true;
}
/**
* THE MARK IS DOWN — the hunt's completion witness (the kill, then every
* survivor of its escort put down). It sets the plan's `down` mark and
* checks off EVERY remaining leg in the same breath (a hunt ends on the
* kill, not on the flying), so bountyRouteComplete stands from here and no
* further leg fires. Idempotent — a double witness marks once.
* @returns {boolean} true when NEWLY witnessed
*/
bountyDown(id) {
const route = this.routes.get(id);
const b = route?.bounty;
if (!b) return false;
for (let i = 0; i < route.waypoints.length; i++) {
if (!route.visited.includes(i)) route.visited.push(i);
}
if (b.down === true) return false;
b.down = true;
return true;
}
toJSON() {
return {
accepted: [...this.acceptedOrder],
completed: [...this.completed],
claimed: [...this.claimed],
activeId: this.activeId,
routes: Object.fromEntries(this.routes),
};
}
/**
* Restore a ledger from a save record. A malformed record yields an
* empty ledger rather than a throw (contracts are a progression
* nicety, not a crash vector — the QuestState precedent). With a
* `known` job space (allSideQuestJobIds of the restored galaxy) only
* ids inside it survive — a foreign or hand-edited id degrades. The
* active slot is restored through reconcileActive: a standing saved
* active is kept, else the top accepted stands (the save's order).
* @param {object} data the record.sidequests shape
* @param {Set<string>|null} [known] the save's galaxy job space
*/
static fromJSON(data, known = null) {
const st = new SideQuestState();
if (!data || typeof data !== 'object') return st;
const list = (arr) => (Array.isArray(arr) ? arr.filter((x) => typeof x === 'string' && x !== '') : []);
const ok = (id) => (known ? known.has(id) : true);
for (const id of list(data.accepted)) {
if (!ok(id) || st.accepted.has(id)) continue;
st.accepted.add(id);
st.acceptedOrder.push(id);
}
for (const id of list(data.completed)) {
if (!ok(id) || !st.accepted.has(id) || st.completed.has(id)) continue; // completed ⊆ accepted
st.completed.add(id);
}
for (const id of list(data.claimed)) {
if (!ok(id) || !st.completed.has(id) || st.claimed.has(id)) continue; // claimed ⊆ completed
st.claimed.add(id);
}
const routes = data.routes && typeof data.routes === 'object' && !Array.isArray(data.routes) ? data.routes : null;
if (routes) {
for (const [id, raw] of Object.entries(routes)) {
if (!ok(id) || !st.accepted.has(id)) continue; // a route only for a contract this ledger holds
st.setRoute(id, raw);
}
}
if (typeof data.activeId === 'string' && data.activeId !== '') st.activeId = data.activeId;
st.reconcileActive(); // a standing saved active is kept; else the top accepted stands; else null
return st;
}
}