glytch/fktry/src/ui/inspector.ts
type-two e25c854be8 [ui] inspector recipe picker + heat states; fax queue peek and stamps
Inspector: setRecipe finally has a UI — a process picker listing each
machine's recipes as readable lines ("1 LUMA BARS -> 1 COEFFICIENT PACK +
1 HF DUST"). Heat gets a bar with THROTTLING and SCRAM states rather than a
number. Jam reasons route through jamText() so 'starved' and 'output full'
read as different problems, and an unknown reason passes through uppercased
instead of being swallowed.

Fax: NEXT IN TRAY peek, a STANDING ORDER stamp for repeat commissions, and a
SANITISING chip once anchor-slabs ship (hardcoded id sanctioned this round).

voice.ts/palette.ts now prefer MachineDef.flavor/.color from data and keep the
round-1 hand-transcribed tables as fallback. chipspec.ts lifts the chip
arithmetic out of the DOM so it can be tested.

Caveats in NOTES: the sim pins the active commission to commissions[0] and
never advances, so STANDING ORDER is unreachable; NEXT IN TRAY infers "next"
from data order because the snapshot has no queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:27:45 +10:00

191 lines
6.9 KiB
TypeScript

/**
* LANE-UI — inspector panel. Shows one entity: what it is, what the codex says about
* it, what it's chewing, how far along, how hot, 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, UIBus,
} from '../contracts';
import { createChipRow, type ChipRow } from './chips';
import { intakeChips, outputChips } from './chipspec';
import { attrs, cls, el, panel, style, text } from './dom';
import { COPY, jamText, machineFlavor } from './voice';
export interface Inspector {
el: HTMLElement;
open(entityId: number): void;
close(): void;
isOpen(): boolean;
update(snap: SimSnapshot): void;
/** Entity currently shown, for the renderer-side selection highlight later. */
openId(): number | null;
}
/** Heat above this throttles (CONTRACTS: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7;
export function createInspector(data: GameData, bus: UIBus): Inspector {
const machines = new Map<string, MachineDef>(data.machines.map((m) => [m.id, m]));
const recipes = new Map<string, RecipeDef>(data.recipes.map((r) => [r.id, r]));
const items = new Map<string, ItemDef>(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');
// ------------------------------------------------------------- recipe picker
const recHead = el('div', 'fk-h', [COPY.inspectorRecipe]);
const recSelect = el('select', 'fk-ins-recipe');
attrs(recSelect, { title: 'PROCESS SELECT' });
recSelect.addEventListener('change', () => {
if (openId === null) return;
const v = recSelect.value;
bus.dispatch({ kind: 'setRecipe', entity: openId, recipe: v === '' ? null : v });
});
/** The def whose options are currently in the <select>, to avoid rebuilding per frame. */
let optionsFor: string | null = null;
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 heatHead = el('div', 'fk-h', [COPY.inspectorHeat]);
const heatFill = el('div', 'fk-bar-f');
const heatBar = el('div', 'fk-bar fk-heat', [heatFill]);
const heatState = el('div', 'fk-ins-heat-state');
const jam = el('div', 'fk-ins-jam');
const body = el('div', undefined, [
recHead, recSelect, noRecipe,
inHead, inChips.el, outHead, outChips.el,
progHead, progBar,
heatHead, heatBar, heatState,
]);
root.append(head, flavor, el('div', 'fk-rule'), body, jam);
let openId: number | null = null;
function close() {
openId = null;
optionsFor = null;
cls(root, 'is-open', false);
}
closeBtn.addEventListener('click', close);
/** Rebuild the <option> list when the inspected machine changes. */
function syncOptions(def: MachineDef | undefined) {
if (optionsFor === (def?.id ?? null)) return;
optionsFor = def?.id ?? null;
recSelect.replaceChildren();
const idle = el('option', undefined, [COPY.inspectorNoRecipeOption]);
idle.value = '';
recSelect.append(idle);
for (const rid of def?.recipes ?? []) {
const r = recipes.get(rid);
const opt = el('option', undefined, [recipeLabel(r, rid)]);
opt.value = rid;
recSelect.append(opt);
}
}
/** "2 MDAT ORE -> 1 LUMA BARS + 1 CHROMA SLURRY" in item display names. */
function recipeLabel(r: RecipeDef | undefined, fallback: string): string {
if (!r) return fallback.toUpperCase();
const side = (m: Record<string, number>) =>
Object.entries(m)
.map(([id, n]) => `${n} ${items.get(id)?.name ?? id.toUpperCase()}`)
.join(' + ');
const ins = side(r.inputs);
const outs = side(r.outputs);
return ins ? `${ins}${outs}` : outs;
}
function paint(e: EntityState) {
const def = machines.get(e.def);
text(name, def?.name ?? e.def.toUpperCase());
text(flavor, machineFlavor(def, e.def));
// Only machines with something to choose get a picker.
const choices = def?.recipes ?? [];
const canChoose = choices.length > 0;
syncOptions(def);
for (const n of [recHead, recSelect]) style(n, 'display', canChoose ? '' : 'none');
if (canChoose && document.activeElement !== recSelect) {
const want = e.recipe ?? '';
if (recSelect.value !== want) recSelect.value = want;
}
const recipe = e.recipe ? recipes.get(e.recipe) : null;
const hasProcess = !!recipe;
// "No process" only reads as a fault on a machine that could have one.
style(noRecipe, 'display', !hasProcess && canChoose ? 'block' : 'none');
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) {
if (hasInputs) inChips.update(intakeChips(recipe, e));
outChips.update(outputChips(recipe, e));
style(progFill, 'width', `${Math.max(0, Math.min(1, e.progress)) * 100}%`);
}
// Heat: a bar rather than a number, because it's a thing you watch climb.
const hot = e.heat > 0.01;
for (const n of [heatHead, heatBar, heatState]) style(n, 'display', hot ? '' : 'none');
if (hot) {
const scram = e.heat >= 1;
const throttling = e.heat > THROTTLE_AT;
style(heatFill, 'width', `${Math.min(1, e.heat) * 100}%`);
cls(heatBar, 'is-throttling', throttling && !scram);
cls(heatBar, 'is-scram', scram);
text(heatState, scram ? COPY.heatScram : throttling ? COPY.heatThrottling : '');
cls(heatState, 'is-scram', scram);
}
const jammed = !!e.jammed;
cls(jam, 'is-on', jammed);
if (jammed) text(jam, jamText(e.jammed!));
}
return {
el: root,
open(entityId) {
openId = entityId;
cls(root, 'is-open', true);
},
close,
isOpen: () => openId !== null,
openId: () => openId,
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);
},
};
}