395 lines
16 KiB
JavaScript
395 lines
16 KiB
JavaScript
/**
|
||
* SideQuests — the job board's model: the contracts posted on every
|
||
* system EXCEPT the player's home (safe space — the Explore drawer's
|
||
* own gate, data/actionbar.json → surface.explore.items), and the run's
|
||
* ledger of the contracts it has taken.
|
||
*
|
||
* LAYERING — the board is a VIEW over this (js/ui/JobBoardWindow.js):
|
||
* - the WINDOW renders sideQuestPostingsFor(seed, record) (the current
|
||
* system's postings) and hands the player's ACCEPT to the scene;
|
||
* - the SCENE (GameScene.acceptSideQuest — the claimSurfaceQuest
|
||
* precedent: state + effects there, the note on the surface) lands it
|
||
* 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, and the accepted/completed 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. All four start OFFLINE
|
||
* (enabled:false) because none of the four objective/completion rule
|
||
* sets is built yet, and the gate is the contract: a category's jobs
|
||
* appear on the board ONLY when its rules land and its flag flips. A
|
||
* posted job is therefore always a job whose category is live (the
|
||
* completion wiring for the type arrives with the type — posting
|
||
* posting.objective, below, is its typed socket).
|
||
*
|
||
* DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") —
|
||
* postings derive from the galaxy seed + the system record through
|
||
* LABELLED forks: Rng.derive(seed, 'sidequests', <sysId>, …). The same
|
||
* contract stands on the same system for the whole run — across visits,
|
||
* scene restarts, and saves — and a different system draws its own (the
|
||
* variant pool keeps the galaxy's boards from reading as one repeated
|
||
* ad). Nothing here depends on call order.
|
||
*
|
||
* FLAVOR — the variant pools split by SYSTEM CONTEXT
|
||
* (data/sidequests.json → descriptions): NEUTRAL systems post about
|
||
* keeping their system safe from PIRATES; a FACTION's systems post about
|
||
* pushing the OTHER factions out of their space. The faction pools carry
|
||
* placeholders, resolved at generation (never stored): {system} = the
|
||
* system's name, {faction} = the system's faction label
|
||
* (data/factions.json), {issuer} = that faction's board name
|
||
* (data/sidequests.json → factions), {enemies} = the other two
|
||
* factions' labels ("MindWeave and Zenith").
|
||
*/
|
||
import { config } from '../config/Config.js';
|
||
import { Rng } from '../utils/Rng.js';
|
||
|
||
/** The four job types, canonical slot order (the data's category keys). */
|
||
export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort'];
|
||
|
||
/** The three player factions (data/factions.json minus 'neutral'). */
|
||
const FACTIONS = ['apex', 'mindweave', 'zenith'];
|
||
|
||
const clamp01 = (v) => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
|
||
|
||
/** The feature's master switch (data/sidequests.json → enabled). */
|
||
export function sideQuestsEnabled() {
|
||
return config.get('sidequests.enabled', true) !== false;
|
||
}
|
||
|
||
/**
|
||
* The postings-per-system roll range (data/sidequests.json →
|
||
* board.perSystem). A malformed range falls back to the design's 3–4.
|
||
* @returns {[number, number]} [min, max] (integers, min ≤ max)
|
||
*/
|
||
export function sideQuestCountRange() {
|
||
const raw = config.get('sidequests.board.perSystem', null);
|
||
if (!Array.isArray(raw) || raw.length < 2) return [3, 4];
|
||
let lo = Math.floor(Number(raw[0]));
|
||
let hi = Math.floor(Number(raw[1]));
|
||
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 4];
|
||
lo = Math.max(1, lo);
|
||
hi = Math.max(lo, hi);
|
||
return [lo, hi];
|
||
}
|
||
|
||
/** The Rng fork label under the galaxy seed (data/sidequests.json → board.seedLabel). */
|
||
function seedLabel() {
|
||
const s = String(config.get('sidequests.board.seedLabel', 'sidequests') || 'sidequests').trim();
|
||
return s || 'sidequests';
|
||
}
|
||
|
||
/**
|
||
* A category's meta (data/sidequests.json → categories.<id>).
|
||
* @returns {{id:string, enabled:boolean, label:string, accent: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;
|
||
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'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* @returns {string[]}
|
||
*/
|
||
export function enabledSideQuestCategories() {
|
||
return SIDE_QUEST_CATEGORIES.filter((id) => sideQuestCategoryMeta(id)?.enabled === true);
|
||
}
|
||
|
||
/**
|
||
* Faction id → label (data/factions.json → factions), the three player
|
||
* factions only. A missing entry falls back to the id's own casing so a
|
||
* hand-edited table can't blank the board's copy.
|
||
* @returns {Record<string,string>}
|
||
*/
|
||
export function sideQuestFactionLabels() {
|
||
const table = config.get('factions.factions', null);
|
||
const out = {};
|
||
for (const id of FACTIONS) {
|
||
const entry = table && typeof table === 'object' ? table[id] : null;
|
||
const label = entry && typeof entry.label === 'string' && entry.label ? entry.label : id;
|
||
out[id] = label.charAt(0).toUpperCase() + label.slice(1);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Resolve the pool's placeholders in a line of copy (resolveSideQuestText's
|
||
* input context). {system} / {faction} / {issuer} / {enemies} — the
|
||
* neutral context leaves the faction-side keys empty (its pools don't
|
||
* carry them).
|
||
* @param {object} rec a system record ({ id, name, faction, … })
|
||
*/
|
||
function postingContext(rec) {
|
||
const system = String(rec?.name ?? rec?.id ?? 'this system');
|
||
const faction = typeof rec?.faction === 'string' && rec.faction !== '' ? rec.faction : null;
|
||
if (!faction) return { context: 'neutral', system, faction: '', issuer: '', enemies: '' };
|
||
const labels = sideQuestFactionLabels();
|
||
const label = labels[faction] ?? faction;
|
||
const issuer =
|
||
String(config.get(`sidequests.factions.${faction}`, '') || '').trim() || `${label} Authority`;
|
||
const others = FACTIONS.filter((f) => f !== faction).map((f) => labels[f] ?? f);
|
||
const enemies =
|
||
others.length === 2 ? `${others[0]} and ${others[1]}` : others.join(', ');
|
||
return { context: 'faction', system, faction: label, issuer, enemies };
|
||
}
|
||
|
||
/**
|
||
* The pool's placeholder resolution (the data's contract): {system},
|
||
* {faction}, {issuer}, {enemies} → the context's values. split/join (no
|
||
* $-quirks). A placeholder with no value in the context (a {faction} in
|
||
* a neutral pool — a data bug) resolves to '' rather than leaking its
|
||
* braces.
|
||
* @param {string} text the pool line
|
||
* @param {object} ctx the postingContext() shape
|
||
* @returns {string}
|
||
*/
|
||
export function resolveSideQuestText(text, ctx = null) {
|
||
if (typeof text !== 'string' || text === '') return '';
|
||
ctx = ctx && typeof ctx === 'object' ? ctx : {};
|
||
const map = {
|
||
system: ctx.system ?? '',
|
||
faction: ctx.faction ?? '',
|
||
issuer: ctx.issuer ?? '',
|
||
enemies: ctx.enemies ?? '',
|
||
};
|
||
let out = text;
|
||
for (const [key, value] of Object.entries(map)) {
|
||
if (!value) continue;
|
||
out = out.split(`{${key}}`).join(String(value));
|
||
}
|
||
// Any placeholder that still stands (no value for this context) is
|
||
// blanked — the board never ships a brace pair to the player.
|
||
out = out.replace(/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/g, '');
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The posting's CREDITS payout (data/sidequests.json → rewards): the
|
||
* category's band [min,max] rolled seeded per posting, scaled OUT with
|
||
* the system's home→far diagonal d as (1 + d * distanceScale) — the
|
||
* far systems' boards pay more for the same risk (the economy's 'a hop
|
||
* is a hop' rule). A missing band falls back to [40,80]; the result is
|
||
* rounded to 5 CR, floored at 5 CR.
|
||
* @param {string} cat the category id
|
||
* @param {number} d the system's home→far diagonal (0..1)
|
||
* @param {Rng} r the posting's seeded stream
|
||
* @returns {{credits:number}}
|
||
*/
|
||
export function rollSideQuestReward(cat, d, r) {
|
||
let band = config.get(`sidequests.rewards.${cat}`, null);
|
||
if (!Array.isArray(band) || band.length < 2 || !Number.isFinite(Number(band[0])) || !Number.isFinite(Number(band[1]))) {
|
||
band = [40, 80];
|
||
}
|
||
const scale = 1 + clamp01(d) * (Number(config.get('sidequests.rewards.distanceScale', 0.75)) || 0);
|
||
const lo = Math.min(Number(band[0]), Number(band[1]));
|
||
const hi = Math.max(Number(band[0]), Number(band[1]));
|
||
const credits = Math.max(5, Math.round((r.range(lo, hi) * scale) / 5) * 5);
|
||
return { credits };
|
||
}
|
||
|
||
/**
|
||
* The system's LIVE postings (the board's list) — 3–4 by design
|
||
* (sideQuestCountRange), drawn from the LIVE categories only
|
||
* (enabledSideQuestCategories): distinct categories while they last,
|
||
* then repeats into the variant pools (the same category can hold more
|
||
* than one posting once the count outruns the live set).
|
||
*
|
||
* Deterministic in (seed, record) — the labelled forks under
|
||
* Rng.derive(seed, seedLabel, <sysId>) make the roll, the category
|
||
* order, and every per-posting draw independent of call order, so the
|
||
* board stands the same across visits, scene restarts, and saves.
|
||
*
|
||
* A posting's shape (the window's row + detail + the scene's accept):
|
||
* { id, systemId, systemName, faction (null = neutral), category,
|
||
* label, accent, title, issuer, description, reward: { credits },
|
||
* objective: { type } } — objective is the typed socket the
|
||
* category's completion rules fill in when the type lands.
|
||
*
|
||
* @param {string|number} seed the galaxy's seed
|
||
* @param {object} rec a system record ({ id, name, faction, d, … })
|
||
* @returns {object[]} [] when the feature is off or no category is live
|
||
*/
|
||
export function sideQuestPostingsFor(seed, rec) {
|
||
if (!sideQuestsEnabled()) return [];
|
||
if (!rec || typeof rec.id !== 'string' || rec.id === '') return [];
|
||
const cats = enabledSideQuestCategories();
|
||
if (cats.length === 0) return [];
|
||
|
||
const [lo, hi] = sideQuestCountRange();
|
||
const base = Rng.derive(seed, seedLabel(), rec.id);
|
||
const count = base.int(lo, hi);
|
||
const order = base.shuffle(cats); // the system's slot order (seeded, stable)
|
||
const ctx = postingContext(rec);
|
||
|
||
const out = [];
|
||
for (let slot = 0; slot < count; slot++) {
|
||
const cat = order[slot % order.length];
|
||
const r = Rng.derive(seed, seedLabel(), rec.id, cat, slot); // per-posting stream
|
||
const posting = buildPosting(seed, rec, ctx, cat, slot, r);
|
||
if (posting) out.push(posting);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** One posting from its pool variant + reward roll (null on a bad pool entry). */
|
||
function buildPosting(seed, rec, ctx, cat, slot, r) {
|
||
const meta = sideQuestCategoryMeta(cat);
|
||
if (!meta) return null;
|
||
|
||
const poolPath = `sidequests.descriptions.${ctx.context}.${cat}`;
|
||
let pool = config.get(poolPath, null);
|
||
if (!Array.isArray(pool)) pool = config.get(`sidequests.descriptions.neutral.${cat}`, null); // the neutral voice as fallback
|
||
pool = Array.isArray(pool) ? pool : [];
|
||
const v = pool.length > 0 ? pool[r.int(0, pool.length - 1)] : null;
|
||
|
||
const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, ctx);
|
||
// The row's issuer line: the pool's own name (the neutral pools' —
|
||
// “The Harbormaster”), or the faction's board name (the faction pools'
|
||
// {issuer} placeholder — the data's sidequests.factions table), so a
|
||
// faction's postings read “ISSUER — ZENITH CONCORD”, not “The Board”.
|
||
const issuerRaw = v?.issuer ?? (ctx.issuer || 'The Board');
|
||
const issuer = resolveSideQuestText(issuerRaw, ctx) || 'The Board';
|
||
const description = resolveSideQuestText(v?.body ?? '', ctx);
|
||
|
||
return {
|
||
id: `sq-${rec.id}-${cat}-${slot}`, // stable for the run (save-safe ledger key)
|
||
systemId: rec.id,
|
||
systemName: String(rec.name ?? rec.id),
|
||
faction: ctx.context === 'faction' ? rec.faction : null,
|
||
category: cat,
|
||
label: meta.label,
|
||
accent: meta.accent,
|
||
title,
|
||
issuer,
|
||
description,
|
||
reward: rollSideQuestReward(cat, rec.d, r),
|
||
// The completion socket: the type's objective spec (its steps, its
|
||
// witness, its payout rule) is built WITH the type and rides here —
|
||
// the UI already renders objective.text when one stands.
|
||
objective: { type: cat },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The seed's JOB SPACE — every id the ledger could lawfully hold: each
|
||
* system × the full slot range (the range's max — a superset of what any
|
||
* enabled set can post) × all four categories. Used by SideQuestState.
|
||
* fromJSON to drop ledger ids the save's galaxy never posted (a
|
||
* hand-edited or foreign ledger degrades instead of sticking).
|
||
*
|
||
* @param {object[]} records the galaxy's system records (Galaxy.records)
|
||
* @returns {Set<string>}
|
||
*/
|
||
export function allSideQuestJobIds(records) {
|
||
const set = new Set();
|
||
if (!Array.isArray(records)) return set;
|
||
const [, hi] = sideQuestCountRange();
|
||
for (const rec of records) {
|
||
if (!rec || typeof rec.id !== 'string' || rec.id === '') continue;
|
||
for (let slot = 0; slot < hi; slot++) {
|
||
for (const cat of SIDE_QUEST_CATEGORIES) {
|
||
set.add(`sq-${rec.id}-${cat}-${slot}`);
|
||
}
|
||
}
|
||
}
|
||
return set;
|
||
}
|
||
|
||
/**
|
||
* The run's SIDE QUEST ledger (pure; save-ready): which posted contracts
|
||
* the player has ACCEPTED and which of those have COMPLETED. The ids are
|
||
* postings' ids (sq-<sysId>-<cat>-<slot>) — the postings themselves
|
||
* regenerate from the seed, so the save carries only the marks (a save =
|
||
* seed + player state). Invariants: completed ⊆ accepted; accept is
|
||
* idempotent (a double-press or a re-open can't double-take a contract);
|
||
* 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.
|
||
*/
|
||
export class SideQuestState {
|
||
constructor() {
|
||
this.accepted = new Set();
|
||
this.completed = new Set(); // completed ⊆ accepted
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
return true;
|
||
}
|
||
|
||
isAccepted(id) {
|
||
return this.accepted.has(id);
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
* @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);
|
||
return true;
|
||
}
|
||
|
||
isCompleted(id) {
|
||
return this.completed.has(id);
|
||
}
|
||
|
||
toJSON() {
|
||
return {
|
||
accepted: [...this.accepted],
|
||
completed: [...this.completed],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* @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)) continue;
|
||
st.accepted.add(id);
|
||
}
|
||
for (const id of list(data.completed)) {
|
||
if (!st.accepted.has(id)) continue; // completed ⊆ accepted
|
||
if (!ok(id)) continue;
|
||
st.completed.add(id);
|
||
}
|
||
return st;
|
||
}
|
||
}
|