orbit/js/ship/ShipScreenModel.js

242 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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:<dotted path>' a data file (config.get) — the hardware specs
* that live in data/ship.json / data/combat.json.
* 'ship:<dotted path>' a field of the ship snapshot (stats.cargoHold,
* minerals, credits, cargo.used …) — state the run
* mutates (upgrades, ore, buys/sells).
* 'live:<key>' 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 — plus the aggregate mineral load the Mineral HUD reads).
* @param {object} snap the console snapshot ({ ship: { stats, minerals,
* cargo: { used, items }, 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 : [];
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',
};
}),
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)),
};
}
export default { resolveRef, resolveValue, formatValue, resolveRow, statRows, statGroups, isResolved, journeyState, cargoSummary };