orbit/js/ship/ShipScreenModel.js

271 lines
12 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 combat session's armor (hull / hullMax /
* shields / shieldsMax), folded by GameScene:
* the combat system's ledger PERSISTS between
* fights (damage only heals on a landing's
* repairFull or a jump's arrival repair), so
* these read the run's current armor whether a
* fight is live or not — and only with no combat
* system do they read the maxima (a fresh, never-
* flown ship). The `nominalLabel` is the row's
* at-full copy (hull at its limit is 'NOMINAL'),
* not an out-of-combat flag.
*
* 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?: number|string|null, accent?: string|null,
* skillPoints?: number}} [playerClass] the class on file (js/player/ClassState.js)
*/
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,
// the class plate's own theme + crest (data/classes.json rides the save
// record as {name, insignia: frame, accent, skillPoints})
classInsignia: playerClass?.insignia ?? null,
classAccent: playerClass?.accent ?? null,
classSkillPoints: Number.isFinite(Number(playerClass?.skillPoints)) ? Math.max(0, Math.round(Number(playerClass.skillPoints))) : 0,
};
}
/**
* 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 };