orbit/js/build/BuildModel.js

144 lines
5.8 KiB
JavaScript

/**
* BuildModel — the data layer for the build console (the deck's BUILD
* button on a planet surface). Pure functions over data/builds.json —
* no scene, no Phaser (dev/builds.test.mjs runs it in Node; the browser
* side is js/ui/BuildWindow.js).
*
* Research UNLOCKS a build (data/research/*.json → unlocks.builds);
* building then costs resources + time on a planet and applies the effect
* (e.g. the level-2 tether). The availability rules here mirror the
* research model (js/research/ResearchModel.js):
*
* - `requires` — "category/node" ids that must be researched
* - `planetRequires` — world-state gates (e.g. tetherLevel ≥ 1)
* - `isBuilt` — one-off builds are not built twice on a planet
* - `cost` — paid in full when the build starts (minerals/credits)
*
* categories() → data/builds.json → categories
* loadBuilds(catId) → { id, label, accent, builds, order }
* defById(id) → the definition for one build id (any category)
* startingPairs() → [ [planet, buildId], … ] pre-installed on a fresh run
* missingRequirements(def) → unmet gates (research + planet) as strings
* isAvailable(def, ctx) → gates met AND not already built on the planet
* rowState(def, ctx, active, id) → 'built' | 'active' | 'available' | 'locked'
* costLines(def) → [{ res, amount }] for the cost readout
* canAfford(def, minerals) → every line is covered
*
* `ctx` is the caller's view of the world (the window composes it from
* scene seams; the tests compose it by hand):
* { isResearchUnlocked(catId, nodeId), tetherLevel(planetName),
* isBuilt(planetName, buildId) }
*/
import { config } from '../config/Config.js';
import { buildDefs } from '../research/ResearchModel.js';
/** The category registry (data/builds.json → categories). */
export function categories() {
const cats = config.get('builds.categories', []);
return Array.isArray(cats) ? cats : [];
}
/**
* Load one build category (the id → its builds + registry entry).
* Unknown ids resolve to the default category; a missing registry entry
* is a warning (dev tools) with a neutral accent.
*/
export function loadBuilds(catId) {
const cats = categories();
const meta = cats.find((c) => c.id === catId)
?? cats.find((c) => c.id === config.get('builds.defaultCategory', cats[0]?.id))
?? null;
if (!meta) {
console.warn('[builds] no categories in data/builds.json');
return { id: String(catId), label: String(catId), accent: '#8fa3c8', builds: {}, order: [] };
}
const defs = buildDefs();
const builds = {};
for (const [id, def] of Object.entries(defs)) {
if (def?.category === meta.id) builds[id] = def;
}
const order = Object.keys(builds);
if (!order.length) console.warn(`[builds] category "${meta.id}" has no builds (data/builds.json)`);
return { ...meta, builds, order };
}
/** Look up one build definition by id (any category); null if unknown. */
export function defById(id) {
const defs = buildDefs();
return Object.prototype.hasOwnProperty.call(defs, id) ? defs[id] : null;
}
/**
* SHIP-SCOPED build (data/builds.json → targets: ["ship"]): the effect
* is on the ship (the mining arms + storage), not the planet — installed
* from any planet's console, counted as BUILT wherever the ship lands
* (BuildState.isBuiltAnywhere), never re-bought. Planet-scoped builds
* (targets: ["planet"]) are the ordinary one-off surface installs.
*/
export function isShipScoped(def) {
return Array.isArray(def?.targets) && def.targets.includes('ship');
}
/**
* [planet, buildId] pairs pre-installed on a fresh run — the home world
* starts with its level-1 tether (data/builds.json → tether-l1.starting).
* 'home' is the seed-independent home key (the scene resolves it to the
* home world's name before seeding BuildState).
*/
export function startingPairs() {
const out = [];
for (const [id, def] of Object.entries(buildDefs())) {
for (const p of def?.starting ?? []) out.push([p, id]);
}
return out;
}
/** Unmet gates for a build, as short strings (research first, then the planet). */
export function missingRequirements(def, ctx) {
const out = [];
for (const req of def?.requires ?? []) {
const i = req.indexOf('/');
if (i < 0) continue;
const cat = req.slice(0, i);
const node = req.slice(i + 1);
if (!ctx.isResearchUnlocked(cat, node)) out.push(`RESEARCH: ${node.toUpperCase()}`);
}
const needTether = def?.planetRequires?.tetherLevel;
if (typeof needTether === 'number' && (ctx.tetherLevel() ?? 0) < needTether) {
out.push(`TETHER ≥ L${needTether}`);
}
return out;
}
/** Gates met (research + planet) AND not already built on this planet. */
export function isAvailable(def, ctx) {
if (!def) return false;
if (ctx.isBuilt()) return false;
return missingRequirements(def, ctx).length === 0;
}
/** The row's state for the list (the list paints from this). */
export function rowState(def, ctx, active, id) {
if (ctx.isBuilt()) return 'built';
if (active && active.build === id) return 'active';
return isAvailable(def, ctx) ? 'available' : 'locked';
}
/** Cost lines for the readout (data → minerals/credits amounts). */
export function costLines(def) {
const cost = def?.cost;
if (!cost || typeof cost !== 'object') return [];
return Object.entries(cost)
.filter(([, amount]) => typeof amount === 'number' && amount > 0)
.map(([res, amount]) => ({ res, amount }));
}
/**
* Can the player pay the full cost. `wallet` is a number (shorthand for
* { minerals: n } — today's economy) or a { res: amount } map.
*/
export function canAfford(def, wallet) {
const w = typeof wallet === 'number' ? { minerals: wallet } : wallet;
return costLines(def).every((l) => (w?.[l.res] ?? 0) >= l.amount);
}