/** * ShipScreenModel — the SHIP console's read-only brain (data/shipScreen.json, * rendered by js/ui/ShipWindow.js). Pure: no Phaser, no scene, no game * objects, so dev/ship-window.test.mjs drives it in bare Node with a plain * snapshot object. * * The console never invents a number. Every value on the Ship Status plate is * resolved from a path written in data/shipScreen.json → status.rows[*].from: * * 'config:' a data file (config.get) — the hardware specs * that live in data/ship.json / data/combat.json. * 'ship:' a field of the ship snapshot (stats.cargoHold, * minerals, credits, cargo.used …) — state the run * mutates (upgrades, ore, buys/sells). * 'live:' the LIVE combat session's armor (hull / hullMax / * shields / shieldsMax), folded by GameScene: while * a fight runs these are the session's values, and * with no fight they read the maxima (the ship is * never damaged out of combat — the plate says so * through the row's `nominalLabel`). * * Optional per-row math: op 'reciprocal' (a cooldown → a rate) and, for an * array of sources, op 'product' (damage × shots/sec = DPS). A row's meter is * likewise two paths — fill / max — so bars are data too. */ import { config } from '../config/Config.js'; // A missing source stays MISSING (null) — never a fabricated 0, which would // let a broken path masquerade as a real reading on the plate. const num = (v) => { if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null; const n = Number(v); return Number.isFinite(n) ? n : null; }; const clamp01 = (v) => Math.min(1, Math.max(0, v)); /** * Resolve one `from` reference against the snapshot. * @param {string} ref 'config:ship.thrust' | 'ship:stats.cargoHold' | 'live:hull' * @param {{ship?: object, live?: object}} snap the console snapshot * @returns {number|null} a finite number, or null when the path is missing */ export function resolveRef(ref, snap = {}) { const s = String(ref ?? ''); const i = s.indexOf(':'); if (i < 0) return null; const ns = s.slice(0, i); const path = s.slice(i + 1); let node; if (ns === 'config') node = config.get(path, undefined); else if (ns === 'ship') node = path.split('.').reduce((o, k) => (o == null ? o : o[k]), snap?.ship ?? null); else if (ns === 'live') node = (snap?.live ?? {})[path]; else return null; return num(node); } /** The row's value — one source (+ op), or an array of sources + 'product'. */ export function resolveValue(def, snap) { const list = Array.isArray(def?.from) ? def.from : def?.from ? [{ from: def.from, op: def.op }] : []; if (!list.length) return null; if (def?.op === 'product') { let p = 1; let any = false; for (const part of list) { const v = resolveValue(part, snap); if (v === null) return null; p *= v; any = true; } return any ? p : null; } const v = resolveRef(list[0].from ?? list[0], snap); if (v === null) return null; const op = list[0].op ?? def?.op; if (op === 'reciprocal') return v > 0 ? 1 / v : null; return v; } /** The plate's number formatting (data picks it per row). */ export function formatValue(v, format) { const n = num(v); if (n === null) return '—'; if (format === '1dp') return n.toFixed(1); if (format === '2dp') return n.toFixed(2); return String(Math.round(n)); } /** * One readout row, resolved + formatted. `meta` (the row's small right-hand * read — a recharge delay, a shot speed) is resolved the same way. * @returns {{id,label,text,unit,value,frac,meta,note,accent}} */ export function resolveRow(def, snap) { const v = resolveValue(def, snap); const meter = def?.meter ?? null; let frac = null; if (meter) { const fill = resolveRef(meter.fill ?? meter.of, snap); const max = meter.max ? resolveRef(meter.max, snap) : v; frac = fill !== null && max !== null && max > 0 ? clamp01(fill / max) : null; } const m = def?.meta ?? null; const mv = m ? resolveValue(m, snap) : null; return { id: String(def?.id ?? ''), group: String(def?.group ?? ''), label: String(def?.label ?? '').toUpperCase(), value: v, text: formatValue(v, def?.format), unit: String(def?.unit ?? ''), frac, note: def?.note ? String(def.note) : '', accent: def?.accent ?? null, // the row's own DANGER line (data picks it; damage meters declare one, an // empty cargo bay is not an alarm). null = the row never reads hot. hotBelow: Number.isFinite(def?.hotBelow) ? Number(def.hotBelow) : null, meta: m && mv !== null ? `${String(m.label ?? '').toUpperCase()} ${formatValue(mv, m.format)}${m.unit ? ' ' + m.unit : ''}` : '', nominalLabel: def?.nominalLabel ? String(def.nominalLabel) : '', }; } /** Every configured row (data order), grouped by status.groups. */ export function statRows(rows, snap) { const defs = Array.isArray(rows) ? rows : []; return defs.filter((d) => d && d.id).map((d) => resolveRow(d, snap)); } /** The rows of one group, with the group's label (status.groups). */ export function statGroups(rows, snap, groups) { const all = statRows(rows, snap); const gs = Array.isArray(groups) && groups.length ? groups : [...new Set(all.map((r) => r.group))].map((g) => ({ id: g, label: g })); return gs .map((g) => ({ id: String(g.id), label: String(g.label ?? g.id).toUpperCase(), rows: all.filter((r) => r.group === String(g.id)) })) .filter((g) => g.rows.length); } /** A row resolves (or explicitly reads a dash — never a NaN hole in the plate). */ export function isResolved(row) { return row && row.value !== null && Number.isFinite(row.value); } /** * The COMBAT RECORD's journey state (data/shipScreen.json → level). The * ledger itself is js/combat/XpManager.js — stats() hands over {level, xp, * xpForNext, maxLevel, isMax, progress}; this only decides what the plate * shows: * mode 'timeline' the level `from`…`to` milestone journey (below `classAt`) * mode 'choose' the CHOOSE YOUR CLASS door (at/above `classAt`, no class) * mode 'class' a class is on file — its name + insignia slot * @param {object|null} stats XpManager.stats() (null: no combat ledger yet) * @param {object} cfg data/shipScreen.json → level * @param {{name?: string|null, insignia?: string|null}} [playerClass] */ export function journeyState(stats, cfg = {}, playerClass = null) { const from = Number.isFinite(cfg.from) ? cfg.from : 0; const to = Number.isFinite(cfg.to) ? cfg.to : 5; const classAt = Number.isFinite(cfg.classAt) ? cfg.classAt : 5; const s = stats ?? null; const level = s ? Math.max(0, Math.round(Number(s.level) || 0)) : from; const xp = s ? Math.max(0, Math.round(Number(s.xp) || 0)) : 0; const xpForNext = s ? Math.max(0, Math.round(Number(s.xpForNext) || 0)) : 0; const isMax = !!(s && s.isMax); // the partial step toward the NEXT level (the bar's fine fill) const step = clamp01(Number(s?.progress) || 0); const span = Math.max(1, to - from); const nodes = (Array.isArray(cfg.milestones) ? cfg.milestones : []).map((m) => { const lv = Number(m?.level); return { level: Number.isFinite(lv) ? lv : from, label: String(m?.label ?? `LV ${lv}`).toUpperCase(), note: String(m?.note ?? ''), state: !Number.isFinite(lv) ? 'future' : level > lv ? 'done' : level === lv ? 'current' : 'future', }; }); const name = typeof playerClass?.name === 'string' && playerClass.name ? playerClass.name : null; return { mode: name ? 'class' : level >= classAt ? 'choose' : 'timeline', from, to, classAt, level, xp, xpForNext, step, isMax, // the journey's overall fill (0…1 across `from`…`to`) — the timeline bar progress: clamp01((level - from + (level >= to ? 0 : step)) / span), nodes, className: name, classInsignia: playerClass?.insignia ?? null, }; } /** * The CARGO HOLDS summary (js/economy/CargoHold.js — one good per bay, in * fill order, each with its RECORDED purchase cost (or null where the bay * has no cost on file) — plus the aggregate mineral load the Mineral HUD * reads). * @param {object} snap the console snapshot ({ ship: { stats, minerals, * cargo: { used, items, costs }, credits } }) * @param {Array<{id:string,name:string,icon?:number}>} [catalog] goods.json * (icon frame + display name; ShopView's texture is keyed off it) */ export function cargoSummary(snap = {}, catalog = []) { const hold = snap?.ship?.cargo ?? null; const items = Array.isArray(hold?.items) ? hold.items : []; // The per-bay purchase-cost ledger (parallel to items — the credits/unit // the player actually paid; null where a bay has no cost on file). The // bay manifest's exit prices are compared against this, so the numbers // here are the ones the console prints — never invented. (null in → null // out: Number(null) === 0 would count a missing cost as a 0-credit one.) const costs = Array.isArray(hold?.costs) ? hold.costs : []; const bayCost = (v) => (v != null && Number.isFinite(Number(v)) ? Math.max(0, Math.floor(Number(v))) : null); const capacity = Math.max(0, Math.round(num(snap?.ship?.stats?.cargoHold) ?? 0)); const slots = Math.max(1, Math.round(num(config.get('economy.cargo.maxSlots', 16)) || 16)); const used = Math.max(0, Math.min(capacity, Math.round(num(hold?.used) ?? items.length))); const byId = new Map((Array.isArray(catalog) ? catalog : []).map((g) => [String(g.id), g])); return { // the bays as drawn: filled first (the held good), then the unlocked-but- // empty bays, then the grid's LOCKED bays (the hold's upgrade room) bays: Array.from({ length: Math.max(used, Math.min(slots, Math.max(capacity, used))) }).map((_, i) => { const id = items[i] != null ? String(items[i]) : null; const g = id ? byId.get(id) ?? null : null; return { bay: i, goodId: id, name: g?.name ?? id ?? '', icon: Number.isFinite(Number(g?.icon)) ? Number(g.icon) : null, state: id ? 'filled' : i < capacity ? 'empty' : 'locked', // The bay's recorded purchase cost (credits/unit) — null when there // is none on file (the quest package's handoff, a save predating the // cost record); the manifest then falls back to the catalog value. cost: bayCost(costs[i]), }; }), used, capacity, room: Math.max(0, capacity - used), pct: capacity > 0 ? Math.min(1, used / capacity) : 0, slots, lockedBays: Math.max(0, slots - capacity), empty: used === 0, minerals: Math.max(0, Math.round(num(snap?.ship?.minerals) ?? 0)), mineralCapacity: Math.max(0, Math.round(num(snap?.ship?.stats?.mineralStorage) ?? 0)), // the same fill the Mineral HUD draws (js/ui/MineralHud.js is fed // ship.minerals / stats.mineralStorage — one aggregate, no ore model) mineralPct: Number(num(snap?.ship?.stats?.mineralStorage)) > 0 ? Math.min(1, Math.max(0, num(snap?.ship?.minerals) ?? 0) / num(snap?.ship?.stats?.mineralStorage)) : 0, credits: Math.max(0, Math.round(num(snap?.ship?.credits) ?? 0)), // The load's cost BASIS — the credits actually paid for the bays aboard // (null-cost bays count as 0: the plate never invents a number) and how // many bays carry a recorded cost (the hold meta prints it only when at // least one bay does — an all-null hold stays quiet). totalCost: items.reduce((s, _id, i) => s + (bayCost(costs[i]) ?? 0), 0), costBays: items.filter((_id, i) => bayCost(costs[i]) != null).length, }; } export default { resolveRef, resolveValue, formatValue, resolveRow, statRows, statGroups, isResolved, journeyState, cargoSummary };