144 lines
5.8 KiB
JavaScript
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);
|
|
}
|