/** * 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', * , 'patrol', , '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', * , 'patrol', , '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', , …). 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.). * * `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} */ 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', * , 'patrol', , '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', * , 'patrol', , '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', * ,'bounty',,'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.) 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, ) 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} */ 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---) — 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|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; } }