diff --git a/fktry/src/ui/chipspec.ts b/fktry/src/ui/chipspec.ts new file mode 100644 index 0000000..ade7316 --- /dev/null +++ b/fktry/src/ui/chipspec.ts @@ -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): 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): boolean { + return Object.entries(c.wants).every(([item, need]) => (progress[item] ?? 0) >= need); +} diff --git a/fktry/src/ui/fax.ts b/fktry/src/ui/fax.ts index 85b9d34..c859c23 100644 --- a/fktry/src/ui/fax.ts +++ b/fktry/src/ui/fax.ts @@ -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(data.commissions.map((c) => [c.id, c])); const items = new Map(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. diff --git a/fktry/src/ui/inspector.ts b/fktry/src/ui/inspector.ts index 6ce8258..fc78b8c 100644 --- a/fktry/src/ui/inspector.ts +++ b/fktry/src/ui/inspector.ts @@ -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(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])); @@ -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 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; diff --git a/fktry/src/ui/voice.ts b/fktry/src/ui/voice.ts index dc4d5fb..216c837 100644 --- a/fktry/src/ui/voice.ts +++ b/fktry/src/ui/voice.ts @@ -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 = { 'seam-extractor': @@ -44,19 +44,39 @@ const MACHINE_FLAVOR: Record = { 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 = { + '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. */