[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>
This commit is contained in:
type-two 2026-07-17 17:27:45 +10:00
parent cd733445d9
commit e25c854be8
6 changed files with 289 additions and 66 deletions

43
fktry/src/ui/chipspec.ts Normal file
View File

@ -0,0 +1,43 @@
/**
* LANE-UI pure chip-spec builders.
*
* The arithmetic behind every chip row (what's short, what's met, what the count reads)
* with no DOM in sight, so it can be tested and so `chips.ts` stays a dumb renderer.
*/
import type { CommissionDef, EntityState, RecipeDef } from '../contracts';
import type { ChipSpec } from './chips';
/** Recipe inputs against what the machine is actually holding: `have/need`. */
export function intakeChips(recipe: RecipeDef, e: EntityState): ChipSpec[] {
return Object.entries(recipe.inputs).map(([item, need]) => {
const have = e.inputBuf[item] ?? 0;
return { item, count: `${have}/${need}`, state: have >= need ? 'met' : 'short' };
});
}
/**
* Recipe outputs, then anything else the output buffer is holding a jam is often an
* item the current recipe never mentions (left over from a previous recipe).
*/
export function outputChips(recipe: RecipeDef, e: EntityState): ChipSpec[] {
const ids = new Set(Object.keys(recipe.outputs));
for (const id of Object.keys(e.outputBuf)) ids.add(id);
return [...ids].map((item) => ({ item, count: String(e.outputBuf[item] ?? 0) }));
}
/** Commission wants against delivered progress. Never shows more delivered than wanted. */
export function commissionChips(c: CommissionDef, progress: Record<string, number>): ChipSpec[] {
return Object.entries(c.wants).map(([item, need]) => {
const have = progress[item] ?? 0;
return {
item,
count: `${Math.min(have, need)}/${need}`,
state: have >= need ? 'met' : 'plain',
};
});
}
/** True once every want is satisfied. */
export function commissionMet(c: CommissionDef, progress: Record<string, number>): boolean {
return Object.entries(c.wants).every(([item, need]) => (progress[item] ?? 0) >= need);
}

View File

@ -7,6 +7,7 @@
*/
import type { CommissionDef, GameData, ItemDef, SimSnapshot } from '../contracts';
import { createChipRow, type ChipRow } from './chips';
import { commissionChips } from './chipspec';
import { cls, el, panel, text } from './dom';
import { COPY } from './voice';
@ -19,6 +20,12 @@ export interface Fax {
const STAMP_MS = 1800; // must match the fk-stamp keyframes in style.ts
/**
* The Correction quietly buys these back (lore §5; the fiction lands properly in M3).
* Hardcoded id sanctioned by round-2 orders.
*/
const SANITISED_ITEM = 'anchor-slab';
export function createFax(data: GameData): Fax {
const commissions = new Map<string, CommissionDef>(data.commissions.map((c) => [c.id, c]));
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
@ -32,12 +39,39 @@ export function createFax(data: GameData): Fax {
const chips: ChipRow = createChipRow(items);
const reward = el('div', 'fk-fax-sub');
// SANITISING: shown once The Correction has something to buy back.
const sanHead = el('div', 'fk-h fk-san-h', [COPY.sanitising]);
const sanChips: ChipRow = createChipRow(items);
const sanWrap = el('div', 'fk-san', [sanHead, sanChips.el]);
// NEXT IN TRAY: the peek at what the tray is holding.
const nextHead = el('div', 'fk-h', [COPY.faxNext]);
const nextName = el('div', 'fk-fax-next-name');
const nextChips: ChipRow = createChipRow(items);
const nextWrap = el('div', 'fk-fax-next', [
el('div', 'fk-rule'), nextHead, nextName, nextChips.el,
]);
const standing = el('div', 'fk-fax-standing', [el('span', undefined, [COPY.faxStanding])]);
const stampEl = el('div', 'fk-fax-stamp', [el('span', undefined, [COPY.faxStamp])]);
root.append(head, sub, flavor, el('div', 'fk-rule'), chips.el, reward, stampEl);
root.append(head, sub, flavor, el('div', 'fk-rule'), chips.el, reward,
sanWrap, nextWrap, standing, stampEl);
let stampTimer: number | undefined;
/**
* TEMPORARY: the snapshot exposes `activeCommission` but no queue, and the sim
* currently pins the active commission to `commissions[0]`. So "next" is inferred as
* the following entry in data order an assumption, not truth. CONTRACT REQUEST filed
* in NOTES for `nextCommission` / a queue on the snapshot.
*/
function peekNext(activeId: string): CommissionDef | null {
const i = data.commissions.findIndex((c) => c.id === activeId);
if (i < 0 || data.commissions.length < 2) return null;
return data.commissions[(i + 1) % data.commissions.length];
}
return {
el: root,
update(snap) {
@ -46,17 +80,25 @@ export function createFax(data: GameData): Fax {
if (!c) return;
text(flavor, c.flavor);
chips.update(
Object.entries(c.wants).map(([item, need]) => {
const have = snap.commissionProgress[item] ?? 0;
return {
item,
count: `${Math.min(have, need)}/${need}`,
state: have >= need ? ('met' as const) : ('plain' as const),
};
}),
);
chips.update(commissionChips(c, snap.commissionProgress));
text(reward, `ON RECEIPT: +${c.rewardBandwidth} BANDWIDTH`);
cls(standing, 'is-on', c.repeat === true);
const sanitised = snap.shippedTotal[SANITISED_ITEM] ?? 0;
cls(sanWrap, 'is-on', sanitised > 0);
if (sanitised > 0) {
sanChips.update([{ item: SANITISED_ITEM, count: String(sanitised), state: 'plain' }]);
}
const next = peekNext(c.id);
cls(nextWrap, 'is-on', !!next);
if (next) {
text(nextName, next.id.replace(/-/g, ' ').toUpperCase());
nextChips.update(
Object.entries(next.wants).map(([item, need]) => ({ item, count: String(need) })),
);
}
},
stamp() {
// Restart cleanly if two commissions complete back to back.

View File

@ -1,15 +1,18 @@
/**
* 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.
* 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 } from '../contracts';
import { createChipRow, type ChipRow, type ChipSpec } from './chips';
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, machineFlavor } from './voice';
import { COPY, jamText, machineFlavor } from './voice';
export interface Inspector {
el: HTMLElement;
@ -17,9 +20,14 @@ export interface Inspector {
close(): void;
isOpen(): boolean;
update(snap: SimSnapshot): void;
/** Entity currently shown, for the renderer-side selection highlight later. */
openId(): number | null;
}
export function createInspector(data: GameData): Inspector {
/** 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]));
@ -34,6 +42,18 @@ export function createInspector(data: GameData): Inspector {
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]);
@ -45,13 +65,18 @@ export function createInspector(data: GameData): Inspector {
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 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, [
noRecipe, inHead, inChips.el, outHead, outChips.el, progHead, progBar, heatRow,
recHead, recSelect, noRecipe,
inHead, inChips.el, outHead, outChips.el,
progHead, progBar,
heatHead, heatBar, heatState,
]);
root.append(head, flavor, el('div', 'fk-rule'), body, jam);
@ -60,19 +85,59 @@ export function createInspector(data: GameData): Inspector {
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(e.def));
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;
style(noRecipe, 'display', hasProcess ? 'none' : 'block');
// "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');
}
@ -83,33 +148,27 @@ export function createInspector(data: GameData): Inspector {
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) })),
);
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;
style(heatRow, 'display', hot ? '' : 'none');
for (const n of [heatHead, heatBar, heatState]) style(n, '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 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, COPY.inspectorJamPrefix + e.jammed!.toUpperCase());
if (jammed) text(jam, jamText(e.jammed!));
}
return {
@ -120,6 +179,7 @@ export function createInspector(data: GameData): Inspector {
},
close,
isOpen: () => openId !== null,
openId: () => openId,
update(snap) {
if (openId === null) return;
const e = snap.entities.find((x) => x.id === openId);

View File

@ -1,15 +1,14 @@
/**
* LANE-UI machine accent colors for build-bar icons.
*
* `ItemDef` carries a `color`; `MachineDef` does not, so the build bar has nothing
* data-driven to tint with. These are transcribed from docs/FKTRY_LORE.md §4 (each
* machine's ASSET block names its impossible element) as a stopgap.
* `MachineDef.color` (contracts v2) is the source of truth and wins whenever LANE-DATA
* has filled it the same value LANE-RENDER tints its placeholder boxes with, so the
* bar and the world agree about what a quantizer looks like.
*
* CONTRACT REQUEST filed in NOTES: add `MachineDef.color` LANE-RENDER needs the same
* value for its placeholder box tint, and two lanes hand-maintaining the same table is
* how the build bar and the world end up disagreeing about what a quantizer looks like.
* Until then: id override, else kind fallback, else house green. Never crashes on a
* machine we've never heard of.
* The tables below are the round-1 stopgap, kept as fallback: transcribed from
* docs/FKTRY_LORE.md §4 (each machine's ASSET block names its impossible element).
* Order: data color, else id override, else kind fallback, else house green. Never
* crashes on a machine we've never heard of.
*/
import type { MachineDef, MachineKind } from '../contracts';
@ -34,5 +33,5 @@ const BY_KIND: Record<MachineKind, string> = {
};
export function machineColor(def: MachineDef): string {
return BY_ID[def.id] ?? BY_KIND[def.kind] ?? '#d8ffd8';
return def.color || BY_ID[def.id] || BY_KIND[def.kind] || '#d8ffd8';
}

View File

@ -68,6 +68,9 @@ const CSS = `
.fk-bar { height: 6px; background: var(--fk-bg-solid); border: 1px solid var(--fk-line); }
.fk-bar-f { height: 100%; width: 0%; background: var(--fk-cool); transition: width 90ms linear; }
.fk-heat .fk-bar-f { background: var(--fk-dim); }
.fk-heat.is-throttling .fk-bar-f { background: var(--fk-amber); }
.fk-heat.is-scram .fk-bar-f { background: var(--fk-red); }
/* ---------------------------------------------------------------- top strip */
@ -103,13 +106,24 @@ const CSS = `
padding: 6px;
max-width: calc(100vw - 20px);
}
/* LANE-DATA shipped 21 machines; the bar wraps rather than running off the viewport.
Paging/tabs proposed for a later round see NOTES. */
.fk-build-row {
display: flex; gap: 4px;
flex-wrap: wrap;
justify-content: center;
}
/* Kind tabs. A page holds at most nine so 1-9 always reaches every machine. */
.fk-tabs { display: flex; gap: 3px; margin-bottom: 5px; flex-wrap: wrap; justify-content: center; }
.fk-tab {
background: var(--fk-bg-solid);
border: 1px solid var(--fk-line);
border-bottom-color: transparent;
color: var(--fk-faint);
font: inherit; font-size: 9px; letter-spacing: 0.12em;
padding: 2px 8px; cursor: pointer; pointer-events: auto;
}
.fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
.fk-tab.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; }
.fk-build-btn {
position: relative;
width: 58px;
@ -162,6 +176,25 @@ const CSS = `
.fk-ins-jam { display: none; color: var(--fk-amber); font-size: 11px; margin-top: 5px; }
.fk-ins-jam.is-on { display: block; }
.fk-ins-empty { color: var(--fk-faint); font-size: 10px; }
.fk-ins-heat-state { font-size: 10px; color: var(--fk-amber); margin-top: 3px; }
.fk-ins-heat-state.is-scram { color: var(--fk-red); font-weight: bold; }
/* Process select: a native <select> beaten into the house style. */
.fk-ins-recipe {
width: 100%;
background: var(--fk-bg-solid);
border: 1px solid var(--fk-line);
color: var(--fk-fg);
font: inherit; font-size: 10px;
padding: 3px 4px;
margin-bottom: 4px;
border-radius: 0;
appearance: none;
cursor: pointer;
pointer-events: auto;
}
.fk-ins-recipe:hover, .fk-ins-recipe:focus { border-color: var(--fk-cool); outline: none; }
.fk-ins-recipe option { background: var(--fk-bg-solid); color: var(--fk-fg); }
/* ---------------------------------------------------------------- fax */
@ -188,6 +221,29 @@ const CSS = `
#fk-fax .fk-chip { border-color: #4a4230; }
#fk-fax .fk-chip-n { color: #a09468; }
#fk-fax .fk-chip.is-met .fk-chip-n { color: var(--fk-cool); }
/* NEXT IN TRAY: a peek, so it reads quieter than the live commission. */
.fk-fax-next { display: none; opacity: 0.6; }
.fk-fax-next.is-on { display: block; }
.fk-fax-next-name { color: #a09468; font-size: 10px; letter-spacing: 0.08em; }
/* SANITISING: The Correction's buyback, stated as routine paperwork. */
.fk-san { display: none; margin-top: 4px; }
.fk-san.is-on { display: block; }
.fk-san-h { color: #7fa8a0; }
/* STANDING ORDER: not an animation — it's just always true, stamped once. */
.fk-fax-standing {
position: absolute; top: 6px; right: 6px;
display: none; pointer-events: none;
}
.fk-fax-standing.is-on { display: block; }
.fk-fax-standing span {
border: 2px solid var(--fk-cool); color: var(--fk-cool);
font-size: 9px; font-weight: bold; letter-spacing: 0.1em;
padding: 1px 4px; transform: rotate(-6deg); display: block;
opacity: 0.75;
}
.fk-fax-stamp {
position: absolute; inset: 0;
display: none; align-items: center; justify-content: center;

View File

@ -7,15 +7,15 @@
*
* All copy in the UI comes from this file so the tone stays in one place.
*/
import type { SimEvent } from '../contracts';
import type { MachineDef, SimEvent } from '../contracts';
/**
* Flavor lines for the inspector, keyed by MachineDef.id.
* Fallback flavor, keyed by MachineDef.id.
*
* TEMPORARY: hand-transcribed from docs/FKTRY_LORE.md §4 by LANE-UI. These belong in
* data/machines.json as a `flavor` field owned by LANE-DATA CONTRACT REQUEST filed in
* NOTES. Unknown ids fall back to `GENERIC_FLAVOR`, so new machines never crash the
* panel, they just sound like a filing cabinet until the codex line lands.
* `MachineDef.flavor` (contracts v2) is the real source and wins whenever LANE-DATA has
* filled it. These are the seven I hand-transcribed from the codex in round 1, kept as
* the fallback per orders. Unknown ids get `GENERIC_FLAVOR` a new machine never
* crashes the panel, it just sounds like a filing cabinet until its line lands.
*/
const MACHINE_FLAVOR: Record<string, string> = {
'seam-extractor':
@ -44,19 +44,39 @@ const MACHINE_FLAVOR: Record<string, string> = {
const GENERIC_FLAVOR =
'Industrial equipment. Operates within tolerances. Tolerances not disclosed.';
export function machineFlavor(defId: string): string {
return MACHINE_FLAVOR[defId] ?? GENERIC_FLAVOR;
export function machineFlavor(def: MachineDef | undefined, id: string): string {
return def?.flavor || MACHINE_FLAVOR[id] || GENERIC_FLAVOR;
}
/**
* Jam reasons, in the house voice. The sim owns the reason strings; we translate the
* ones we know and pass anything else through uppercased, so a new stall reason shows
* up honestly rather than being swallowed.
*/
const JAM_TEXT: Record<string, string> = {
'output full': 'OUTPUT BUFFER AT CAPACITY. THIS IS A YOU PROBLEM.',
starved: 'INTAKE STARVED. NOTHING IS ARRIVING.',
'no recipe': 'NO PROCESS ASSIGNED. UNIT AWAITS INSTRUCTION.',
'no power': 'INSUFFICIENT BANDWIDTH. THE MATHEMATICS ARE UNMOVED.',
};
export function jamText(reason: string): string {
return JAM_TEXT[reason] ?? reason.toUpperCase();
}
/** Toast copy for events the player should notice without reading a manual. */
export function eventToast(ev: SimEvent, machineName: (id: number) => string): string | null {
switch (ev.kind) {
case 'jammed':
return `${machineName(ev.entity)} HAS STOPPED. ${ev.reason.toUpperCase()}.`;
return `${machineName(ev.entity)}: ${jamText(ev.reason)}`;
case 'brownout':
return ev.on
? 'BUFFER UNDERRUN. THIS SECTOR IS NOW A STILL LIFE.'
: 'BUFFER RESTORED. RESUME SINNING.';
case 'scram':
return ev.on
? `THERMAL SCRAM. ${machineName(ev.entity)} HAS EXCUSED ITSELF.`
: `${machineName(ev.entity)} IS COOL ENOUGH TO CONTINUE. REGRETTABLY.`;
case 'commissionDone':
return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.';
default:
@ -72,25 +92,28 @@ export const COPY = {
running: 'RUNNING',
tick: 'TICK',
buildBarTitle: 'REQUISITION',
inspectorEmptyRecipe: 'NO PROCESS ASSIGNED. UNIT IS BEING PAID TO EXIST.',
inspectorInput: 'INTAKE',
inspectorOutput: 'OUTPUT',
inspectorProgress: 'PROCESS',
inspectorJamPrefix: 'FAULT: ',
inspectorRecipe: 'PROCESS SELECT',
inspectorNoRecipeOption: 'IDLE',
inspectorClose: 'X',
inspectorHeat: 'HEAT',
heatThrottling: 'THROTTLING',
heatScram: 'SCRAM — UNIT OFFLINE',
faxHeader: 'INCOMING FAX',
faxSubheader: 'GLITCH ACQUISITIONS — UNSOLICITED',
faxNoCommission: 'NO ACTIVE COMMISSION. THE FAX MACHINE IS RESTING.',
faxStamp: 'FAX SENT',
faxStanding: 'STANDING ORDER',
faxNext: 'NEXT IN TRAY',
/** Shown against shipments The Correction quietly buys back (M3 fiction). */
sanitising: 'SANITISING',
/** Shown on the build bar when the player has something selected. */
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
idleHint: '1-9 SELECT · SPACE HALT · TAB MANIFEST',
/** Inspector fallback when no entity is under the cursor. */
manifestEmpty: 'NO UNITS ON RECORD. THE FLOOR IS AUDITED AND EMPTY.',
idleHint: '1-9 SELECT · [ ] PAGE · SPACE HALT · CLICK A UNIT TO INSPECT',
} as const;
/** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */