271 lines
12 KiB
JavaScript
271 lines
12 KiB
JavaScript
/**
|
||
* 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 };
|