/** * LANE-UI โ€” inspector panel. Shows one entity: what it is, what the codex says about * it, what it's chewing, how far along, and why it has stopped. * * Holds an entity *id*, never an entity object โ€” CONTRACTS says the sim may reuse * snapshot buffers between frames, so we re-look-up from the live snapshot each update * and close ourselves if the unit is gone. */ import type { EntityState, GameData, ItemDef, MachineDef, RecipeDef, SimSnapshot } from '../contracts'; import { createChipRow, type ChipRow, type ChipSpec } from './chips'; import { attrs, cls, el, panel, style, text } from './dom'; import { COPY, machineFlavor } from './voice'; export interface Inspector { el: HTMLElement; open(entityId: number): void; close(): void; isOpen(): boolean; update(snap: SimSnapshot): void; } export function createInspector(data: GameData): Inspector { const machines = new Map(data.machines.map((m) => [m.id, m])); const recipes = new Map(data.recipes.map((r) => [r.id, r])); const items = new Map(data.items.map((i) => [i.id, i])); const root = panel(); root.id = 'fk-inspect'; const name = el('div', 'fk-ins-name'); const closeBtn = el('button', 'fk-ins-x', [COPY.inspectorClose]); attrs(closeBtn, { type: 'button', title: 'CLOSE (ESC)' }); const head = el('div', 'fk-ins-head', [name, closeBtn]); const flavor = el('div', 'fk-ins-flavor'); const noRecipe = el('div', 'fk-ins-empty', [COPY.inspectorEmptyRecipe]); const inHead = el('div', 'fk-h', [COPY.inspectorInput]); const inChips: ChipRow = createChipRow(items); const outHead = el('div', 'fk-h', [COPY.inspectorOutput]); const outChips: ChipRow = createChipRow(items); const progHead = el('div', 'fk-h', [COPY.inspectorProgress]); const progFill = el('div', 'fk-bar-f'); const progBar = el('div', 'fk-bar', [progFill]); const heatRow = el('div', 'fk-stat', [el('span', undefined, ['HEAT']), el('span', 'fk-stat-v')]); const heatVal = heatRow.lastElementChild as HTMLElement; const jam = el('div', 'fk-ins-jam'); const body = el('div', undefined, [ noRecipe, inHead, inChips.el, outHead, outChips.el, progHead, progBar, heatRow, ]); root.append(head, flavor, el('div', 'fk-rule'), body, jam); let openId: number | null = null; function close() { openId = null; cls(root, 'is-open', false); } closeBtn.addEventListener('click', close); function paint(e: EntityState) { const def = machines.get(e.def); text(name, def?.name ?? e.def.toUpperCase()); text(flavor, machineFlavor(e.def)); const recipe = e.recipe ? recipes.get(e.recipe) : null; const hasProcess = !!recipe; style(noRecipe, 'display', hasProcess ? 'none' : 'block'); for (const n of [outHead, outChips.el, progHead, progBar]) { style(n, 'display', hasProcess ? '' : 'none'); } // Extractors take nothing from the world but the world itself โ€” no INTAKE header // for a machine that has no intake. const hasInputs = !!recipe && Object.keys(recipe.inputs).length > 0; for (const n of [inHead, inChips.el]) style(n, 'display', hasInputs ? '' : 'none'); if (recipe) { const ins: ChipSpec[] = Object.entries(recipe.inputs).map(([item, need]) => { const have = e.inputBuf[item] ?? 0; return { item, count: `${have}/${need}`, state: have >= need ? 'met' : 'short' }; }); inChips.update(ins); // Recipe outputs first, then anything else the buffer is holding (a jam is often // an item the recipe never mentions). const outIds = new Set(Object.keys(recipe.outputs)); for (const id of Object.keys(e.outputBuf)) outIds.add(id); outChips.update( [...outIds].map((item) => ({ item, count: String(e.outputBuf[item] ?? 0) })), ); style(progFill, 'width', `${Math.max(0, Math.min(1, e.progress)) * 100}%`); } const hot = e.heat > 0.01; style(heatRow, 'display', hot ? '' : 'none'); if (hot) { text(heatVal, `${(e.heat * 100).toFixed(0)}%${e.heat > 0.7 ? ' ยท THROTTLING' : ''}`); style(heatVal, 'color', e.heat > 0.7 ? 'var(--fk-red)' : 'var(--fk-amber)'); } const jammed = !!e.jammed; cls(jam, 'is-on', jammed); if (jammed) text(jam, COPY.inspectorJamPrefix + e.jammed!.toUpperCase()); } return { el: root, open(entityId) { openId = entityId; cls(root, 'is-open', true); }, close, isOpen: () => openId !== null, update(snap) { if (openId === null) return; const e = snap.entities.find((x) => x.id === openId); if (!e) return close(); // unit was removed out from under us paint(e); }, }; }