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