diff --git a/fktry/src/ui/fax.ts b/fktry/src/ui/fax.ts index c859c23..f6a64f9 100644 --- a/fktry/src/ui/fax.ts +++ b/fktry/src/ui/fax.ts @@ -61,15 +61,19 @@ export function createFax(data: GameData): Fax { 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. + * What the tray is actually holding. + * + * `snap.commissionQueue` (contracts v3) is the truth: active first, then upcoming in + * activation order — so the peek is `queue[1]`. The round-2 data-order inference is + * gone; it was a guess that went wrong the moment a completed standing order was + * re-queued to the back, and a fax that lies quietly is worse than a fax that says + * nothing. If the field is absent, we show no peek. */ - 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]; + function peekNext(snap: SimSnapshot, activeId: string): CommissionDef | null { + const queue = snap.commissionQueue; + if (!queue) return null; + const nextId = queue[0] === activeId ? queue[1] : queue[0]; + return (nextId && commissions.get(nextId)) || null; } return { @@ -91,7 +95,7 @@ export function createFax(data: GameData): Fax { sanChips.update([{ item: SANITISED_ITEM, count: String(sanitised), state: 'plain' }]); } - const next = peekNext(c.id); + const next = peekNext(snap, c.id); cls(nextWrap, 'is-on', !!next); if (next) { text(nextName, next.id.replace(/-/g, ' ').toUpperCase()); diff --git a/fktry/src/ui/inspector.ts b/fktry/src/ui/inspector.ts index fc78b8c..b09a39c 100644 --- a/fktry/src/ui/inspector.ts +++ b/fktry/src/ui/inspector.ts @@ -154,13 +154,18 @@ export function createInspector(data: GameData, bus: UIBus): Inspector { } // Heat: a bar rather than a number, because it's a thing you watch climb. - const hot = e.heat > 0.01; + // A scrammed unit stays on screen while it cools, so show the bar for either. + const scram = e.scrammed === true; + const hot = e.heat > 0.01 || scram; 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; + // `scrammed` is the latch (contracts v3). Heat alone can't show it: a scrammed + // machine cools back below 1 while still being offline, and inferring the state + // from a threshold here would mean re-implementing SIM's hysteresis and silently + // desyncing the moment they retune it. + const throttling = !scram && e.heat > THROTTLE_AT; style(heatFill, 'width', `${Math.min(1, e.heat) * 100}%`); - cls(heatBar, 'is-throttling', throttling && !scram); + cls(heatBar, 'is-throttling', throttling); cls(heatBar, 'is-scram', scram); text(heatState, scram ? COPY.heatScram : throttling ? COPY.heatThrottling : ''); cls(heatState, 'is-scram', scram); diff --git a/fktry/src/ui/palette.ts b/fktry/src/ui/palette.ts index 5f0d6b3..d7e11cc 100644 --- a/fktry/src/ui/palette.ts +++ b/fktry/src/ui/palette.ts @@ -1,19 +1,27 @@ /** - * LANE-UI — machine accent colors for build-bar icons. + * LANE-UI — machine colors for build-bar icons. * - * `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. + * Contracts v3 RULING: `MachineDef.color` is the CHASSIS/BODY colour (codex §8 grime); + * the emissive accent is derived from what the machine outputs. So an icon is built the + * way the codex builds every asset — the two-material rule (§8): grimy industrial body + * plus exactly ONE impossible element. * - * 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. + * This matters for legibility, not just fidelity: DATA's chassis values are all + * desaturated neutrals (#4a4e52, #3f4348, #55595e…), exactly as §8 demands, and a bar of + * 21 icons in those colours is an unreadable grey wall. The accent is what tells a + * quantizer from a demuxer at a glance — and deriving it from outputs is also what + * LANE-RENDER does, so the bar and the world agree. + * + * The id/kind tables below are the round-1 hand-transcriptions from docs/FKTRY_LORE.md §4 + * — each machine's ASSET block names its impossible element, so they were always accent + * values. They were only ever mislabeled (the same mistake the v2 contract made); here + * they serve as the accent fallback for machines that output nothing (belts, buffers). */ -import type { MachineDef, MachineKind } from '../contracts'; +import type { GameData, ItemDef, MachineDef, MachineKind, RecipeDef } from '../contracts'; -const BY_ID: Record = { - 'seam-extractor': '#c8a03f', // grimy yellow chassis +/** Accent by id — the "one impossible element" from each codex ASSET block. */ +const ACCENT_BY_ID: Record = { + 'seam-extractor': '#c8a03f', // grimy yellow chassis, CRT head belt: '#5a7a5a', demuxer: '#ff3fd4', // the magenta output chute quantizer: '#e8e0ff', // HF dust leaking from the seams @@ -22,7 +30,7 @@ const BY_ID: Record = { shipper: '#d8ffd8', // THE SCREEN's own light }; -const BY_KIND: Record = { +const ACCENT_BY_KIND: Record = { extractor: '#c8a03f', belt: '#5a7a5a', crafter: '#3fffe0', @@ -32,6 +40,34 @@ const BY_KIND: Record = { shipper: '#d8ffd8', }; -export function machineColor(def: MachineDef): string { - return def.color || BY_ID[def.id] || BY_KIND[def.kind] || '#d8ffd8'; +/** Fallback chassis for machines DATA hasn't art-directed yet. */ +const CHASSIS_FALLBACK = '#4a4e52'; + +/** The body. Data wins; nothing here is ever saturated. */ +export function machineChassis(def: MachineDef): string { + return def.color || CHASSIS_FALLBACK; +} + +export type AccentFn = (def: MachineDef) => string; + +/** + * Accent lookup: the colour of the first thing the machine makes, else the codex table. + * Curried over GameData because the derivation needs recipes + items. + */ +export function createAccentLookup(data: GameData): AccentFn { + const recipes = new Map(data.recipes.map((r) => [r.id, r])); + const items = new Map(data.items.map((i) => [i.id, i])); + + return (def) => { + for (const rid of def.recipes) { + const r = recipes.get(rid); + if (!r) continue; + for (const out of Object.keys(r.outputs)) { + const c = items.get(out)?.color; + if (c) return c; + } + } + // Belts, splitters, buffers and shippers produce nothing — they get their codex glow. + return ACCENT_BY_ID[def.id] || ACCENT_BY_KIND[def.kind] || '#d8ffd8'; + }; } diff --git a/fktry/src/ui/voice.test.ts b/fktry/src/ui/voice.test.ts index e2e0ec1..7e9fbaa 100644 --- a/fktry/src/ui/voice.test.ts +++ b/fktry/src/ui/voice.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { MachineDef, MachineKind, SimEvent } from '../contracts'; -import { machineColor } from './palette'; +import type { GameData, MachineDef, MachineKind, SimEvent } from '../contracts'; +import { createAccentLookup, machineChassis } from './palette'; import { eventToast, jamText, machineFlavor } from './voice'; function def(over: Partial = {}): MachineDef { @@ -61,32 +61,66 @@ describe('eventToast', () => { expect(eventToast(e, named)).toContain('THERMAL SCRAM'); }); + it('confirms a removal, because demolition should feel acknowledged', () => { + const e: SimEvent = { kind: 'removed', entity: 1, def: 'belt', tick: 1 }; + expect(eventToast(e, named)).toBe('UNIT RECLAIMED. THE FLOOR REMEMBERS.'); + }); + it('stays quiet for the high-frequency events', () => { for (const e of [ { kind: 'shipped', item: 'melt', count: 1, tick: 1 }, { kind: 'crafted', entity: 1, recipe: 'mosh', tick: 1 }, { kind: 'placed', entity: 1, def: 'belt', tick: 1 }, - { kind: 'removed', entity: 1, def: 'belt', tick: 1 }, ] as SimEvent[]) { expect(eventToast(e, named)).toBeNull(); } }); }); -describe('machineColor', () => { - it('prefers the art-directed color from data', () => { - expect(machineColor(def({ color: '#123456' }))).toBe('#123456'); +describe('machineChassis', () => { + it('uses the art-directed body colour from data', () => { + expect(machineChassis(def({ color: '#4e5a62' }))).toBe('#4e5a62'); }); - it('falls back to the codex-derived id color', () => { - expect(machineColor(def({ id: 'mosh-reactor' }))).toBe('#ff7a3f'); + it('falls back to a neutral for a machine DATA has not art-directed', () => { + expect(machineChassis(def({ color: undefined }))).toMatch(/^#/); + }); +}); + +describe('createAccentLookup', () => { + // contracts v3: color is the chassis; the accent derives from what the machine makes. + const data: GameData = { + items: [ + { id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff7a3f' }, + { id: 'hf-dust', name: 'HF DUST', codex: '', tier: 1, color: '#e8e0ff' }, + ], + machines: [], + recipes: [ + { id: 'mosh', machine: 'mosh-reactor', inputs: {}, outputs: { melt: 1 }, ticks: 90, bandwidth: 10 }, + ], + tech: [], + commissions: [], + }; + const accentOf = createAccentLookup(data); + + it('derives the accent from the first thing the machine outputs', () => { + expect(accentOf(def({ id: 'mosh-reactor', recipes: ['mosh'] }))).toBe('#ff7a3f'); }); - it('falls back to a kind color for a machine with no entry', () => { - expect(machineColor(def({ id: 'brand-new-thing', kind: 'power' }))).toBe('#3fffe0'); + it('falls back to the codex glow for machines that output nothing', () => { + // Belts carry; they do not produce. Their accent has to come from the codex table. + expect(accentOf(def({ id: 'belt', kind: 'belt', recipes: [] }))).toBe('#5a7a5a'); + }); + + it('falls back by kind for an unknown machine with no recipes', () => { + expect(accentOf(def({ id: 'brand-new-thing', kind: 'power', recipes: [] }))).toBe('#3fffe0'); + }); + + it('ignores a recipe id that does not resolve rather than throwing', () => { + expect(accentOf(def({ id: 'x', kind: 'crafter', recipes: ['ghost-recipe'] }))).toBe('#3fffe0'); }); it('always returns something for an unheard-of kind', () => { - expect(machineColor(def({ id: 'x', kind: 'teleporter' as MachineKind }))).toMatch(/^#/); + expect(accentOf(def({ id: 'x', kind: 'teleporter' as MachineKind, recipes: [] }))).toMatch(/^#/); }); });