[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:
parent
cd733445d9
commit
e25c854be8
43
fktry/src/ui/chipspec.ts
Normal file
43
fktry/src/ui/chipspec.ts
Normal 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);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user