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>
191 lines
6.9 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|