orbit/js/research/ResearchModel.js

399 lines
16 KiB
JavaScript

/**
* Research — pure model: category registry, tree loading/validation, and the
* deterministic top→down tree layout. No Phaser, no DOM — node-testable
* (dev/research-builds.test.mjs). The browser-side ResearchWindow.js reads
* these; ResearchState.js holds the player's progress (also pure).
*
* Tree contract (one JSON per category, section named after the category id):
* starting: ["root-id"] already-owned nodes (fresh run)
* nodes: { id: { label, description, icon, duration, requires: [ids],
* quest?: <quest id>,
* build?: <build id>,
* unlocks: { builds, research }, effects } }
* Layout: level(id) = 0 for roots, else 1 + max(level(requires)). Columns:
* leaves take sequential slots in DFS order (data order of roots, then
* children), each interior node sits at the mean of its children's columns —
* a tidy, stable arrangement for a small tech tree.
* Optional tree hooks (dynamic categories, e.g. SystemCategory): a tree
* object may carry `available(state, id)` (extra availability gate,
* consulted by isAvailable) and `lockNote(state, id)` (the window's
* LOCKED reason when the node is gated but its requires are met).
*
* Node's optional `quest` field (data/quests.json id): a STORY-QUEST gate on
* top of `requires` — the quest's reward must be CLAIMED before the node is
* researchable. The model stays pure: attachQuestGates(tree, isQuestClaimed)
* wires the caller's live predicate into those same hooks (the scene reads
* its quest ledger — the ledger's claimed set; today: mining/premium_arm +
* mining/advanced_storage + cargo/cargo_bay_8 wait on 'special_delivery').
* Node's optional `build` field (data/builds.json id): a BUILD-COMPLETION
* gate on top of `requires` — the named build must be BUILT before the
* node is researchable. Same pure seam: attachBuildGates(tree,
* isBuildCompleted) wires the caller's live predicate (the scene reads its
* BuildState — the built set; today: cargo/cargo_bay_12 waits on the
* Improved Mining Storage build, cargo/cargo_bay_16 on the Advanced one).
* Both gates compose over each other and over a dynamic tree's own hooks.
*/
import { config } from '../config/Config.js';
import { questDef } from '../quests/QuestState.js';
/** The category registry (data/research.json → categories[]). */
export function categories() {
const list = config.get('research.categories', []);
return Array.isArray(list) ? list.filter((c) => c && typeof c.id === 'string') : [];
}
/** Load one category's tree. Returns { id, label, accent, nodes, order, starting } or null. */
export function loadCategory(catId) {
const meta = categories().find((c) => c.id === catId);
if (!meta) return null;
const section = config.section(catId, {});
const raw = (section && section.nodes) || {};
const nodes = {};
for (const [id, n] of Object.entries(raw)) {
if (id.startsWith('_') || !n || typeof n !== 'object') continue;
nodes[id] = n;
}
const order = Object.keys(nodes);
if (order.length === 0) return null;
const starting = Array.isArray(section.starting) ? section.starting.filter((id) => nodes[id]) : [];
return {
id: catId,
label: typeof meta.label === 'string' ? meta.label : catId,
accent: typeof meta.accent === 'string' ? meta.accent : '#00e5ff',
nodes,
order,
starting,
};
}
/** Root nodes (no requires). */
export function roots(tree) {
return tree.order.filter((id) => {
const r = tree.nodes[id].requires;
return !Array.isArray(r) || r.length === 0;
});
}
/**
* Structural issues (empty array = well-formed): dangling requires, cycles,
* no root. The tree must be a DAG rooted at one or more roots for the
* top→down layout to be meaningful.
*/
export function issues(tree) {
const out = [];
const known = new Set(tree.order);
for (const id of tree.order) {
for (const r of tree.nodes[id].requires ?? []) {
if (!known.has(r)) out.push(`${id} requires unknown node "${r}"`);
}
}
// Cycle check (iterative DFS, three-color).
const color = new Map(); // 0/absent white, 1 gray, 2 black
const dfs = (start) => {
const stack = [[start, (tree.nodes[start].requires ?? []).filter((r) => known.has(r))]];
color.set(start, 1);
while (stack.length) {
const [id, kids] = stack[stack.length - 1];
if (kids.length) {
const next = kids[0];
stack[stack.length - 1][1] = kids.slice(1);
if (color.get(next) === 1) {
out.push(`cycle: ${id} → ${next}`);
return;
}
if (!color.has(next)) {
color.set(next, 1);
stack.push([next, (tree.nodes[next].requires ?? []).filter((r) => known.has(r))]);
}
} else {
color.set(id, 2);
stack.pop();
}
}
};
for (const id of tree.order) if (!color.has(id)) dfs(id);
if (roots(tree).length === 0) out.push('tree has no root node');
return out;
}
/** level(id): 0 for roots, else 1 + max(level of its requires). Cycle-safe. */
export function levels(tree) {
const lvl = {};
const visiting = new Set();
const compute = (id) => {
if (lvl[id] !== undefined) return lvl[id];
if (visiting.has(id)) return 0; // cycle guard — issues() reports these
visiting.add(id);
const reqs = (tree.nodes[id].requires ?? []).filter((r) => lvl[r] !== undefined || tree.nodes[r]);
const v = reqs.length ? Math.max(...reqs.map(compute)) + 1 : 0;
visiting.delete(id);
lvl[id] = v;
return v;
};
for (const id of tree.order) compute(id);
return lvl;
}
/**
* Tidy layout. Returns {
* col: { id: number }, column slot per node (fractional for parents)
* minCol, maxCol, spread (≥ 0; 0 = single column)
* level: { id: number }, 0 = top row
* rows, number of rows (max level + 1)
* }.
*/
export function layoutTree(tree) {
const level = levels(tree);
const known = new Set(tree.order);
const children = new Map(); // parent → [child ids, data order]
for (const id of tree.order) {
for (const r of tree.nodes[id].requires ?? []) {
if (!known.has(r)) continue;
if (!children.has(r)) children.set(r, []);
children.get(r).push(id);
}
}
const col = new Map();
let slot = 0;
const assign = (id, guard) => {
if (col.has(id)) return col.get(id);
guard.add(id);
const kids = (children.get(id) ?? []).filter((c) => !guard.has(c) && known.has(c));
const v = kids.length
? kids.map((c) => assign(c, guard)).reduce((a, b) => a + b, 0) / kids.length
: slot++;
guard.delete(id);
col.set(id, v);
return v;
};
for (const id of roots(tree)) assign(id, new Set());
for (const id of tree.order) if (!col.has(id)) col.set(id, slot++); // cycle residue
const values = [...col.values()];
const maxLevel = Math.max(...Object.values(level));
return {
col: Object.fromEntries(col),
minCol: Math.min(...values),
maxCol: Math.max(...values),
level,
rows: maxLevel + 1,
};
}
/**
* Can this node be researched right now? (known + all parents researched +
* not already researched.) `state` is a ResearchState (or anything with
* isUnlocked(category, id)).
*
* A tree may also carry an optional `available(state, id)` predicate —
* an EXTRA availability gate evaluated after the requires check (the
* SYSTEM category uses it for its chart-completion gate; attachQuestGates
* uses it for a node's story-quest gate; static trees have none).
* Pure: the predicate is data the caller supplies.
*/
export function isAvailable(tree, state, id) {
const n = tree.nodes[id];
if (!n) return false;
if (state.isUnlocked(tree.id, id)) return false;
if ((n.requires ?? []).some((r) => !tree.nodes[r] || !state.isUnlocked(tree.id, r))) return false;
if (typeof tree.available === 'function' && !tree.available(state, id)) return false;
return true;
}
/**
* Wire a node's STORY-QUEST gate (optional `quest` field, a data/quests.json
* id) into the tree's available/lockNote hooks — the same seam the
* SYSTEM category's chart gate uses (isAvailable consults `available`; the
* console's LOCKED readout consults `lockNote`). The model stays pure:
* `isQuestClaimed(questId)` is the caller's LIVE predicate (the scene reads
* its quest ledger — the ledger's claimed/paid-out set); absent it, the
* tree is returned untouched and the gate is not enforced (the pure tree
* has no run state to consult).
*
* Gating semantics: a node with `quest` is unavailable while
* !isQuestClaimed(quest) — regardless of its `requires` — and its lock
* reason reads "<QUEST TITLE> REQUIRED" (the quest id, upper-cased, when
* the title is unknown). Claim (reward collected) is the bar — not
* checklist completion alone; the tech opens on the CLAIM press.
* Idempotent: an already-wired hook composes (the quest gate is checked
* first). Returns the same tree (mutated) or null for no tree.
*/
export function attachQuestGates(tree, isQuestClaimed) {
if (!tree || typeof tree !== 'object' || !Array.isArray(tree.order)) return tree;
const questOf = (id) => {
const q = tree.nodes[id]?.quest;
return typeof q === 'string' && q !== '' ? q : null;
};
if (typeof isQuestClaimed !== 'function' || !tree.order.some((id) => questOf(id))) return tree;
const prevAvailable = tree.available;
const prevNote = tree.lockNote;
tree.available = (state, id) => {
const q = questOf(id);
if (q !== null && !isQuestClaimed(q)) return false;
return typeof prevAvailable === 'function' ? prevAvailable(state, id) : true;
};
tree.lockNote = (state, id) => {
const q = questOf(id);
if (q !== null && !isQuestClaimed(q)) {
const title = questDef(q)?.title ?? q;
return `${String(title).toUpperCase()} REQUIRED`;
}
return typeof prevNote === 'function' ? prevNote(state, id) : null;
};
return tree;
}
/**
* Wire a node's BUILD-COMPLETION gate (optional `build` field, a
* data/builds.json id) into the tree's available/lockNote hooks — the same
* seam the SYSTEM category's chart gate and the STORY-QUEST gate use
* (isAvailable consults `available`; the console's LOCKED readout
* consults `lockNote`). The named build must be BUILT before the node is
* researchable — for a ship upgrade (targets: ["ship"]) that means built
* anywhere (the caller's predicate decides — the scene reads its BuildState
* built set). Pure: `isBuildCompleted(buildId)` is the caller's LIVE
* predicate; absent it, the tree is returned untouched and the gate is not
* enforced (the pure tree has no run state to consult).
*
* Gating semantics: a node with `build` is unavailable while
* !isBuildCompleted(build) — regardless of its `requires` — and its lock
* reason reads "<BUILD LABEL> BUILD REQUIRED" (the build id, upper-cased,
* when the label is unknown). Idempotent: an already-wired hook composes
* (this gate is checked first). Returns the same tree (mutated) or null for
* no tree.
*/
export function attachBuildGates(tree, isBuildCompleted) {
if (!tree || typeof tree !== 'object' || !Array.isArray(tree.order)) return tree;
const buildOf = (id) => {
const b = tree.nodes[id]?.build;
return typeof b === 'string' && b !== '' ? b : null;
};
if (typeof isBuildCompleted !== 'function' || !tree.order.some((id) => buildOf(id))) return tree;
const prevAvailable = tree.available;
const prevNote = tree.lockNote;
tree.available = (state, id) => {
const b = buildOf(id);
if (b !== null && !isBuildCompleted(b)) return false;
return typeof prevAvailable === 'function' ? prevAvailable(state, id) : true;
};
tree.lockNote = (state, id) => {
const b = buildOf(id);
if (b !== null && !isBuildCompleted(b)) {
const label = buildDefs()[b]?.label ?? b;
return `${String(label).toUpperCase()} BUILD REQUIRED`;
}
return typeof prevNote === 'function' ? prevNote(state, id) : null;
};
return tree;
}
/**
* Does this tree hold ANY tech ready to research right now — an
* unresearched node whose requires are all met and whose extra gate
* (the `available` hook) holds? The scene's "any research available"
* cue (the deck's RESEARCH-slot shimmer) and the console's per-tab
* shimmers ask this. The caller may additionally demand
* `!state.getActive()` — with maxConcurrent 1 nothing can START while a
* project runs (isAvailable doesn't see the in-flight slot).
*/
export function hasAvailable(tree, state) {
if (!tree || !Array.isArray(tree.order) || typeof state?.isUnlocked !== 'function') return false;
for (const id of tree.order) {
if (isAvailable(tree, state, id)) return true;
}
return false;
}
/** The parents this node is still missing (for the LOCKED readout). */
export function missingRequires(tree, state, id) {
return (tree.nodes[id]?.requires ?? []).filter(
(r) => !tree.nodes[r] || !state.isUnlocked(tree.id, r),
);
}
/**
* What completing this tech OPENS, normalized (both arrays always present,
* strings only). `unlocks` is optional on a node; absent = unlocks nothing.
* One read point for the console's UNLOCKS line and the build system:
* builds → ids in data/builds.json the tech makes available
* (the build's own `requires` is the authoritative gate)
* research → readable mirror of the children's `requires` edges
*/
export function unlocksOf(tree, id) {
const u = tree.nodes[id]?.unlocks ?? {};
const str = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []);
return { builds: str(u.builds), research: str(u.research) };
}
/** Map of build id → build definition (data/builds.json → builds, `_`-keys excluded). */
export function buildDefs() {
const raw = config.get('builds.builds', {}) ?? {};
const out = {};
for (const [k, v] of Object.entries(raw)) {
if (!k.startsWith('_') && v && typeof v === 'object') out[k] = v;
}
return out;
}
/**
* Cross-file unlock contract for one tree (empty array = clean):
* - unlocks.research entries name real nodes that require THIS node
* (the mirror of the DAG's child edges — kept honest by the test),
* - unlocks.builds entries exist in data/builds.json AND that build
* names this node ("<tree.id>/<id>", bare id tolerated) in its `requires`.
*/
export function unlockIssues(tree) {
const out = [];
const defs = buildDefs();
for (const id of tree.order) {
const { builds: ub, research: ur } = unlocksOf(tree, id);
for (const r of ur) {
if (!tree.nodes[r]) out.push(`${id} unlocks unknown research node "${r}"`);
else if (!(tree.nodes[r].requires ?? []).includes(id))
out.push(`${id}.unlocks.research lists "${r}", but ${r}.requires does not name ${id}`);
}
for (const b of ub) {
const def = defs[b];
if (!def) {
out.push(`${id} unlocks unknown build "${b}" (data/builds.json → builds)`);
continue;
}
const req = Array.isArray(def.requires) ? def.requires : [];
if (!req.includes(`${tree.id}/${id}`) && !req.includes(id))
out.push(`build "${b}" does not require ${tree.id}/${id} (its requires: [${req.join(', ')}])`);
}
}
return out;
}
/**
* Builds → research direction (empty array = clean): every entry of a
* build's `requires` must be a well-formed "category/node id" that
* resolves to a real node in a registered category's tree.
*/
export function buildIssues() {
const out = [];
for (const [bid, def] of Object.entries(buildDefs())) {
const req = def.requires ?? [];
if (!Array.isArray(req)) {
out.push(`build "${bid}".requires must be an array of "category/node id"`);
continue;
}
for (const rid of req) {
const m = typeof rid === 'string' ? rid.split('/') : null;
if (!m || m.length !== 2 || !m[0] || !m[1]) {
out.push(`build "${bid}" requires malformed research id "${rid}" (want "category/node id")`);
continue;
}
const catId = m[0];
if (!categories().some((c) => c.id === catId)) {
out.push(`build "${bid}" requires unknown category "${catId}"`);
continue;
}
const t = loadCategory(catId);
if (!t || !t.nodes[m[1]]) out.push(`build "${bid}" requires unknown node "${m[1]}" in ${catId}`);
}
}
return out;
}