orbit/js/quests/SideQuests.js

395 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* SideQuests — the job board's model: the contracts posted on every
* system EXCEPT the player's home (safe space — the Explore drawer's
* own gate, data/actionbar.json → surface.explore.items), and the run's
* ledger of the contracts it has taken.
*
* LAYERING — the board is a VIEW over this (js/ui/JobBoardWindow.js):
* - the WINDOW renders sideQuestPostingsFor(seed, record) (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;
}
}