/** * SideQuests — the job board's model: the contracts posted on every * SURFACE WORLD (each planet and free-space station) EXCEPT in the * player's home system (safe space — the Explore drawer's own gate, * data/actionbar.json → surface.explore.items), and the run's ledger * of the contracts it has taken. A world's board stands in that world * (the board is a VIEW over the postings — two planets in one system * post two different boards); the CONTRACTS themselves still fly in * the system's space (a patrol's route is the system's, as always). * * LAYERING — the board is a VIEW over this (js/ui/JobBoardWindow.js): * - the WINDOW renders sideQuestPostingsFor(seed, record, worldKey, * galaxy) (the current WORLD's postings) and hands the player's * intent to the scene (ACCEPT / SET ACTIVE / CLAIM REWARD); * - the SCENE (GameScene.acceptSideQuest / setActiveSideQuest / * claimSideQuest — the claimSurfaceQuest precedent: state + effects * there, the note on the surface) lands them on the SideQuestState * ledger (registry 'sidequests' — rides the saves like the quest * ledger, SaveData capture/prepareLoad); * - this module owns the CONTRACTS: which ones stand, what they say, * what they pay, the PATROL's route (buildPatrolRoute + * patrolTargetSystem), and the accepted/active/completed/claimed * record — pure, seeded, Node-testable (dev/sidequests.test.mjs). * * CATEGORIES — the four job types (data/sidequests.json → categories): * patrol / delivery / bounty / escort. Every category is ACCOUNTED FOR — * its slot machinery here, its description pool + reward band in the * data, the window's row + contract readout, the ledger, the accept * seam — behind its OWN `enabled` gate, and the gate is the contract: * a category's jobs appear on the board ONLY when its rules land and * its flag flips. PATROL is the first live type (enabled:true) — a * patrol's objective is its ROUTE (below); the other three stay * OFFLINE until their rule sets land. * * PATROL — the route the player flies (data/sidequests.json → patrol): * - TARGET — patrolTargetSystem: the route's system. Default the * posting's own system; in a FACTION system at or beyond the * diagonal mark (neighborMinD — the '40% from home' rule), a seeded * draw (neighborChance) may stand a NEIGHBOR — a system the * posting's jump gates reach (galaxy.jumpGatesFor) controlled by * ANOTHER faction (the push-the-factions-out voice made a route). * - ROUTE — buildPatrolRoute: N isolated waypoints ([min,max] in the * data) as plain world coordinates, inside the caller's REGION (the * player's unlocked tether regions when the target is the system * they stand in; one tether region around each anchor when it is a * system not yet unlocked — the scene's job to assemble), keeping * anchorClearance from every anchor (planet / station / gate) * and minSeparation apart (relaxed, never the region — a patrol * must always be completable). Seeded on (seed, 'sidequests', * , '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; the tiers: the low band (levels 1–5, 1–2 hops) the established * type only, the level-6 jump a weighted mix (spitter + bomber beside * the raiders) in a fatter wave, the higher tiers wider mixes — see * data/sidequests.json → patrol.encounters.bands), and the fight's shape * (the wave — the shared rolling shape, a band's `wave` overriding it — * the tiers grow it, capped at 20 deep / six at a time). derivePatrolEncounters rolls it all seeded * at the accept (the labelled fork (seed, 'sidequests', * , '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, IN ORDER * — the route is a path flown 1, 2, 3, …), completes the * contract, and pays the reward exactly * once on claim (the credits + the XP, the claim's two payouts). * * DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") — * postings derive from the galaxy seed + the system record + the WORLD * through LABELLED forks: Rng.derive(seed, 'sidequests', , * , …). The same contract stands on the same WORLD for the whole * run — across visits, scene restarts, and saves — and a different world * (another planet in the same system, another system) draws its own (the * variant pool keeps the galaxy's boards from reading as one repeated * ad). Nothing here depends on call order. * * FLAVOR — the variant pools split by SYSTEM CONTEXT * (data/sidequests.json → descriptions): NEUTRAL systems post about * keeping their system safe from PIRATES; a FACTION's systems post about * pushing the OTHER factions out of their space. The faction pools carry * placeholders, resolved at generation (never stored): {system} = the * system's name, {faction} = the system's faction label * (data/factions.json), {issuer} = that faction's board name * (data/sidequests.json → factions), {enemies} = the other two * factions' labels ("MindWeave and Zenith"). */ import { config } from '../config/Config.js'; import { Rng } from '../utils/Rng.js'; import { Tether } from '../tether/Tether.js'; import { enemyTypeConfig, enemyTypeIds, factionTypeIds } from '../combat/EnemyTypes.js'; /** The four job types, canonical slot order (the data's category keys). */ export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort']; /** The three player factions (data/factions.json minus 'neutral'). */ const FACTIONS = ['apex', 'mindweave', 'zenith']; const clamp01 = (v) => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0)); /** The feature's master switch (data/sidequests.json → enabled). */ export function sideQuestsEnabled() { return config.get('sidequests.enabled', true) !== false; } /** * The postings-per-world roll range (data/sidequests.json → * board.perWorld). A malformed range falls back to the design's 1–3. * @returns {[number, number]} [min, max] (integers, min ≤ max) */ export function sideQuestCountRange() { const raw = config.get('sidequests.board.perWorld', null); if (!Array.isArray(raw) || raw.length < 2) return [1, 3]; let lo = Math.floor(Number(raw[0])); let hi = Math.floor(Number(raw[1])); if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [1, 3]; lo = Math.max(1, lo); hi = Math.max(lo, hi); return [lo, hi]; } /** The Rng fork label under the galaxy seed (data/sidequests.json → board.seedLabel). */ function seedLabel() { const s = String(config.get('sidequests.board.seedLabel', 'sidequests') || 'sidequests').trim(); return s || 'sidequests'; } /** * A category's meta (data/sidequests.json → categories.). * * `minLevel` (the BOUNTY HUNT's gate — data → categories.bounty.minLevel) * is the combat LEVEL the board demands (js/combat/XpManager.js): under * it the posting still STANDS (its slot is seeded — a board that * reshuffled as the player leveled would never settle), but the row * renders LOCKED and the scene's accept refuses it. 1 = no gate * (patrol's, and every category's, default). `contexts` (the BOUNTY * HUNT's neutral-only posting) lists which boards post it — ['neutral'], * ['faction'], or both (the default when a category says nothing). * * @returns {{id:string, enabled:boolean, label:string, accent:string, minLevel:number, contexts:string[]}|null} null when unknown */ export function sideQuestCategoryMeta(id) { if (!SIDE_QUEST_CATEGORIES.includes(id)) return null; const c = config.get(`sidequests.categories.${id}`, null); if (!c || typeof c !== 'object') return null; // An omitted minLevel demands NOTHING (0) — that is every legacy category, // which stood open long before the hunt's gate existed. A fresh run's combat // level is 0 (js/combat/XpManager.js), so a default of 1 would have rendered // the standing PATROLS locked; only a category that SAYS so asks for a level. const minLevel = Math.max(0, Math.floor(numOr(c.minLevel, 0))); const contexts = (Array.isArray(c.contexts) ? c.contexts : []) .map((v) => String(v).trim().toLowerCase()) .filter((v) => v === 'neutral' || v === 'faction'); return { id, enabled: c.enabled === true, // strict — a missing flag is OFFLINE (never fabricate jobs) label: String(c.label ?? id.toUpperCase()), accent: String(c.accent ?? '#00e5ff'), minLevel, contexts: contexts.length > 0 ? contexts : ['neutral', 'faction'], }; } /** * The LIVE categories (enabled:true, canonical order). The board's gate: * only these ever generate a posting — a category is accounted for but * unavailable until its rules are built and its flag flipped. * * @param {string} [context] 'neutral' | 'faction' — when given, the * category's own `contexts` list also gates it (a BOUNTY HUNT never * stands on a faction board; omitting the argument is every category * that says nothing about contexts, i.e. patrol unchanged) * @returns {string[]} */ export function enabledSideQuestCategories(context = null) { const ctx = context === 'neutral' || context === 'faction' ? context : null; return SIDE_QUEST_CATEGORIES.filter((id) => { const meta = sideQuestCategoryMeta(id); if (!meta || meta.enabled !== true) return false; return ctx === null || meta.contexts.includes(ctx); }); } /** * Can this posting be ACCEPTED at this combat level? The gate is the * category's `minLevel` (the scene's accept refuses with the data's deny * toast, and the board renders the row LOCKED); a posting without a * known level (a test, an old call) stands open. * @param {object} posting sideQuestPostingsFor's shape * @param {number|null} level the player's combat level * @returns {{ok:boolean, locked:boolean, minLevel:number}} */ /** * Can this posting be ACCEPTED at this combat level? Two gates: the * category's `minLevel` (the board's own demand), and the posting's * `gateLevel` — the GATE ACCESS level for the contract's target system * (bounty/escort in a hostile neighbor: the player's jump-gate capability * must be unlocked INTO that system before the contract stands open; the * pure rule is gateAccessLevel above). The scene's accept refuses with * the data's deny toast, and the board renders the row LOCKED; a posting * without a known level (a test, an old call) stands open. * @param {object} posting sideQuestPostingsFor's shape * @param {number|null} level the player's combat level * @returns {{ok:boolean, locked:boolean, minLevel:number, gateLevel:number}} */ export function sideQuestAcceptable(posting, level = null) { const meta = sideQuestCategoryMeta(posting?.category); const minLevel = meta ? meta.minLevel : 0; const gateLevel = Math.max(0, Math.floor(Number(posting?.gateLevel) || 0)); const floor = Math.max(minLevel, gateLevel); // UNKNOWN is not LEVEL 0. `null` / `undefined` (no XP bank in the boot, an // older caller that never reads one) stands the contract OPEN — Number(null) // is 0, which would otherwise lock every gated category on a missing read. if (level === null || level === undefined || !Number.isFinite(Number(level))) { return { ok: true, locked: false, minLevel, gateLevel }; } const lvl = Math.floor(Number(level)); return { ok: lvl >= floor, locked: lvl < floor, minLevel, gateLevel }; } /** * Faction id → label (data/factions.json → factions), the three player * factions only. A missing entry falls back to the id's own casing so a * hand-edited table can't blank the board's copy. * @returns {Record} */ export function sideQuestFactionLabels() { const table = config.get('factions.factions', null); const out = {}; for (const id of FACTIONS) { const entry = table && typeof table === 'object' ? table[id] : null; const label = entry && typeof entry.label === 'string' && entry.label ? entry.label : id; out[id] = label.charAt(0).toUpperCase() + label.slice(1); } return out; } /** * Resolve the pool's placeholders in a line of copy (resolveSideQuestText's * input context). {system} / {faction} / {issuer} / {enemies} — the * neutral context leaves the faction-side keys empty (its pools don't * carry them). * @param {object} rec a system record ({ id, name, faction, … }) */ function postingContext(rec) { const system = String(rec?.name ?? rec?.id ?? 'this system'); const faction = typeof rec?.faction === 'string' && rec.faction !== '' ? rec.faction : null; if (!faction) return { context: 'neutral', system, faction: '', issuer: '', enemies: '' }; const labels = sideQuestFactionLabels(); const label = labels[faction] ?? faction; const issuer = String(config.get(`sidequests.factions.${faction}`, '') || '').trim() || `${label} Authority`; const others = FACTIONS.filter((f) => f !== faction).map((f) => labels[f] ?? f); const enemies = others.length === 2 ? `${others[0]} and ${others[1]}` : others.join(', '); return { context: 'faction', system, faction: label, issuer, enemies }; } /** * The pool's placeholder resolution (the data's contract): {system}, * {faction}, {issuer}, {enemies} → the context's values. split/join (no * $-quirks). A placeholder with no value in the context (a {faction} in * a neutral pool — a data bug) resolves to '' rather than leaking its * braces. * @param {string} text the pool line * @param {object} ctx the postingContext() shape * @returns {string} */ export function resolveSideQuestText(text, ctx = null) { if (typeof text !== 'string' || text === '') return ''; ctx = ctx && typeof ctx === 'object' ? ctx : {}; const map = { system: ctx.system ?? '', faction: ctx.faction ?? '', issuer: ctx.issuer ?? '', enemies: ctx.enemies ?? '', enemy: ctx.enemy ?? '', }; let out = text; for (const [key, value] of Object.entries(map)) { if (!value) continue; out = out.split(`{${key}}`).join(String(value)); } // Any placeholder that still stands (no value for this context) is // blanked — the board never ships a brace pair to the player. out = out.replace(/\{system\}|\{faction\}|\{issuer\}|\{enemies\}|\{enemy\}/g, ''); return out; } /** * The posting's CREDITS payout (data/sidequests.json → rewards): the * category's band [min,max] rolled seeded per posting, scaled OUT with * the system's home→far diagonal d as (1 + d * distanceScale) — the * far systems' boards pay more for the same risk (the economy's 'a hop * is a hop' rule). A missing band falls back to [40,80]; the result is * rounded to 5 CR, floored at 5 CR. * @param {string} cat the category id * @param {number} d the system's home→far diagonal (0..1) * @param {Rng} r the posting's seeded stream * @returns {{credits:number}} */ export function rollSideQuestReward(cat, d, r) { let band = config.get(`sidequests.rewards.${cat}`, null); if (!Array.isArray(band) || band.length < 2 || !Number.isFinite(Number(band[0])) || !Number.isFinite(Number(band[1]))) { band = [40, 80]; } const scale = 1 + clamp01(d) * (Number(config.get('sidequests.rewards.distanceScale', 0.75)) || 0); const lo = Math.min(Number(band[0]), Number(band[1])); const hi = Math.max(Number(band[0]), Number(band[1])); const credits = Math.max(5, Math.round((r.range(lo, hi) * scale) / 5) * 5); return { credits }; } /** * The patrol's TARGET system — the system the route will be built in. * WAR MODEL (current): the patrol is ALWAYS the posting's own system — * the board's own space, cleaned of the sighted rival's fighters (the * enemy is ONE seeded opposing faction — the posting's `enemyFaction`, * never both, never a pirate). Returns null = the posting system stands. * (The old neighbor rule — a seeded draw could stand a gate-reachable * other-faction system — is gone: the sighted faction is the enemy, the * posting's system is the ground.) * * @param {object} rec the posting's system record ({ id, faction, d, … }) * @param {Rng} r the posting's seeded stream (unused — the rule is fixed) * @param {object|null} [galaxy] the galaxy (unused — kept for the call sites) * @returns {{id:string, name:string}|null} null = the posting system stands (always) */ export function patrolTargetSystem(rec, r, galaxy = null) { return null; } // ------------------------------------------------------------ the war model // // The faction-board's three contracts share the same geography: the // board's own system (friendly) and the NEIGHBORING systems it controls // through its jump gates — the hostile ground (the two opposing // factions' systems a gate-hop away). Patrol fights the sighted rival // at home; the bounty and the escort stand on that hostile ground. // Everything here is seeded on the posting's own stream, so the same // posting holds the same enemy / ground for the whole run (accepts, // scene restarts, saves). /** The two OPPOSING factions (the FACTIONS roster minus the controller). */ export function opposingFactions(faction) { const f = String(faction ?? '').trim(); if (f === '') return []; return FACTIONS.filter((x) => x !== f); } /** * One SEEDED opposing faction (the patrol's sighted enemy, the bounty's * target's faction when the board names it, …): a draw over the two * rivals on the posting's own stream — deterministic per posting. * @param {string} faction the controller (a roster id) * @param {Rng} r the posting's seeded stream * @returns {string|null} a rival id, or null (a non-roster controller / no stream) */ export function seededOpposingFaction(faction, r) { const opp = opposingFactions(faction); if (opp.length === 0) return null; if (!r || typeof r.int !== 'function') return opp[0]; return opp[r.int(0, opp.length - 1) % opp.length]; } /** * The posting's HOSTILE GROUND — a NEIGHBORING system (a jump-gate link, * galaxy.jumpGatesFor) controlled by ONE of the two opposing factions * (the bounty's mark's home, the escort's trapped transport's ground), * picked seeded on the posting's stream. Null when the controller is * unknown, there is no galaxy (a test), or no rival holds a gate-reachable * neighbor — the board then leaves that slot EMPTY (a contract with no * ground is never offered). * @param {object} rec the posting's system record * @param {Rng} r the posting's seeded stream * @param {object|null} [galaxy] the galaxy (jumpGatesFor) — null in tests * @returns {?{id:string, name:string, faction:string}} */ export function hostileNeighborSystem(rec, r, galaxy = null) { const faction = typeof rec?.faction === 'string' ? rec.faction : ''; if (faction === '' || faction === 'neutral') return null; if (!galaxy || typeof galaxy.jumpGatesFor !== 'function') return null; const opp = new Set(opposingFactions(faction)); let links = null; try { links = galaxy.jumpGatesFor(rec.id); } catch { links = null; } const cands = []; for (const nb of links ?? []) { if (!nb || typeof nb.id !== 'string' || nb.id === rec.id) continue; const nf = typeof nb.faction === 'string' ? nb.faction : ''; if (opp.has(nf)) cands.push({ id: nb.id, name: String(nb.name ?? nb.id), faction: nf }); } if (cands.length === 0) return null; // no rival holds a gate-reachable neighbor if (!r || typeof r.int !== 'function') return cands[0]; return cands[r.int(0, cands.length - 1) % cands.length]; } /** * The GATE ACCESS LEVEL — the combat level the player must hold before a * contract whose TARGET is a faction-controlled system can be accepted * (data/gates.json → jump.factionLock). The rule is TARGET-DRIVEN: any * faction-controlled target demands the lock's minLevel, because reaching * it is a gate hop the player must be capable of — whether the board that * posts it stands in neutral space (the classic neutral→faction hop) or in * a rival faction's space (the war-model bounty/escort, whose mark's home / * transport's ground is a hostile faction system). A NEUTRAL target is never * locked. This is the bounty/escort lock: the posting stands greyed until * the player's gate capability reaches the target's system. * @param {object} rec the ISSUING system record * @param {object} target the target system record * @returns {number} the level that opens the gate (0 = no lock stands) */ export function gateAccessLevel(rec, target) { const neutral = (x) => { const f = x?.faction; return f == null || f === '' || f === 'neutral'; }; if (target == null || typeof target !== 'object') return 0; if (neutral(target)) return 0; // a NEUTRAL target — no gate lock stands const enabled = config.get('gates.jump.factionLock.enabled', true); if (enabled === false) return 0; const minLevel = Number(config.get('gates.jump.factionLock.minLevel', 5)); return Number.isFinite(minLevel) ? minLevel : 5; } /** * The patrol's ROUTE — N isolated waypoints (data/sidequests.json → * patrol.waypoints [min,max]) as plain world coordinates inside the * target system. The REGION argument is the contract's flight envelope * (the scene assembles it: the player's UNLOCKED tether regions when * the target is the system they stand in; one tether region — a level-1 * radius — around each of the target's planets / stations / gates when * it is a system not yet unlocked). Waypoints stay inside the region * (a rim inset off the tether line), anchorClearance px clear of every * ANCHOR (a planet / station / gate center — the scene's list) and * minSeparation px apart; a tight field relaxes the clearance and * separation (never the region) until it gives, and a field that gives * nowhere stands the point at its region's heart — a patrol must always * be completable. * * Seeded on its own labelled fork (seed, 'sidequests', * , '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; } /** * A wave's ROSTER as a type → weight table (Waves.js's composition * contract — {raider: 2, bomber: 1} + total 6 is a 2:1 mix of six * hostiles). From a data shape: a weighted `{ type: weight }` object, an * array of type ids (equal weight — the legacy bands' `enemyTypes` shape), * or null. Ids are validated against combat.enemyTypes (js/combat/ * EnemyTypes.js) — an unknown id (a data typo) never ships; a roster that * empties yields `fb`. * @param {object|string[]|string|null} raw the data's roster shape * @param {?object} fb the fallback table (null = "nothing stands") * @returns {?Record} */ export function rosterWeights(raw, fb = null) { const valid = new Set(enemyTypeIds()); const out = {}; if (Array.isArray(raw)) { for (const t of raw) { const k = String(t).trim(); if (k !== '' && valid.has(k)) out[k] = (out[k] ?? 0) + 1; } } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) { for (const [t, w] of Object.entries(raw)) { const k = String(t).trim(); const wt = Math.max(0, Number(w) || 0); if (k !== '' && valid.has(k) && wt > 0) out[k] = (out[k] ?? 0) + wt; } } if (Object.keys(out).length > 0) return out; return fb; } /** The established type's weighted table (every fallback's floor). */ function establishedRoster() { return rosterWeights(ESTABLISHED_ENEMY_TYPES, { [ESTABLISHED_ENEMY_TYPES[0]]: 1 }); } /** * The standing PATROL band (data/sidequests.json → patrol.encounters * .bands): the FIRST band in list order whose `levels` AND `hops` windows * both contain the player's state (the difficulty's axis — the player's * COMBAT level × the target system's HOPS from home). A band may carry its * own `roster` (weighted type mix), `wave` (the contact's fight shape) * and `counts` (the contact-count odds) — each overriding the shared * encounter block. Null when no band stands (the shared defaults + the * fallback roster then rule). * @param {number} level the player's combat level * @param {number} hops the target system's jump-network depth from home * @returns {?object} */ export function patrolEncounterBand(level, hops) { const bands = config.get('sidequests.patrol.encounters.bands', null); if (!Array.isArray(bands)) return null; const lvl = numOr(level, 0); const hop = numOr(hops, 0); for (const b of bands) { if (!b || typeof b !== 'object' || Array.isArray(b)) continue; if (!Array.isArray(b.levels) || b.levels.length < 2 || !Array.isArray(b.hops) || b.hops.length < 2) continue; const [lo, hi] = [numOr(b.levels[0], NaN), numOr(b.levels[1], NaN)]; const [ho, hh] = [numOr(b.hops[0], NaN), numOr(b.hops[1], NaN)]; if (![lo, hi, ho, hh].every(Number.isFinite)) continue; if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi) && hop >= Math.min(ho, hh) && hop <= Math.max(ho, hh)) { return b; // the first standing band wins (list order) } } return null; } /** The contact COUNT's odds table parsed — positive [count, weight] pairs sorted by count, or null. */ function patrolCountsTable(raw) { const pairs = []; if (Array.isArray(raw)) { for (const [c, w] of raw) { const count = Math.floor(numOr(c, NaN)); const weight = numOr(w, NaN); if (Number.isFinite(count) && count >= 1 && Number.isFinite(weight) && weight > 0) pairs.push([count, weight]); } } if (pairs.length === 0) return null; return pairs.sort((a, b) => a[0] - b[0]); } /** * The contact COUNT's odds (data/sidequests.json → patrol.encounters.counts * — [count, odds] pairs; the design: 1 contact on 20% of patrols, 2 on * 20%, 3 on 60%). A standing band's own `counts` overrides the shared table * (the higher tiers skew the odds toward every waypoint carrying a * contact). A malformed table falls back to the design's own. * @param {number} [level] the player's combat level (the band's axis) * @param {number} [hops] the target system's hops from home * @returns {Array<[number, number]>} sorted by count, positive weights */ export function patrolEncounterCounts(level = 0, hops = 0) { const band = patrolEncounterBand(level, hops); const fromBand = band ? patrolCountsTable(band.counts) : null; if (fromBand) return fromBand; return patrolCountsTable(config.get('sidequests.patrol.encounters.counts', null)) ?? [[1, 0.2], [2, 0.2], [3, 0.6]]; } /** * A contact's WAVE shape (data/sidequests.json → patrol.encounters.wave — * the design: one ROLLING wave of six, three at a time, each kill * replaced — the combat.waves rolling mechanic, js/combat/Waves.js). A * standing band's own `wave` overrides the shared block (the tiers scale * the force — total grows ~60%/tier, the concurrent seats one/tier, the * replace rule holds). * @param {number} [level] the player's combat level (the band's axis) * @param {number} [hops] the target system's hops from home * @returns {{kind:string, total:number, maxConcurrent:number, replacePerKill:number}} */ export function patrolEncounterWave(level = 0, hops = 0) { const shared = config.get('sidequests.patrol.encounters.wave', null); const s = shared && typeof shared === 'object' && !Array.isArray(shared) ? shared : {}; const band = patrolEncounterBand(level, hops); const w = { ...s, ...((band && band.wave && typeof band.wave === 'object' && !Array.isArray(band.wave)) ? band.wave : {}) }; const kind = w.kind === 'fixed' ? 'fixed' : 'rolling'; const total = Math.max(1, Math.floor(numOr(w.total, 6))); return { kind, total, maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))), replacePerKill: kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, 1))) : 0, }; } /** * The standing band's XP SCALE (the subtle dial — data → patrol.encounters * bands[].xpScale): a tier's fatter wave is worth more XP per contact, and * the scale keeps the payout's pace with the curve rather than the body * count (the tier's `xpScale`, a positive number; a band without one — the * low band — pays full worth, 1). * @returns {number} the multiplier (1 when the band carries none) */ export function patrolXpScale(level = 0, hops = 0) { return Math.max(0, numOr(patrolEncounterBand(level, hops)?.xpScale, 1)); } /** * The standing band's ROSTER — the weighted type mix a patrol's contacts * fly (data/sidequests.json → patrol.encounters.bands — the first standing * band's `roster` (a `{ type: weight }` object) or legacy `enemyTypes` * (equal weights), validated against combat.enemyTypes): the low tier * (levels 1–5 at 1–2 hops) flies the ESTABLISHED type only (raider); the * level-6 tier jumps to a three-type mix and fatter waves, and the higher * tiers add types one at a time (stinger, gunner, hunter, barrage) as the * level climbs. A roster that empties — or no band standing at all — takes * the fallback (the established type). * * @param {number} level the player's combat level (data/progression.json — js/combat/XpManager.js) * @param {number} hops the target system's jump-network depth from home (0 = home) * @param {string|null} [enemyFaction] the WAR MODEL's sighted rival (the * posting's enemyFaction): its OWN roster (combat.factions' type ids, * equal weight, validated) stands for the data band — the patrol fights * the faction it sighted, not a pirate * @returns {Record} the roster's type → weight table (never empty) */ export function patrolEnemyRoster(level, hops, enemyFaction = null) { const opp = String(enemyFaction ?? '').trim(); if (opp !== '') { const valid = new Set(enemyTypeIds()); const types = (factionTypeIds(opp) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t)); if (types.length > 0) { const mix = {}; for (const t of types) mix[t] = 1; return mix; } } const band = patrolEncounterBand(level, hops); const fallback = rosterWeights(config.get('sidequests.patrol.encounters.fallbackEnemyTypes', null), null) ?? establishedRoster(); if (!band) return fallback; return rosterWeights(band.roster ?? band.enemyTypes, fallback) ?? fallback; } /** * The contact's WAYPOINTS — the `count` of the route's `waypoints` * (finite [x,y] pairs) the contacts stand on, SPREAD across the route * (data/sidequests.json → patrol.encounters — POSITIONING): for count ≥ * 2, the most EVENLY SPREAD subset of the route's waypoints (scored by * its MINIMUM pairwise separation — maximin — then its total: on a * near-linear route that's the ends and the middle; a 3–5-point * route's contacts never cluster); a single contact stands on the * waypoint nearest the route's centroid (the patrol's * middle). Pure in (waypoints, count) — deterministic, ties keep the * lowest index order (the route itself is seeded, so the pair is stable * for the run). * * @param {Array<[number, number]>} waypoints the route's waypoints * @param {number} count the contacts' number (clamped to the route's length) * @returns {number[]} the waypoint indices (distinct, ascending; [] for an empty route) */ export function patrolEncounterPositions(waypoints, count) { const n = Array.isArray(waypoints) ? waypoints.length : 0; if (n === 0) return []; const k = Math.max(1, Math.min(Math.floor(numOr(count, 1)), n)); if (k === n) return waypoints.map((_, i) => i); if (k === 1) { // One contact: the patrol's MIDDLE — the waypoint nearest the // route's centroid (the points' center of mass — the route is a set // of points, not a path, so the centroid is its only 'middle'). let cx = 0, cy = 0, m = 0; for (const w of waypoints) { if (Array.isArray(w) && w.length >= 2 && Number.isFinite(w[0]) && Number.isFinite(w[1])) { cx += w[0]; cy += w[1]; m++; } } if (m === 0) return [0]; cx /= m; cy /= m; let best = 0; let bestD = Infinity; for (let i = 0; i < n; i++) { const w = waypoints[i]; if (!Array.isArray(w) || w.length < 2) continue; const d = (w[0] - cx) ** 2 + (w[1] - cy) ** 2; if (d < bestD) { bestD = d; best = i; } } return [best]; } // k ≥ 2: every C(n, k) subset (n ≤ the route's 5 — a trivial space), // scored for EVEN SPREAD: first the MINIMUM pairwise separation // (maximin — the contacts are as far apart from each other as the // route allows: on a near-linear route that's the ends and the middle, // never a cluster), then the total pairwise separation, ties the // lowest index order (the subsets enumerate in index order). const dist = (a, b) => Math.hypot(waypoints[a][0] - waypoints[b][0], waypoints[a][1] - waypoints[b][1]); const score = (idxs) => { let min = Infinity; let sum = 0; for (let a = 0; a < idxs.length; a++) { for (let b = a + 1; b < idxs.length; b++) { const d = dist(idxs[a], idxs[b]); if (d < min) min = d; sum += d; } } return { min, sum }; }; const combos = []; const rec = (start, cur) => { if (cur.length === k) { combos.push(cur.slice()); return; } for (let i = start; i <= n - (k - cur.length); i++) rec(i + 1, [...cur, i]); }; rec(0, []); let best = null; let bestMin = -1; let bestSum = -1; for (const c of combos) { const s = score(c); if (s.min > bestMin + 1e-9 || (Math.abs(s.min - bestMin) <= 1e-9 && s.sum > bestSum + 1e-9)) { bestMin = s.min; bestSum = s.sum; best = c; } } return best ?? [0]; } /** * The patrol's ENCOUNTERS — the contract's contact spec, rolled/derived * at the accept (the scene's _establishPatrol) from the data's rules: * the COUNT by its odds (patrolEncounterCounts — 1 on 20%, 2 on 20%, 3 * on 60%), the POSITIONS on the route's waypoints (patrolEncounter * Positions — the max-spread subset, the single contact at the route's * middle), the ROSTER from the standing band (patrolEnemyRoster — the * player's level × the target's hops from home; today the low band's * established type only), and the WAVE shape (patrolEncounterWave — one * rolling wave of six, three at a time). * * Seeded on its own labelled fork (seed, 'sidequests', * , '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 * @param {string|null} [enemyFaction] the WAR MODEL's sighted rival (the * posting's enemyFaction) — its roster stands for the data band * (patrolEnemyRoster's first rule) * @returns {?{at:number[], cleared:number[], roster:string[], wave:object}} null when the feature is off or the route is empty */ export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, level, hops, enemyFaction = null) { if (config.get('sidequests.patrol.encounters.enabled', true) === false) return null; if (!Array.isArray(waypoints) || waypoints.length === 0) return null; const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', String(jobId), 'encounters'); const count = Math.max(1, Math.floor(numOr(r.weighted(patrolEncounterCounts(level, hops), 3), 3))); return { at: patrolEncounterPositions(waypoints, count), cleared: [], roster: patrolEnemyRoster(level, hops, enemyFaction), enemyFaction: enemyFaction || null, wave: patrolEncounterWave(level, hops), xpScale: patrolXpScale(level, hops), }; } /** * A contact's WAVE SCRIPT (the shape for CombatSystem.startWaves — * js/combat/CombatSystem.js; the same `{ waves: [...] }` shape the named * scripts normalize to, js/combat/Waves.js): one wave of the contact's * roster (equal weights — a single-type band is a pure type + total), * the spec's wave shape (the data's: rolling six, three at a time). Pure * — the scene's _firePatrolContact hands it to startWaves. * @param {object} encounters the route record's encounters spec * @returns {{waves: object[]}} */ export function patrolEncounterScript(encounters) { const e = encounters && typeof encounters === 'object' && !Array.isArray(encounters) ? encounters : null; const roster = rosterWeights(e?.roster, null) ?? establishedRoster(); const w = { ...patrolEncounterWave(), ...(e?.wave && typeof e.wave === 'object' && !Array.isArray(e.wave) ? e.wave : {}) }; const kind = w.kind === 'fixed' ? 'fixed' : 'rolling'; const total = Math.max(1, Math.floor(numOr(w.total, 6))); const wave = { kind, roster: { ...roster }, total, maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))), }; if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1))); return { waves: [wave] }; } /** * The XP REWARD — the contract's SECOND payout (the claim pays it * beside the credits): the experience WORTH of the patrol's defeated * contacts. Completing the route requires every contact's wave to run * to completion, so the enemies the player defeats during the quest * ARE the contacts' waves — `at.length` × the wave's `total` — each * enemy worth its type's `xp` (data/combat.json — the roster's weighted * average; today's equal-weight specs are a plain average). Zero when * the route carries no encounters (old saves, the feature off) — the * reward degrades to credits-only, the route's waypoints alone * completing it. Pure in the spec — it reads the SAME wave shape + * roster patrolEncounterScript hands to the fight, so the reward and * the combat can't drift apart (a re-tune of the bands re-tunes both). * * @param {object|null} encounters the route record's encounters spec * @returns {number} the payout's XP (0 for a route without encounters) */ export function patrolEncounterXp(encounters) { const e = encounters && typeof encounters === 'object' && !Array.isArray(encounters) ? encounters : null; const at = e && Array.isArray(e.at) ? e.at.length : 0; if (at === 0) return 0; const wave = patrolEncounterScript(e).waves[0]; const total = Math.max(0, Math.floor(numOr(wave?.total, 0))); if (total === 0) return 0; const roster = wave?.roster && (typeof wave.roster === 'object' || Array.isArray(wave.roster)) ? wave.roster : null; const rw = rosterWeights(roster, null); let weightSum = 0; let xpWeighted = 0; if (rw) { for (const [typeId, w] of Object.entries(rw)) { const weight = Math.max(0, Number(w) || 0); if (weight === 0) continue; weightSum += weight; xpWeighted += weight * Math.max(0, Number(enemyTypeConfig(String(typeId))?.xp) || 0); } } if (weightSum === 0) return 0; // The band's XP SCALE (the tier's subtle dial — data → patrol.encounters // bands[].xpScale; absent = 1): the fatter waves pay more XP worth, and // the scale keeps the payout's pace with the curve rather than the count. const scale = Math.max(0, numOr(e?.xpScale, 1)); return Math.round(at * total * (xpWeighted / weightSum) * scale); } /** * A patrol's ROUTE is COMPLETE when every waypoint is LOGGED and every * contact CLEARED (the contract's combat is part of the job — the * reward's door opens on the last of the two). A route without * encounters (a save predating the contacts — old saves load) completes * on its waypoints alone. * @param {object} route the route record (sideQuestState.routeFor's shape) * @returns {boolean} */ export function patrolRouteComplete(route) { if (!route || typeof route !== 'object') return false; const wpCount = Array.isArray(route.waypoints) ? route.waypoints.length : 0; const visited = Array.isArray(route.visited) ? route.visited.length : 0; if (wpCount === 0 || visited < wpCount) return false; const enc = route.encounters; if (!enc || typeof enc !== 'object') return true; const at = Array.isArray(enc.at) ? enc.at : []; if (at.length === 0) return true; const cleared = new Set(Array.isArray(enc.cleared) ? enc.cleared : []); return at.every((i) => cleared.has(i)); } // --------------------------------------------- the BOUNTY HUNT (category 'bounty') // The contract with a NAME ON IT (data/sidequests.json → bounty). A hunt is // a patrol with its ending hidden: three legs along one pirate leader's // travel lanes, flown STRICTLY IN ORDER, and the mark stands on ONE of them // (20% on the first leg, else 20% of what is left on the second, else the // third at 100%). The rolls happen ONCE at the accept on their own labelled // fork and ride the route record — a signed contract is a signed contract — // and NOTHING here is spoken to the player before it fires (the plan object // is model-side only; the board, the compass, and the beacons never read // `bountyAt`). // // The scene's share (js/scenes/GameScene.js → _establishBountyHunt / // updateBountyHunt) uses these pure pieces: // bountyRules(level) — the standing band's numbers (the data's, // with `inherits` resolved and fallbacks) // deriveBountyPlan(…) — the rolled chain (bountyAt + per-leg pickets) // bountySkirmishScript / bountyEngagementScript — CombatSystem.startWaves shapes // bountyHuntXp(plan) — the claim's XP beside the credits // nextBountyWaypoint(route) — the SEQUENTIAL check-in rule (one leg at a time) // bountyRouteComplete(route) — the kill is the whole job /** The elite's fallback frames (a dossier naming a craft that isn't in combat.enemyTypes). */ const BOUNTY_FALLBACK_FRAMES = [ 'bomber', 'spitter', 'stinger', 'hunter', 'gunner', 'barrage', 'warden', ]; /** * The wanted leader's OWN identity, carried on the plan (the dossier's name + * epithet). The comms name the mark only once its fight stands in front of the * ship — the plan itself never reads out where it hides — and because the mark * rides the route record it survives a save. Empty strings when the board had * no dossier (a hand-edited pool): the hunt still flies, silent. */ function bountyMark(raw) { const m = raw && typeof raw === 'object' ? raw : {}; return { name: String(m.name ?? '').trim(), epithet: String(m.epithet ?? '').trim(), }; } /** One wave shape, clamped (a hunt's fight is never smaller than one hostile). */ function bountyWaveShape(raw, fb) { const w = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; const kind = (w.kind ?? fb.kind) === 'fixed' ? 'fixed' : 'rolling'; const total = Math.max(1, Math.floor(numOr(w.total, fb.total))); const out = { kind, total, maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, fb.maxConcurrent)))), replacePerKill: kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, fb.replacePerKill))) : 0, }; if (w.roster != null) { const rw = Array.isArray(w.roster) ? w.roster.map(String) : rosterWeights(w.roster, null); if (rw != null) out.roster = rw; } // The BOUNTY's escort mix (weighted — a tier's escort flies a roster, not // one type; the base craft's plain `raiderType` count is the fallback). if (w.escortRoster != null) { const er = rosterWeights(w.escortRoster, null); if (er != null) out.escortRoster = er; } if (w.raiderType != null) out.raiderType = String(w.raiderType); if (w.raiders != null) out.raiders = Math.max(0, Math.floor(numOr(w.raiders, fb.raiders ?? 0))); if (Array.isArray(w.eliteFrames) && w.eliteFrames.length > 0) out.eliteFrames = w.eliteFrames.map(String); return out; } /** * The standing band's RULES (data/sidequests.json → bounty + bounty.encounters). * A BAND is a level window's tuning: the 'hunter' band (levels 2–5) and the * tuned 'marksman' (6–10), 'deadeye' (11–14), 'apex' (15+) bands — the tiers * grow the pickets (6→10→14→18 at 3/4/5/6 at a time, their mixes widening * — spitters/bombers at the level-6 jump, stingers/gunners/hunters/barrages * higher) and the hunt (9→13→17→21 — the escort's mix via `escortRoster`, * the mark's armor/damage scale 2×→3×→3.5×→4×), so a player who outruns a * level window keeps getting hunts with the tier's pressure, not the same fight. * Resolution: the first band whose `levels` window holds the player's * combat level, else the fallbackBand, else the first band; the chosen band's * own overrides (bountyChance / skirmishChance / waypoints / skirmishWave / * bountyWave) sit on top of the shared encounter block, and `inherits` pulls * a band's numbers underneath them. * * @param {number} level the player's combat level (js/combat/XpManager.js) * @returns {{id:string, legs:[number,number], visitRadius:number, bountyChance:number, skirmishChance:number, skirmishWave:object, bountyWave:object}} */ export function bountyRules(level = 0) { const enc = config.get('sidequests.bounty.encounters', null); const e = enc && typeof enc === 'object' && !Array.isArray(enc) ? enc : {}; const legRange = (() => { const raw = config.get('sidequests.bounty.waypoints', [3, 3]); let lo = Math.floor(numOr(Array.isArray(raw) ? raw[0] : 3, 3)); let hi = Math.floor(numOr(Array.isArray(raw) ? raw[1] : 3, 3)); if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 3]; lo = Math.max(1, lo); hi = Math.max(lo, hi); return [lo, hi]; })(); const base = { id: 'hunter', legs: legRange, visitRadius: Math.max(60, numOr(config.get('sidequests.bounty.visitRadius', 420), 420)), bountyChance: Math.min(1, Math.max(0, numOr(e.bountyChance, 0.2))), skirmishChance: Math.min(1, Math.max(0, numOr(e.skirmishChance, 0.5))), skirmishWave: bountyWaveShape(e.skirmishWave, { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1, roster: ['raider'], }), bountyWave: bountyWaveShape(e.bountyWave, { kind: 'rolling', total: 9, raiders: 8, maxConcurrent: 3, replacePerKill: 1, raiderType: 'raider', eliteFrames: BOUNTY_FALLBACK_FRAMES.slice(), }), elite: { healthScale: Math.max(1, numOr(e.bountyWave?.elite?.healthScale, 2)), damageScale: Math.max(1, numOr(e.bountyWave?.elite?.damageScale, 2)), }, xpScale: Math.max(0, numOr(e.xpScale, 1)), enabled: e.enabled !== false, }; // The elite's own scales ride the wave shape (the runner reads one object). base.bountyWave.elite = { ...base.elite }; const bands = Array.isArray(e.bands) ? e.bands : []; const byId = new Map(); for (const b of bands) { if (b && typeof b === 'object' && !Array.isArray(b) && b.id != null) byId.set(String(b.id), b); } const lvl = numOr(level, 0); let band = null; for (const b of bands) { if (!b || typeof b !== 'object' || Array.isArray(b)) continue; const w = Array.isArray(b.levels) ? b.levels : null; if (!w) continue; const lo = numOr(w[0], NaN); const hi = numOr(w[1] ?? w[0], lo); if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue; if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi)) { band = b; // the first window holding the level wins (list order) break; } } if (!band) band = byId.get(String(e.fallbackBand ?? '')) ?? bands[0] ?? null; if (!band || typeof band !== 'object' || Array.isArray(band)) return base; // The band's overrides, with `inherits` standing underneath them. const layers = []; const seen = new Set(); let cur = band; while (cur && !seen.has(String(cur.id ?? ''))) { seen.add(String(cur.id ?? '')); layers.unshift(cur); cur = cur.inherits != null ? byId.get(String(cur.inherits)) : null; } const out = { ...base, id: String(band.id ?? base.id) }; for (const layer of layers) { if (Array.isArray(layer.waypoints)) { const lo = Math.max(1, Math.floor(numOr(layer.waypoints[0], base.legs[0]))); const hi = Math.max(lo, Math.floor(numOr(layer.waypoints[1] ?? layer.waypoints[0], base.legs[1]))); out.legs = [lo, hi]; } if (Number.isFinite(Number(layer.bountyChance))) out.bountyChance = Math.min(1, Math.max(0, Number(layer.bountyChance))); if (Number.isFinite(Number(layer.skirmishChance))) out.skirmishChance = Math.min(1, Math.max(0, Number(layer.skirmishChance))); if (Number.isFinite(Number(layer.visitRadius))) out.visitRadius = Math.max(60, Number(layer.visitRadius)); if (Number.isFinite(Number(layer.xpScale)) && Number(layer.xpScale) > 0) out.xpScale = Number(layer.xpScale); if (layer.skirmishWave) out.skirmishWave = bountyWaveShape(layer.skirmishWave, out.skirmishWave); if (layer.bountyWave) { out.bountyWave = bountyWaveShape(layer.bountyWave, out.bountyWave); const sc = layer.bountyWave.elite || {}; out.bountyWave.elite = { healthScale: Math.max(1, numOr(sc.healthScale, base.elite.healthScale)), damageScale: Math.max(1, numOr(sc.damageScale, base.elite.damageScale)), }; } } return out; } /** * The hunt's PLAN — the hidden shape of one contract, rolled at the accept * and FIXED on the route record (never re-drawn; never shown). * * The chain rides its own labelled fork (seed,'sidequests', * ,'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(); // THE WAR MODEL — a FACTION hunt (opts.faction = the mark's own // faction): the escorts + the mark's frame draw from THAT faction's // roster (combat.factions' type ids, validated) — the mark is a member // of the faction that holds the target system, and its pickets are its // own fighters. A faction without a registered roster (or a neutral // hunt) keeps the data's pirate frames. const factionTypes = (String(opts?.faction ?? '').trim() !== '' ? (factionTypeIds(opts.faction) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t)) : []); const frames = (Array.isArray(rules.bountyWave.eliteFrames) ? rules.bountyWave.eliteFrames : BOUNTY_FALLBACK_FRAMES) .map((t) => String(t).trim()) .filter((t) => valid.has(t)); const framePool = factionTypes.length > 0 ? factionTypes : frames; let eliteType = declared !== '' && valid.has(declared) ? declared : null; if (!eliteType) { const pool = framePool.length > 0 ? framePool : BOUNTY_FALLBACK_FRAMES.filter((t) => valid.has(t)); eliteType = pool.length > 0 ? String(r.pick(pool)) : ESTABLISHED_ENEMY_TYPES[0]; } const raiderType = factionTypes.length > 0 ? String(factionTypes[0]) : (valid.has(String(rules.bountyWave.raiderType ?? '')) ? String(rules.bountyWave.raiderType) : ESTABLISHED_ENEMY_TYPES[0]); const raiders = Math.max(0, Math.floor(numOr(rules.bountyWave.raiders, rules.bountyWave.total - 1))); // The picket waves ride the mark's OWN faction (the war model) — its // fighters screen the lanes, equal weight over the roster. const skirmishWave = factionTypes.length > 0 ? { ...rules.skirmishWave, roster: Object.fromEntries(factionTypes.map((t) => [t, 1])) } : { ...rules.skirmishWave }; return { legs, bountyAt, pickets, band: rules.id, faction: factionTypes.length > 0 ? String(opts.faction) : null, raiders, raiderType, eliteType, mark: bountyMark(opts?.mark), skirmishWave, bountyWave: { ...rules.bountyWave, total: Math.max(1, raiders + 1) }, xpScale: Math.max(0, numOr(rules.xpScale, 1)), visitRadius: rules.visitRadius, }; } /** * A hunt's picket wave — one rolling wave of the standing band's roster * (a weighted mix — the tiers bring in spitters, bombers, stingers, and * gunners beside the established type; a band with no roster is a pure * type + total), the seats as the band says, each kill replaced. */ export function bountySkirmishScript(plan = {}) { const w = plan?.skirmishWave || bountyRules(0).skirmishWave; const kind = w.kind === 'fixed' ? 'fixed' : 'rolling'; const total = Math.max(1, Math.floor(numOr(w.total, 6))); const roster = rosterWeights(w.roster, null) ?? establishedRoster(); const wave = { kind, roster: { ...roster }, total, maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))), }; if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1))); return { waves: [wave] }; } /** * The BOUNTY's wave — one rolling wave of `raiders + 1`: the escort plus the * mark itself, declared on the wave as `elite` so js/combat/Waves.js deploys * it with the OPENING group and js/combat/EnemyShip.js doubles its hull / * shields and weapon damage (the data's healthScale / damageScale). The kill * is the contract: the scene halts the run (CombatSystem.haltWaves) and waits * out the survivors. */ export function bountyEngagementScript(plan = {}) { const rules = bountyRules(0); const w = plan?.bountyWave || rules.bountyWave; const kind = w.kind === 'fixed' ? 'fixed' : 'rolling'; const eliteType = String(plan?.eliteType ?? 'raider'); const raiders = Math.max(0, Math.floor(numOr(plan?.raiders, Math.max(0, numOr(w.total, 9) - 1)))); const raiderType = String(w.raiderType ?? ESTABLISHED_ENEMY_TYPES[0]); const total = Math.max(1, raiders + 1); // the escort + exactly one mark const roster = {}; const escort = rosterWeights(w.escortRoster, null); if (escort) { for (const [t, wt] of Object.entries(escort)) roster[t] = wt; // the band's escort mix (weighted) } else if (raiders > 0) { roster[raiderType] = raiders; } roster[eliteType] = (roster[eliteType] ?? 0) + 1; const wave = { kind, roster, total, maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))), elite: { type: eliteType, healthScale: numOr(w.elite?.healthScale, rules.elite.healthScale), damageScale: numOr(w.elite?.damageScale, rules.elite.damageScale), // The name the KILL reads out (js/combat/Waves.js keeps it; EnemyShip // tags it; GameScene's witness puts it in the comms line). name: String(plan?.mark?.name ?? '').trim(), }, }; if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1))); return { waves: [wave] }; } /** * The hunt's XP payout — the worth of everything it throws at the ship: the * mark (its own type's worth × its scale-up) plus the escort plus every * rolled picket wave. Fixed at the accept beside the credits, so the claim * pays what the plan promised even if the data is re-tuned mid-run. */ export function bountyHuntXp(plan = {}) { const p = plan && typeof plan === 'object' ? plan : {}; const xpOf = (t) => Math.max(0, Number(enemyTypeConfig(String(t))?.xp) || 0); const rules = bountyRules(0); const raiders = Math.max(0, Math.floor(numOr(p.raiders, numOr(rules.bountyWave.raiders, 8)))); const raiderType = p.raiderType != null ? String(p.raiderType) : String(rules.bountyWave.raiderType ?? 'raider'); const eliteType = String(p.eliteType ?? ESTABLISHED_ENEMY_TYPES[0]); const scale = numOr(p.bountyWave?.elite?.healthScale, rules.bountyWave.elite?.healthScale ?? 2); // The band's XP SCALE (the tier's subtle dial — data → bounty.encounters // bands[].xpScale; a band without one — the hunter — pays full worth, 1): // the tiers' fatter escorts + pickets pay more XP worth, and the scale // keeps the payout's pace with the curve rather than the body count. const xpScale = Math.max(0, numOr(p.xpScale, 1)); // The escort's XP — a band's escort mix (weighted roster) at its weighted // mean; a band without one is the plain type × its count. const escort = rosterWeights(p.bountyWave?.escortRoster, null); let escortXp; if (escort) { let es = 0; let ex = 0; for (const [t, wt] of Object.entries(escort)) { es += wt; ex += wt * xpOf(t); } escortXp = es > 0 ? (ex / es) * raiders : raiders * xpOf(raiderType); } else { escortXp = raiders * xpOf(raiderType); } const xpMean = (weights) => { let s = 0; let x = 0; for (const [t, wt] of Object.entries(weights)) { s += wt; x += wt * xpOf(t); } return s > 0 ? x / s : xpOf(ESTABLISHED_ENEMY_TYPES[0]); }; let xp = escortXp + xpOf(eliteType) * Math.max(1, scale); const skirmishTotal = Math.max(0, Math.floor(numOr(p.skirmishWave?.total, rules.skirmishWave.total))); const pickets = (Array.isArray(p.pickets) ? p.pickets : []).filter(Boolean).length; const skirmishRoster = rosterWeights(p.skirmishWave?.roster, null) ?? establishedRoster(); xp += pickets * skirmishTotal * xpMean(skirmishRoster); return Math.round(xp * xpScale); } /** * The SEQUENTIAL check-in rule — the hunt's one departure from patrol law. * The chain's odds read an ORDER, so a hunt logs its legs one at a time: the * NEXT leg is the lowest index not yet logged, and only that leg's beacon and * compass point anywhere. `null` when every leg stands logged. * * @param {object} route the route record * @returns {?{index:number, x:number, y:number, leg:number, of:number}} */ export function nextBountyWaypoint(route) { if (!route || typeof route !== 'object') return null; const wp = Array.isArray(route.waypoints) ? route.waypoints : []; if (wp.length === 0) return null; const visited = new Set(Array.isArray(route.visited) ? route.visited : []); for (let i = 0; i < wp.length; i++) { const w = wp[i]; if (!Array.isArray(w) || w.length < 2) continue; if (visited.has(i)) continue; return { index: i, x: Number(w[0]), y: Number(w[1]), leg: i + 1, of: wp.length }; } return null; } /** * The hunt is COMPLETE when the mark is DOWN (the contract's only witness — * the legs are a formality the kill checks off at once). A malformed or * missing bounty record falls back to the patrol law (every leg logged) so a * degraded save can still finish. */ export function bountyRouteComplete(route) { if (!route || typeof route !== 'object') return false; const b = route.bounty && typeof route.bounty === 'object' ? route.bounty : null; if (b?.down === true) return true; if (!b) return patrolRouteComplete(route); return false; } /** * A bounty posting's DOSSIER (the wanted list's fields — data → * descriptions.neutral.bounty[]). Resolved for the readout, with the craft * VALIDATED against combat.enemyTypes and the copy placeholder-free. Returns * null when the pool entry isn't a dossier (a hand-edited pool) — the posting * still stands on its title/description alone. */ function buildBountyDossier(v, ctx) { if (!v || typeof v !== 'object' || Array.isArray(v)) return null; const name = resolveSideQuestText(String(v.name ?? ''), ctx).trim(); if (name === '') return null; const valid = new Set(enemyTypeIds()); const craft = String(v.craft ?? '').trim(); return { name, epithet: resolveSideQuestText(String(v.epithet ?? ''), ctx).trim(), crime: resolveSideQuestText(String(v.crime ?? ''), ctx).trim(), craft: valid.has(craft) ? craft : null, // null = the plan picks a frame (seeded) craftLabel: resolveSideQuestText(String(v.craftLabel ?? ''), ctx).trim(), }; } /** * One line of the hunt's UI copy (data/sidequests.json → ui.) with its * `{placeholders}` filled. A key can stand as a plain string or as * `{ glyph, color, text }` (the toasts carry their own glyph + color); an * unknown key falls back to `fb` so a data typo degrades to the design's own * words instead of blanking a toast. Placeholders with no value blank out. * @returns {{glyph:string,color:string,text:string}|null} null when neither the key nor a fallback stands */ function bountyUiLine(key, vars = {}, fb = '') { const raw = config.get(`sidequests.ui.${key}`, null); const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null; const text = obj ? String(obj.text ?? '') : typeof raw === 'string' ? raw : ''; if (text === '' && fb === '') return null; let out = text === '' ? String(fb) : text; for (const [k, value] of Object.entries(vars)) out = out.split(`{${k}}`).join(String(value)); out = out.replace(/\{[a-zA-Z]+\}/g, ''); // an unfilled placeholder never ships return { glyph: obj ? String(obj.glyph ?? '') : '', color: obj ? String(obj.color ?? '') : '', text: out, }; } /** The bounty gate's combat level (data → categories.bounty.minLevel; the design's 2). */ export function bountyMinLevel() { return sideQuestCategoryMeta('bounty')?.minLevel ?? 2; } /** A locked row's badge (“LOCKED — REQUIRES LEVEL 2”); `sub` = the detail line under it. */ export function bountyLockCopy(sub = false) { const level = bountyMinLevel(); return sub ? bountyUiLine('bountyLockSub', { level }, 'BRING YOUR RECORD UP TO LEVEL {level}') : bountyUiLine('bountyLockBadge', { level }, 'LOCKED — REQUIRES LEVEL {level}'); } /** The scene's refusal when a locked contract is pressed anyway. */ export function bountyDenyCopy() { return bountyUiLine('bountyAcceptDenyToast', { level: bountyMinLevel() }, 'COMMISSION REFUSED — LEVEL {level} REQUIRED'); } /** * The system name the posting's GATE ACCESS lock stands on — the system the * player needs access to FIRST: the BOUNTY's mark's home (targetSystemName) or the ESCORT's trapped transport's ground (enemySystemName). Null when the * posting names none (a neutral posting, a data gap — the copy then falls * to the level gate's own words). * @param {object} posting a sideQuestPostingsFor posting * @returns {string|null} */ export function gateLockSystemName(posting) { const p = posting && typeof posting === 'object' ? posting : {}; const raw = p.category === 'escort' ? p.enemySystemName : p.targetSystemName; const name = String(raw ?? '').trim(); return name !== '' ? name : null; } /** * Is the posting's binding door the GATE ACCESS lock (its target system sits * behind a faction gate) rather than the category's own level gate? The * gate level must stand AND outrun the level gate — then the lock's words * are the gate's, with the system named (gateLockCopy). * @param {object} posting a sideQuestPostingsFor posting * @returns {boolean} */ export function postingGateLocked(posting) { const gl = Math.max(0, Math.floor(Number(posting?.gateLevel) || 0)); if (gl <= 0) return false; const minLevel = sideQuestCategoryMeta(posting?.category)?.minLevel ?? 0; return gl >= minLevel; } /** * The GATE ACCESS lock's copy — the faction board's hunt / rescue stands * behind a gate into the hostile neighbor's system, and the player is told * WHICH system it needs access to before the contract stands open (the * data's {system} placeholder, resolved against the posting: bounty → * targetSystemName, escort → enemySystemName; the {level} is the posting's * gateLevel — the capability that opens the hop). The `which` forms: 'badge' * (the row's greyed plate + the door's label), 'sub' (the detail's lock * line), 'deny' (the scene's refusal when a locked contract is pressed). * A posting with no named system (a data gap) degrades to the level gate's * own words rather than shipping an empty brace pair. * @param {object} posting a sideQuestPostingsFor posting * @param {'badge'|'sub'|'deny'} [which] the form * @returns {{glyph:string,color:string,text:string}} */ export function gateLockCopy(posting, which = 'badge') { const isEscort = String(posting?.category ?? '') === 'escort'; const key = { badge: isEscort ? 'escortGateLockBadge' : 'bountyGateLockBadge', sub: isEscort ? 'escortGateLockSub' : 'bountyGateLockSub', deny: isEscort ? 'escortGateDenyToast' : 'bountyGateDenyToast', }[which] ?? (isEscort ? 'escortGateLockBadge' : 'bountyGateLockBadge'); const level = Math.max(0, Math.floor(Number(posting?.gateLevel) || 0)) || Math.max(0, Math.floor(numOr(config.get('gates.jump.factionLock.minLevel', 5), 5))); const name = gateLockSystemName(posting); if (name === null) return which === 'deny' ? bountyDenyCopy() : bountyLockCopy(which === 'sub'); const fb = which === 'deny' ? 'COMMISSION REFUSED — ACCESS TO {system} OPENS AT COMBAT LEVEL {level}' : 'LOCKED — ACCESS TO {system} REQUIRED'; return bountyUiLine(key, { system: String(name).toUpperCase(), level }, fb); } /** A hunt line's copy (a leg logged / pickets inbound / the mark sighted / down / complete). */ export function bountyText(key, vars = {}) { return bountyUiLine(key, vars, ''); } /** The readout's progress line (logged legs only — never what waits on them). */ export function bountyProgressCopy(route) { const wp = Array.isArray(route?.waypoints) ? route.waypoints : []; const visited = Array.isArray(route?.visited) ? new Set(route.visited).size : 0; return bountyUiLine('bountyRouteProgress', { visited, total: wp.length }, 'WAYPOINT {visited} OF {total} LOGGED'); } /** The contract readout's dossier lines (wanted / epithet / charges / craft). */ export function bountyDossierCopy(dossier) { const d = dossier && typeof dossier === 'object' ? dossier : null; if (!d || !d.name) return []; const craftLine = bountyUiLine('dossierCraft', { craft: d.craftLabel ?? '' }, 'CRAFT — {craft}'); const lines = [ bountyUiLine('dossierWanted', { name: d.name }, 'WANTED — {name}'), d.epithet ? bountyUiLine('dossierEpithet', { epithet: d.epithet }, '{epithet}') : null, bountyUiLine('dossierCrime', { crime: d.crime ?? '' }, 'CHARGES — {crime}'), d.craftLabel ? craftLine : null, ]; return lines.filter((l) => l && l.text !== ''); } /** * A bounty route record's PLAN, normalized (SideQuestState.setRoute's pass). * The rolled shape is VALIDATED, never re-drawn: `bountyAt` inside the leg * count, one `pickets` mark per leg with nothing standing on the mark's own * leg or behind it, the elite a real combat.enemyTypes id, the wave shapes * finite, and the runtime marks (`down`, the cleared picket legs) kept. A * malformed record degrades to null — the route's waypoints still stand, the * hunt simply never fires (a hand-edited save must not manufacture a fight). * * @param {*} raw deriveBountyPlan's result (or a save's) * @param {number} wpCount the route's waypoint count * @returns {?object} */ function normalizeBountyPlan(raw, wpCount) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; const legs = Math.max(1, Math.floor(numOr(raw.legs, wpCount))); const bountyAt = Math.floor(numOr(raw.bountyAt, NaN)); if (!Number.isInteger(bountyAt) || bountyAt < 0 || bountyAt >= legs) return null; const valid = new Set(enemyTypeIds()); const eliteType = String(raw.eliteType ?? '').trim(); if (!valid.has(eliteType)) return null; // The pickets: one mark per leg BEFORE the mark (the chain's rule — the leg // that holds the hunt, and every leg behind it, never rolls). const pickets = []; for (let i = 0; i < legs - 1; i++) { pickets.push(i < bountyAt && raw.pickets?.[i] === true); } const raiders = Math.max(0, Math.floor(numOr(raw.raiders, 8))); const raiderType = valid.has(String(raw.raiderType ?? '').trim()) ? String(raw.raiderType).trim() : ESTABLISHED_ENEMY_TYPES[0]; const rules = bountyRules(0); const mark = bountyMark(raw.mark); const sw = raw.skirmishWave && typeof raw.skirmishWave === 'object' ? raw.skirmishWave : {}; const bw = raw.bountyWave && typeof raw.bountyWave === 'object' ? raw.bountyWave : {}; const el = bw.elite && typeof bw.elite === 'object' ? bw.elite : {}; return { legs, bountyAt, pickets, band: String(raw.band ?? 'hunter'), raiders, raiderType, eliteType, mark, skirmishWave: bountyWaveShape(sw, rules.skirmishWave), bountyWave: { ...bountyWaveShape({ ...bw, total: Math.max(1, raiders + 1) }, rules.bountyWave), raiderType, raiders, elite: { healthScale: Math.max(1, numOr(el.healthScale, 2)), damageScale: Math.max(1, numOr(el.damageScale, 2)), }, }, xpScale: Math.max(0, numOr(raw.xpScale, 1)), visitRadius: Math.max(60, numOr(raw.visitRadius, rules.visitRadius)), // The runtime marks (the scene's witness marks — a fresh plan carries none). down: raw.down === true, skirmishCleared: [...new Set( (Array.isArray(raw.skirmishCleared) ? raw.skirmishCleared : []).filter( (i) => Number.isInteger(i) && i >= 0 && i < bountyAt && raw.pickets?.[i] === true, ), )].sort((a, b) => a - b), }; } /** * The WORLD's LIVE postings (the board's list) — 1–3 by design * (sideQuestCountRange), drawn from the LIVE categories only * (enabledSideQuestCategories): distinct categories while they last, * then repeats into the variant pools (the same category can hold more * than one posting once the count outruns the live set). The board is * PER SURFACE WORLD (a planet's `p` / a station's `s` key — * the record id minus the system prefix): two worlds in one system * stand two DIFFERENT boards, while the flavor's {system} voice stays * the system's (the contract is about keeping the SYSTEM safe — the * board just sits on a world), and the contract still flies in the * system's space (the patrol's route is the system's, as always). * * Deterministic in (seed, record, world[, galaxy]) — the labelled forks * under Rng.derive(seed, seedLabel, , ) make the roll, the * category order, and every per-posting draw (the patrol's target * included) independent of call order, so the board stands the same * across visits, scene restarts, and saves. * * A posting's shape (the window's row + detail + the scene's accept): * { id, systemId, systemName, worldKey, faction (null = neutral), * category, label, accent, title, issuer, description, * reward: { credits }, objective: { type } } — objective is the * typed socket the category's completion rules fill in when the type * lands. A PATROL posting additionally carries targetSystemId / * targetSystemName (the route's system — patrolTargetSystem; default * the posting's own system). The id's shape is * 'sq----' (the save-safe ledger key — * parseSideQuestId below splits it back apart). * * @param {string|number} seed the galaxy's seed * @param {object} rec a system record ({ id, name, faction, d, … }) * @param {string} worldKey the board's world (a planet's `p` / a station's `s`) * @param {object|null} [galaxy] the galaxy (the patrol's neighbor rule's jumpGatesFor) — null in tests * @param {number|null} [level] the player's combat level (the BOUNTY HUNT's gate stamp) * @returns {object[]} [] when the feature is off or no category is live */ export function sideQuestPostingsFor(seed, rec, worldKey, galaxy = null, level = null) { if (!sideQuestsEnabled()) return []; if (!rec || typeof rec.id !== 'string' || rec.id === '') return []; // A world the board can't name (a caller without world data — a test, // a hand-built record) borrows the FIRST world's board — the board // never blanks for a missing key, and the key is part of the seed, so // the fallback is still deterministic. const wk = String(worldKey ?? '').trim(); const world = /^[a-z][a-z0-9]{0,3}$/i.test(wk) ? wk.toLowerCase() : 'p1'; const ctx = postingContext(rec); const cats = enabledSideQuestCategories(ctx.context); // a faction board never draws a neutral-only type if (cats.length === 0) return []; const [lo, hi] = sideQuestCountRange(); const base = Rng.derive(seed, seedLabel(), rec.id, world); const count = base.int(lo, hi); const order = base.shuffle(cats); // the world's slot order (seeded, stable) const out = []; const usedDossier = new Set(); // one board never posts the SAME wanted leader twice for (let slot = 0; slot < count; slot++) { const cat = order[slot % order.length]; const r = Rng.derive(seed, seedLabel(), rec.id, world, cat, slot); // per-posting stream const posting = buildPosting(seed, rec, ctx, cat, slot, r, galaxy, usedDossier, world); if (!posting) continue; if (cat === 'bounty' && posting.dossier?.name) usedDossier.add(posting.dossier.name); // The level gate is PRESENTATION + ACCEPT, never generation: the // posting stands in its seeded slot below the gate too (a locked // row is still the same row), and the scene refuses the accept. const gate = sideQuestAcceptable(posting, level); posting.locked = gate.locked === true; posting.minLevel = gate.minLevel; // The GATE ACCESS lock's mark (the posting's target system sits behind a // faction gate and it is the binding door — the board's row / detail + // the scene's refusal then speak in the gate's words, the system named, // not the level gate's). posting.gateLocked = gate.locked === true && gate.gateLevel > 0 && gate.gateLevel >= gate.minLevel; out.push(posting); } return out; } /** * A POSTING id back apart (the scene's _sideQuestPosting / the claim's * reward lookup parse the ledger's id to its board + slot, then * regenerate the board from the seed and find the posting). The id's * shape is 'sq-\u003csystemId\u003e-\u003cworld\u003e-\u003ccategory\u003e-\u003cslot\u003e' (a planet's world key * `p\u003cordinal\u003e`, a station's `s\u003cn\u003e' — both a letter + digits, so the * greedy split is the true one: the category is letters, the slot * digits, at the id's end; the system id is whatever stands before the * world key). An id the board never posted (an OLD per-system save id — * 'sq-\u003csys\u003e-\u003ccat\u003e-\u003cslot\u003e' — the world key's digits never stand — or a * hand-edited string) yields null rather than a guess. * @param {string} id the ledger's posting id * @returns {?{systemId: string, worldKey: string, category: string, slot: number}} */ export function parseSideQuestId(id) { if (typeof id !== 'string') return null; const m = /^sq-(.+)-([a-z]\d{1,3})-([a-z]+)-(\d+)$/i.exec(id); if (!m) return null; const cat = m[3].toLowerCase(); if (!SIDE_QUEST_CATEGORIES.includes(cat)) return null; // a world key is a letter + digits only return { systemId: m[1], worldKey: m[2].toLowerCase(), category: cat, slot: Math.floor(Number(m[4])) }; } /** One posting from its pool variant + reward roll (null on a bad pool entry). */ function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier = null, world = 'p1') { const meta = sideQuestCategoryMeta(cat); if (!meta) return null; const factionCtx = ctx.context === 'faction'; // THE WAR MODEL (a faction board — the contract's ground): // patrol — the sighted enemy: ONE seeded opposing faction (the posting's // own system is the ground — patrolTargetSystem stands home); // bounty — the mark's home: a NEIGHBORING system a gate-hop away held by // one of the two rivals (hostileNeighborSystem); no rival ground // ⇒ the slot stands EMPTY; // escort — the trapped transport's ground: the same hostile neighbor; // the convoy's destination is the board's OWN system (the // friendly territory the contract is issued from). // The draw rides the posting's seeded stream, so the same posting holds // the same enemy / ground for the whole run (accepts, restarts, saves). let enemyFaction = null; let ground = null; if (factionCtx && cat === 'patrol') { enemyFaction = seededOpposingFaction(rec.faction, r); } else if (factionCtx && (cat === 'bounty' || cat === 'escort')) { ground = hostileNeighborSystem(rec, r, galaxy); if (!ground) return null; // no rival holds gate-reachable ground — the slot is empty enemyFaction = ground.faction; } // ESCORT FEASIBILITY (the NEUTRAL board — the classic contract): only an // escort that is actually COMPLETABLE (a NEUTRAL destination 1–2 gate // hops away, the contract's signature, fixed at the accept). When the // system has no gate-reachable neutral world the escort slot stands // EMPTY. (The FACTION escort's feasibility is the hostile ground above // — its destination is the board's own system, always deliverable.) if (cat === 'escort' && !factionCtx && galaxy) { const postingId = `sq-${rec.id}-${world}-${cat}-${slot}`; if (!escortDestinationFor(seed, postingId, rec.id, galaxy)) return null; } 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; } } } // The posting's COPY context: the board's own (system/faction/issuer/ // enemies) plus `enemy` — the ONE named rival this posting is about // (the sighted patrol faction, the mark's faction, the transport's // captors — the pools' {enemy} placeholder resolves against it). const labels = sideQuestFactionLabels(); const pctx = { ...ctx, enemy: enemyFaction ? (labels[enemyFaction] ?? enemyFaction) : '' }; const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, pctx); // The row's issuer line: the pool's own name (the neutral pools' — // “The Harbormaster”), or the faction's board name (the faction pools' // {issuer} placeholder — the data's sidequests.factions table), so a // faction's postings read “ISSUER — ZENITH CONCORD”, not “The Board”. const issuerRaw = v?.issuer ?? (ctx.issuer || 'The Board'); const issuer = resolveSideQuestText(issuerRaw, pctx) || 'The Board'; const description = resolveSideQuestText(v?.body ?? '', pctx); // The GATE ACCESS lock — the contract's target system must be // gate-reachable at the player's level (bounty/escort in hostile ground; // 0 = the hop is never locked — the scene's gateAccessLevel pure rule). const gateLevel = ground ? gateAccessLevel(rec, ground) : 0; return { id: `sq-${rec.id}-${world}-${cat}-${slot}`, // stable for the run (save-safe ledger key) systemId: rec.id, systemName: String(rec.name ?? rec.id), worldKey: world, // the board's world (the id's world part — a planet's p / a station's s) faction: ctx.context === 'faction' ? rec.faction : null, category: cat, label: meta.label, accent: meta.accent, title, issuer, description, reward: rollSideQuestReward(cat, rec.d, r), // The completion socket: the type's objective spec (its steps, its // witness, its payout rule) is built WITH the type and rides here — // the UI already renders objective.text when one stands. objective: { type: cat }, // THE WAR MODEL — the contract's named rival + hostile ground (a // faction board's patrol/bounty/escort; neutral postings carry none): // enemyFaction — the sighted / target / captor faction (one of the // board's two rivals; the roster the combat draws); // gateLevel — the jump-gate level that opens the hop to the // target system (the accept's lock, beside minLevel); // targetSystem* — the PATROL's ground (its own system — // patrolTargetSystem) or the BOUNTY's mark's home // (the hostile neighbor); // enemySystem* — the ESCORT's trapped transport's ground (the // hostile neighbor — the convoy's RENDEZVOUS system; // its destination is the board's own system, here). ...(enemyFaction ? { enemyFaction } : {}), ...(gateLevel > 0 ? { gateLevel } : {}), ...(cat === 'patrol' ? patrolTargetFields(rec, r, galaxy) : {}), ...(cat === 'bounty' ? { dossier: buildBountyDossier(v, pctx), targetSystemId: ground ? ground.id : rec.id, targetSystemName: ground ? ground.name : String(rec.name ?? rec.id), } : {}), ...(cat === 'escort' && factionCtx && ground ? { enemySystemId: ground.id, enemySystemName: ground.name, destSystemId: rec.id, // the friendly territory: the board's own system destSystemName: String(rec.name ?? rec.id), } : {}), }; } /** The patrol posting's target fields (its own system, or the neighbor's). */ function patrolTargetFields(rec, r, galaxy) { const t = patrolTargetSystem(rec, r, galaxy); return t ? { targetSystemId: t.id, targetSystemName: t.name } : { targetSystemId: rec.id, targetSystemName: String(rec.name ?? rec.id) }; } /** * The seed's JOB SPACE — every id the ledger could lawfully hold: each * system × its SURFACE WORLDS (every planet — `p` — and every * free-space station — the `s` settlement) × the full slot range * (the range's max — a superset of what any enabled set can post) × all * four categories. The worlds come from the system's CONTENT (the * galaxy's ensureContent — lazy === eager, the same worlds the player * lands on; a hand-built caller without one degrades to an empty * space, which drops every ledger id — a foreign save rather than a * broken one). Used by SideQuestState.fromJSON to drop ledger ids the * save's galaxy never posted (a hand-edited or foreign ledger degrades * instead of sticking) — and to shed the OLD per-system ids (the board * went per-world; a contract a world never posted can't survive a load). * * @param {{records: object[], ensureContent: (id: string) => object}} galaxy the restored galaxy (Galaxy — records + ensureContent) * @returns {Set} */ export function allSideQuestJobIds(galaxy) { const set = new Set(); const records = galaxy && Array.isArray(galaxy.records) ? galaxy.records : null; if (!records || typeof galaxy.ensureContent !== 'function') return set; const [, hi] = sideQuestCountRange(); for (const rec of records) { if (!rec || typeof rec.id !== 'string' || rec.id === '') continue; let content = null; try { content = galaxy.ensureContent(rec.id) ?? null; } catch { content = null; // a degenerate system (a hand-built test) — no worlds stand } // The SURFACE WORLDS that can carry a board: every planet (the // world's own surface) + every free-space station (its own surface). // Planet-bound settlements sit ON their planet (the planet is the // board's world — its `s` key is never posted). The key is the // record id minus the system prefix (p / s — the // reputation/trading key, stable per seed). const worlds = []; const keyOf = (w) => { const id = w && typeof w.id === 'string' ? w.id : ''; return id.startsWith(rec.id + '-') ? id.slice(rec.id.length + 1) : null; }; for (const p of (content && Array.isArray(content.planets) ? content.planets : [])) { const k = keyOf(p); if (k) worlds.push(k); } for (const s of (content && Array.isArray(content.settlements) ? content.settlements : [])) { if (s?.anchor?.type !== 'space') continue; // a planet-bound settlement is its planet's board const k = keyOf(s); if (k) worlds.push(k); } for (const wk of worlds) { for (let slot = 0; slot < hi; slot++) { for (const cat of SIDE_QUEST_CATEGORIES) { set.add(`sq-${rec.id}-${wk}-${cat}-${slot}`); } } } } return set; } /** * The run's SIDE QUEST ledger (pure; save-ready): which posted contracts * the player has ACCEPTED (in the order taken), which is the ONE ACTIVE * contract being flown, which of the taken contracts have COMPLETED, * which of those have been CLAIMED, and the PATROL route record (its * target system, its waypoints, its visited marks). The ids are * postings' ids (sq---- — the world's key: a * planet's p / a station's s) — the postings themselves * regenerate from the seed, so the save carries only the marks (a save = * seed + player state). * * Invariants (the scene's flows lean on them): * - completed ⊆ accepted, claimed ⊆ completed; * - accept / complete / claim are idempotent (a double-press or a * re-open can't double-take, double-pay, or re-open a contract); * - ONE active contract at a time (activeId) — reconcileActive keeps * a standing active valid, and otherwise stands the TOP accepted * contract (acceptedOrder's head) that is not done + claimed; null * when no contract is out; * - a save predating the feature (no field) loads an empty ledger * (old saves load); ids the save's galaxy never posted are dropped, * not propagated; a route record only for a contract this ledger * holds (a route for a foreign id degrades away). */ /** * A route record's ENCOUNTERS spec, normalized (SideQuestState.setRoute's * pass): the at/cleared indices valid for the route (distinct, sorted; * cleared ⊆ at), the roster's type ids (combat.enemyTypes — unknown ids * dropped, the established type standing when the roster empties), the * wave shape finite. A malformed or missing spec degrades to null (an * encounter-free route — the waypoints still stand the contract), so a * hand-edited save or a future data shape can't break the patrol. * @param {*} raw the raw spec (derivePatrolEncounters' result, or a save's) * @param {number} wpCount the route's waypoint count * @returns {?{at:number[], cleared:number[], roster:string[], wave:object}} */ function normalizePatrolEncounters(raw, wpCount) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; const at = [...new Set( (Array.isArray(raw.at) ? raw.at : []).filter( (i) => Number.isInteger(i) && i >= 0 && i < wpCount, ), )].sort((a, b) => a - b); if (at.length === 0) return null; // no contact stands (the route's waypoints do) const cleared = [...new Set( (Array.isArray(raw.cleared) ? raw.cleared : []).filter( (i) => Number.isInteger(i) && at.includes(i), ), )].sort((a, b) => a - b); const roster = rosterWeights(raw.roster, null); const wave = patrolEncounterWave(); const w = raw.wave && typeof raw.wave === 'object' && !Array.isArray(raw.wave) ? raw.wave : {}; const kind = w.kind === 'fixed' ? 'fixed' : wave.kind; const total = Math.max(1, Math.floor(numOr(w.total, wave.total))); const out = { at, cleared, roster: roster ?? establishedRoster(), xpScale: Math.max(0, numOr(raw.xpScale, 1)), wave: { kind, total, maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, wave.maxConcurrent)))), }, }; if (kind === 'rolling') out.wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, wave.replacePerKill))); return out; } // ------------------------------------------- the ESCORT (category 'escort') // The convoy job (data/sidequests.json → escort). A neutral TRANSPORT (its own // ship class — js/combat/TransportShip.js) plus 1–2 escort fighters stands at // a single RENDEZVOUS waypoint in the posting's own system; the player links // it and flies the convoy through 1–2 gate hops to a seeded DESTINATION // (a planet or a free-space station) in a gate-reachable NEUTRAL system, // through a heavily elevated lane of mixed-fighter waves (its own director, // data → escort.encounters). If the transport is destroyed the contract fails // (phase 'failed') and the player must LAND ON A PLANET to reset it (a fresh // convoy stands at the rendezvous); arriving at the destination object with // the convoy intact and the sky clear completes it. // // The scene's share (js/scenes/GameScene.js → _establishEscort / updateEscort) // uses these pure pieces: // escortDestinationFor(…) — the seeded 1–2 hop destination + object (fork // 'dest'; a signed contract is a signed contract) // escortEncounterXp(plan) — the claim's XP beside the credits (the lane's worth) // escortWaveRoster(level) — the mixed roster for the player's combat level // escortRouteComplete(route) — the delivery + sky-clear witness (scene-side) /** A system record is NEUTRAL when it carries no faction (null/''/'neutral'). */ function isNeutralSystem(rec) { if (!rec) return false; const f = rec.faction; return typeof f !== 'string' || f === '' || f.toLowerCase() === 'neutral'; } /** * The escort's DESTINATION — a planet or a free-space station 1–2 gate hops * away in a gate-reachable NEUTRAL system (data → escort.destinationHops), * rolled seeded on its own labelled fork (seed, 'sidequests', , * 'escort', , 'dest'). The origin is the posting's system (where the * convoy stands); the walk takes NEUTRAL neighbors only (the neutral-only * scope), the 2-hop leg falling back to a 1-hop when the far leg gives up. * A destination object (planet first, then a free-space station, then any * laid-down object) is picked seeded, so the same contract always points at * the same world across re-accepts, jumps, and saves. * @param {string|number} seed the galaxy's seed * @param {string} jobId the accepted contract's id * @param {string} originSystemId the convoy's standing system (the posting's) * @param {object|null} [galaxy] the galaxy (jumpGatesFor / ensureContent) * @returns {?{hops:number, path:string[], destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string}} */ export function escortDestinationFor(seed, jobId, originSystemId, galaxy = null) { const origin = String(originSystemId ?? '').trim(); if (!origin || !galaxy || typeof galaxy.jumpGatesFor !== 'function') return null; const hopsRange = config.get('sidequests.escort.destinationHops', [1, 2]); let lo = Math.max(1, Math.floor(Number(Array.isArray(hopsRange) ? hopsRange[0] : 1))); let hi = Math.max(lo, Math.floor(Number(Array.isArray(hopsRange) ? hopsRange[1] : 2))); const r = Rng.derive(seed, seedLabel(), origin, 'escort', String(jobId), 'dest'); const hops = r.int(lo, hi); const neighbors = (id) => { let links = null; try { links = galaxy.jumpGatesFor(id) ?? []; } catch { links = []; } return (Array.isArray(links) ? links : []) .filter((nb) => nb && typeof nb.id === 'string' && nb.id !== '' && nb.id !== origin && isNeutralSystem(nb)); }; // The 1-hop set (neutral neighbors of the origin, the origin itself out). const first = neighbors(origin); if (first.length === 0) return null; // a dead-end origin (a leaf with one gate back) — no destination stands let dest = null; let path = [origin]; if (hops >= 2) { const mid = first[r.int(0, first.length - 1)]; const second = neighbors(mid).filter((nb) => nb.id !== mid.id); if (second.length > 0) { dest = second[r.int(0, second.length - 1)]; path = [origin, mid.id, dest.id]; } } if (!dest) { // The 2-hop leg gave up (or asked for one): a 1-hop destination stands. dest = first[r.int(0, first.length - 1)]; path = [origin, dest.id]; } // The destination OBJECT — a planet first, then a free-space station, then // any laid-down object (a leaf with no planets/stations still stands). let content = null; try { content = galaxy.ensureContent(dest.id) ?? null; } catch { content = null; } const planets = (content && Array.isArray(content.planets) ? content.planets : []) .filter((p) => Number.isFinite(p?.x) && Number.isFinite(p?.y)); const stations = (content && Array.isArray(content.settlements) ? content.settlements : []) .filter((s) => s?.anchor?.type === 'space' && Number.isFinite(s?.x) && Number.isFinite(s?.y)); let obj = null; let type = ''; if (planets.length > 0) { obj = planets[r.int(0, planets.length - 1)]; type = 'planet'; } else if (stations.length > 0) { obj = stations[r.int(0, stations.length - 1)]; type = 'station'; } else { const any = (content ? [...(content.planets ?? []), ...(content.settlements ?? [])] : []) .filter((o) => Number.isFinite(o?.x) && Number.isFinite(o?.y)); if (any.length === 0) return null; // a system with nothing laid down — no destination obj = any[r.int(0, any.length - 1)]; type = obj?.anchor?.type === 'space' ? 'station' : 'planet'; } return { hops: path.length - 1, path, destSystemId: dest.id, destSystemName: String(dest.name ?? dest.id), destObjectType: type, destObjectId: String(obj?.id ?? ''), destObjectName: String(obj?.name ?? obj?.id ?? ''), }; } /** * The WAR-MODEL ESCORT's DESTINATION OBJECT — the delivery world INSIDE the * board's OWN (friendly) system: a planet first, then a free-space station, * then any laid-down object, picked seeded on its own labelled fork (seed, * 'sidequests', , 'escort', , 'friend-dest') so the same * contract always points at the same world across re-accepts, jumps, and * saves. (The convoy's RENDEZVOUS stands in the hostile neighbor; its * DELIVERY is this friendly world — 0 hops, the board's own territory.) * @param {string|number} seed the galaxy's seed * @param {string} jobId the accepted contract's id * @param {string} friendlySystemId the board's own (friendly) system * @param {object|null} [galaxy] the galaxy (ensureContent) * @returns {?{hops:number, path:string[], destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string}} */ export function escortFriendlyDestination(seed, jobId, friendlySystemId, galaxy = null) { const id = String(friendlySystemId ?? '').trim(); if (!id || !galaxy || typeof galaxy.ensureContent !== 'function') return null; const rec = galaxy.byId?.get?.(id) ?? null; const r = Rng.derive(seed, seedLabel(), id, 'escort', String(jobId), 'friend-dest'); let content = null; try { content = galaxy.ensureContent(id) ?? null; } catch { content = null; } const planets = (content && Array.isArray(content.planets) ? content.planets : []) .filter((p) => Number.isFinite(p?.x) && Number.isFinite(p?.y)); const stations = (content && Array.isArray(content.settlements) ? content.settlements : []) .filter((s) => s?.anchor?.type === 'space' && Number.isFinite(s?.x) && Number.isFinite(s?.y)); let obj = null; let type = ''; if (planets.length > 0) { obj = planets[r.int(0, planets.length - 1)]; type = 'planet'; } else if (stations.length > 0) { obj = stations[r.int(0, stations.length - 1)]; type = 'station'; } else { const any = (content ? [...(content.planets ?? []), ...(content.settlements ?? [])] : []) .filter((o) => Number.isFinite(o?.x) && Number.isFinite(o?.y)); if (any.length === 0) return null; // a system with nothing laid down — no destination obj = any[r.int(0, any.length - 1)]; type = obj?.anchor?.type === 'space' ? 'station' : 'planet'; } return { hops: 0, path: [id], destSystemId: id, destSystemName: String(rec?.name ?? id), destObjectType: type, destObjectId: String(obj?.id ?? ''), destObjectName: String(obj?.name ?? obj?.id ?? ''), }; } /** * The escort's mixed-fighter ROSTER at the player's combat level (data → * escort.encounters.bands: a { type: weight } table over combat.enemyTypes * ids, the unknown-id rule being the patrol's — a type that isn't in * combat.enemyTypes never ships; a roster that empties takes the standing * type). A band that doesn't stand falls back to the data's fallbackRoster. * THE WAR MODEL (a faction escort — `enemyFaction`, the captors): the * lane's waves are THAT faction's fighters (its registered roster, equal * weight) — a trapped {faction} transport is hunted by its captors, not * by pirates; a neutral escort (no faction) keeps the data bands. * @param {number} level the player's combat level * @param {string|null} [enemyFaction] the captors' faction (the posting's enemyFaction) * @returns {{roster: Record, total:number, maxConcurrent:number, replacePerKill:number}} */ export function escortWaveRoster(level = 0, enemyFaction = null) { const valid = new Set(enemyTypeIds()); const factionMix = (String(enemyFaction ?? '').trim() !== '' ? (factionTypeIds(enemyFaction) ?? []).map((t) => String(t).trim()).filter((t) => t !== '' && valid.has(t)) : []); if (factionMix.length > 0) { const roster = {}; for (const t of factionMix) roster[t] = 1; const w = config.get('sidequests.escort.encounters.wave', {}); const total = Math.max(1, Math.floor(numOr(w.total, 8))); return { roster, total, maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 4)))), replacePerKill: Math.max(0, Math.floor(numOr(w.replacePerKill, 1))), }; } const bands = config.get('sidequests.escort.encounters.bands', null); const lv = Math.max(0, Math.floor(Number(level) || 0)); let band = null; if (Array.isArray(bands)) { for (const b of bands) { if (!b) continue; const lv2 = Array.isArray(b.levels) ? b.levels : [0, 999999]; if (lv >= (Number(lv2[0]) || 0) && lv <= (Number(lv2[1]) || 999999)) { band = b; break; } } } const raw = band?.roster ?? config.get('sidequests.escort.encounters.fallbackRoster', { raider: 1 }); const roster = {}; if (raw && typeof raw === 'object' && !Array.isArray(raw)) { for (const [t, w] of Object.entries(raw)) { const key = String(t).trim(); if (key === '' || !valid.has(key)) continue; const wt = Math.max(0, Number(w) || 0); if (wt > 0) roster[key] = wt; } } if (Object.keys(roster).length === 0) roster[ESTABLISHED_ENEMY_TYPES[0]] = 1; const w = config.get('sidequests.escort.encounters.wave', {}); const total = Math.max(1, Math.floor(numOr(w.total, 8))); return { roster, total, maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 4)))), replacePerKill: Math.max(0, Math.floor(numOr(w.replacePerKill, 1))), }; } /** * The escort contract's XP PAYOUT (the claim's second reward — beside the * credits): the lane's experience WORTH, a flat worth scaled off the lane's * wave (the data's wave total × the roster's mean xp × the hops). Fixed at * the accept (a signed contract is a signed contract). * @param {{hops?:number, level?:number}} [o] * @returns {number} */ export function escortEncounterXp(o = {}) { const w = escortWaveRoster(Number(o?.level) || 0); const hops = Math.max(1, Math.floor(numOr(o?.hops, 1))); let weightSum = 0; let xpWeighted = 0; for (const [t, wt] of Object.entries(w.roster)) { weightSum += wt; xpWeighted += wt * Math.max(0, Number(enemyTypeConfig(String(t))?.xp) || 0); } if (weightSum === 0) return 0; const meanXp = xpWeighted / weightSum; return Math.max(0, Math.round(w.total * meanXp * (1 + 0.25 * (hops - 1)))); } /** * An escort route's `escort` sub-record, normalized (SideQuestState.setRoute's * pass): a valid phase, a destination system + object (finite), the transport's * armor finite (clamped to a sane floor), the escort count a small int. A * malformed record degrades to a fresh 'rendezvous' record on the same * destination (the destination is the contract's signature — it is never * dropped), so a hand-edited save or a future data shape can't break the * convoy (it re-stands, at full armor, at the rendezvous). * @param {*} raw the raw spec (_establishEscort's result, or a save's) * @returns {{phase:string, originSystemId:string, originSystemName:string, destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string, hops:number, transportHull:number, transportShields:number, escortCount:number, name:string}} */ function normalizeEscort(raw) { const PHASES = ['rendezvous', 'transit', 'failed', 'delivered', 'delivering']; const phase = PHASES.includes(raw?.phase) ? raw.phase : 'rendezvous'; const escortCountRange = config.get('sidequests.escort.escortCount', [1, 2]); const eLo = Math.max(0, Math.floor(Number(Array.isArray(escortCountRange) ? escortCountRange[0] : 1))); const eHi = Math.max(eLo, Math.floor(Number(Array.isArray(escortCountRange) ? escortCountRange[1] : 2))); const num = (v, fb) => (Number.isFinite(Number(v)) ? Math.max(0, Number(v)) : fb); return { phase, originSystemId: String(raw?.originSystemId ?? ''), originSystemName: String(raw?.originSystemName ?? ''), destSystemId: String(raw?.destSystemId ?? ''), destSystemName: String(raw?.destSystemName ?? ''), destObjectType: raw?.destObjectType === 'station' ? 'station' : 'planet', destObjectId: String(raw?.destObjectId ?? ''), destObjectName: String(raw?.destObjectName ?? ''), hops: Math.max(0, Math.floor(num(raw?.hops, 1))), // THE ARMOR — the STANDING (max) hull is the reset's full armor; the LIVE // hull is clamped to it. An old save (pre-max fields) carries only the // live armor: the max falls back to it (a degraded record can't gain // armor it never had). Live can't exceed max (a hand-edited save can't // out-heal the standing hull). transportMaxHull: (() => { const live = Math.max(1, Math.floor(num(raw?.transportHull, 1200))); return Math.max(1, Math.floor(num(raw?.transportMaxHull, live))); })(), transportMaxShields: (() => { const live = Math.max(0, Math.floor(num(raw?.transportShields, 500))); return Math.max(0, Math.floor(num(raw?.transportMaxShields, live))); })(), transportHull: Math.min(Math.max(1, Math.floor(num(raw?.transportHull, 1200))), Math.max(1, Math.floor(num(raw?.transportMaxHull, 1200)))), transportShields: Math.min(Math.max(0, Math.floor(num(raw?.transportShields, 500))), Math.max(0, Math.floor(num(raw?.transportMaxShields, 500)))), escortCount: Math.min(eHi, Math.max(eLo, Math.floor(num(raw?.escortCount, Math.floor((eLo + eHi) / 2))))), name: String(raw?.name ?? ''), // THE WAR MODEL — the captors' faction (the lane's roster; a neutral // escort / an old save carries none → '' → the classic pirate-lane law). enemyFaction: (() => { const f = String(raw?.enemyFaction ?? '').trim(); return f; })(), }; } export class SideQuestState { constructor() { this.accepted = new Set(); this.acceptedOrder = []; // the order taken (the active slot's "top") this.completed = new Set(); // completed ⊆ accepted this.claimed = new Set(); // claimed ⊆ completed this.activeId = null; // the ONE contract being flown (taken, not done, not claimed) this.routes = new Map(); // id → { targetSystemId, credits, xp, waypoints, visited, encounters } } /** * Take the contract (idempotent — re-accepting is a no-op). * @returns {boolean} true when NEWLY accepted */ accept(id) { if (typeof id !== 'string' || id === '') return false; if (this.accepted.has(id)) return false; this.accepted.add(id); this.acceptedOrder.push(id); return true; } isAccepted(id) { return this.accepted.has(id); } /** The taken order (the ledger's "top" is index 0). */ acceptedList() { return [...this.acceptedOrder]; } /** * Mark a taken contract COMPLETE (the type's objective stood — the * scene's call when a category lands; only ACCEPTED contracts can * complete — completed ⊆ accepted). A done contract drops out of the * ACTIVE slot (reconcileActive — the next top one stands). * @returns {boolean} true when NEWLY completed */ complete(id) { if (typeof id !== 'string' || id === '') return false; if (!this.accepted.has(id)) return false; if (this.completed.has(id)) return false; this.completed.add(id); this.reconcileActive(); return true; } isCompleted(id) { return this.completed.has(id); } /** * Mark a completed contract CLAIMED (the reward is paid exactly once — * idempotent; claimed ⊆ completed, so an uncompleted contract can't * be claimed). A claimed contract drops out of the ACTIVE slot. * @returns {boolean} true when NEWLY claimed */ claim(id) { if (typeof id !== 'string' || id === '') return false; if (!this.completed.has(id)) return false; if (this.claimed.has(id)) return false; this.claimed.add(id); this.reconcileActive(); return true; } isClaimed(id) { return this.claimed.has(id); } isActive(id) { return this.activeId === id; } /** * Stand the ACTIVE contract — the ONE being flown. It must be taken, * not done, not claimed (the board's door only offers it in that state * anyway — this is the belt-and-braces pass). * @returns {boolean} true when it stands */ setActive(id) { if (typeof id !== 'string' || id === '') return false; if (!this.accepted.has(id) || this.completed.has(id) || this.claimed.has(id)) return false; this.activeId = id; return true; } /** * The ACTIVE slot's truth: a standing active (taken, not done, not * claimed) is kept; otherwise the TOP accepted contract (acceptedOrder's * head — "if no quest is active and the player has accepted quests, the * top accepted stands") that is not done + claimed takes the slot; null * when no contract is out. * @returns {string|null} */ reconcileActive() { if ( this.activeId && this.accepted.has(this.activeId) && !this.completed.has(this.activeId) && !this.claimed.has(this.activeId) ) { return this.activeId; } this.activeId = this.acceptedOrder.find((id) => !this.completed.has(id) && !this.claimed.has(id)) ?? null; return this.activeId; } // ------------------------------------------------- the patrol's ROUTE /** * Stand a contract's ROUTE record (the patrol's waypoints + the * contract's ENCOUNTERS — the scene's buildPatrolRoute + * derivePatrolEncounters results, at the accept). Only a TAKEN * contract can carry a route (a route for a foreign id degrades away); * the record is validated + normalized (finite waypoint pairs, visited * indices in range, no duplicates; the encounters' at/cleared indices * valid for the route, the roster the data's type ids, the wave shape * finite — a malformed spec degrades to an encounter-free route rather * than a broken one). @returns {boolean} true when it stands */ setRoute(id, route) { if (typeof id !== 'string' || id === '') return false; if (!this.accepted.has(id)) return false; if (!route || typeof route !== 'object') return false; const waypoints = (Array.isArray(route.waypoints) ? route.waypoints : []) .filter((w) => Array.isArray(w) && w.length >= 2 && Number.isFinite(w[0]) && Number.isFinite(w[1])) .map((w) => [Math.round(w[0]), Math.round(w[1])]); const visited = [...new Set( (Array.isArray(route.visited) ? route.visited : []).filter( (i) => Number.isInteger(i) && i >= 0 && i < waypoints.length, ), )]; const kind = route.kind === 'bounty' ? 'bounty' : route.kind === 'escort' ? 'escort' : 'patrol'; const encounters = normalizePatrolEncounters(route.encounters, waypoints.length); const bounty = kind === 'bounty' ? normalizeBountyPlan(route.bounty, waypoints.length) : null; // The ESCORT's convoy record — its phase + destination + the transport's // armor (the live read-out the scene syncs each frame) + the escort // count + the convoy's name. Only an escort route carries one; the // destination is the contract's signature and is never dropped (a // malformed record re-stands a fresh 'rendezvous' on the same world). const escort = kind === 'escort' ? normalizeEscort(route.escort ?? route) : null; this.routes.set(id, { // The route's TYPE — 'patrol' (the default: every patrol, and every // save written before the hunts existed), 'bounty' (a hunt: SEQUENTIAL // legs + the hidden plan), or 'escort' (a convoy: the rendezvous + the // transport + the destination). The type is what branches the scene's // update; a patrol record normalizes exactly as it always did. kind, targetSystemId: String(route.targetSystemId ?? ''), credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's CREDITS payout — fixed at the accept // The contract's XP payout (the claim's second reward — beside the // credits): the accept fixes it on the record (patrolEncounterXp / // escortEncounterXp, the scene's _establishPatrol / _establishEscort); // a route that stands without one (an old save) derives it from its // own spec — the same contract, a signed contract is a signed contract. xp: Number.isFinite(Number(route.xp)) && Number(route.xp) >= 0 ? Number(route.xp) : kind === 'bounty' ? bountyHuntXp(bounty) : kind === 'escort' ? escortEncounterXp({ hops: escort?.hops ?? 1, level: 0 }) : patrolEncounterXp(encounters), waypoints, visited, encounters, bounty, escort, }); return true; } /** * Mark a contract's contact CLEARED (the wave run completed — the * scene's _firePatrolContact's onComplete). Idempotent — the run's * completion fires once; a re-fire (a mid-fight quit re-arms the * contact — the witness is the completion, not the attempt) marks it * again without a second effect. A cleared contact's index is always a * route waypoint index (validated). @returns {boolean} true when NEWLY cleared */ encounterCleared(id, idx) { const route = this.routes.get(id); if (!route || !route.encounters) return false; if (!Number.isInteger(idx) || !route.encounters.at.includes(idx)) return false; if (route.encounters.cleared.includes(idx)) return false; route.encounters.cleared.push(idx); return true; } /** * A contract's route record (the scene's check-ins + the board's route * line + the claim's payout read it). * @returns {{kind:string, targetSystemId:string, credits:number, xp:number, waypoints:number[][], visited:number[], encounters:?object, bounty:?object}|null} */ routeFor(id) { return this.routes.get(id) ?? null; } /** * Check off a waypoint (idempotent — the scene's per-frame pass may * see the ship inside the radius on several frames; only the FIRST * logs). The route is a PATH, flown IN ORDER (1, 2, 3, …): only the * NEXT waypoint — the first unvisited one — logs, a patrol exactly as * a hunt. @returns {boolean} true when NEWLY logged */ visitWaypoint(id, idx, opts = {}) { const route = this.routes.get(id); if (!route) return false; if (!Number.isInteger(idx) || idx < 0 || idx >= route.waypoints.length) return false; if (route.visited.includes(idx)) return false; // The SEQUENTIAL LAW (both kinds — a patrol's waypoints and a hunt's // legs): only the NEXT waypoint logs (the first unvisited, 1, 2, 3…). // The ONE exception is the hunt's kill's own check-off (`opts.anyOrder`: // the mark came down and what is left of the lane is signed off with it). if (opts.anyOrder !== true) { const next = nextBountyWaypoint(route); if (!next || next.index !== idx) return false; } route.visited.push(idx); return true; } /** * A hunt's PICKET wave cleared (the leg's raid wave ran to completion — the * witness is the completion, not the attempt, exactly as a patrol contact). * Only a leg that ROLLED a picket can clear. @returns {boolean} true when NEWLY cleared */ bountySkirmishCleared(id, idx) { const route = this.routes.get(id); const b = route?.bounty; if (!b || !Number.isInteger(idx) || idx < 0 || idx >= (b.pickets?.length ?? 0)) return false; if (b.pickets[idx] !== true) return false; if (b.skirmishCleared.includes(idx)) return false; b.skirmishCleared.push(idx); return true; } /** * THE MARK IS DOWN — the hunt's completion witness (the kill, then every * survivor of its escort put down). It sets the plan's `down` mark and * checks off EVERY remaining leg in the same breath (a hunt ends on the * kill, not on the flying), so bountyRouteComplete stands from here and no * further leg fires. Idempotent — a double witness marks once. * @returns {boolean} true when NEWLY witnessed */ bountyDown(id) { const route = this.routes.get(id); const b = route?.bounty; if (!b) return false; for (let i = 0; i < route.waypoints.length; i++) { if (!route.visited.includes(i)) route.visited.push(i); } if (b.down === true) return false; b.down = true; return true; } toJSON() { return { accepted: [...this.acceptedOrder], completed: [...this.completed], claimed: [...this.claimed], activeId: this.activeId, routes: Object.fromEntries(this.routes), }; } /** * Restore a ledger from a save record. A malformed record yields an * empty ledger rather than a throw (contracts are a progression * nicety, not a crash vector — the QuestState precedent). With a * `known` job space (allSideQuestJobIds of the restored galaxy) only * ids inside it survive — a foreign or hand-edited id degrades. The * active slot is restored through reconcileActive: a standing saved * active is kept, else the top accepted stands (the save's order). * @param {object} data the record.sidequests shape * @param {Set|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; } }