/** * 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); }