[ui] conform to contracts v3: queue truth, scram latch, chassis vs accent

NEXT IN TRAY now reads snap.commissionQueue[1]. SIM publishes the field, so
the round-2 data-order inference is deleted rather than kept as a fallback —
it went wrong the moment a standing order was re-queued to the back, and a fax
that lies quietly is worse than one that says nothing. No queue, no peek.

Inspector reads EntityState.scrammed instead of inferring scram from heat >= 1.
A scrammed machine cools back below 1.0 while still offline, so the threshold
was wrong in exactly the window that matters; re-deriving SIM's hysteresis here
would also desync silently the moment they retune HEAT_RESTART.

Build-bar icons follow the codex's two-material rule, prompted by the v3 ruling
that color is chassis, not accent: grimy body from data, plus one emissive
element derived from the machine's first output item — the same derivation
RENDER uses, so the bar and the world agree. DATA's chassis values are all
desaturated neutrals (correct per §8), and 21 icons in those colours is an
unreadable grey wall; the accent is what tells a quantizer from a demuxer.
The round-1 id/kind tables were never wrong, only mislabeled — they're the
accent fallback now, for machines that output nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 18:34:22 +10:00
parent 45003db31a
commit ea17e95318
4 changed files with 117 additions and 38 deletions

View File

@ -61,15 +61,19 @@ export function createFax(data: GameData): Fax {
let stampTimer: number | undefined; let stampTimer: number | undefined;
/** /**
* TEMPORARY: the snapshot exposes `activeCommission` but no queue, and the sim * What the tray is actually holding.
* 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 * `snap.commissionQueue` (contracts v3) is the truth: active first, then upcoming in
* in NOTES for `nextCommission` / a queue on the snapshot. * 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 { function peekNext(snap: SimSnapshot, activeId: string): CommissionDef | null {
const i = data.commissions.findIndex((c) => c.id === activeId); const queue = snap.commissionQueue;
if (i < 0 || data.commissions.length < 2) return null; if (!queue) return null;
return data.commissions[(i + 1) % data.commissions.length]; const nextId = queue[0] === activeId ? queue[1] : queue[0];
return (nextId && commissions.get(nextId)) || null;
} }
return { return {
@ -91,7 +95,7 @@ export function createFax(data: GameData): Fax {
sanChips.update([{ item: SANITISED_ITEM, count: String(sanitised), state: 'plain' }]); 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); cls(nextWrap, 'is-on', !!next);
if (next) { if (next) {
text(nextName, next.id.replace(/-/g, ' ').toUpperCase()); text(nextName, next.id.replace(/-/g, ' ').toUpperCase());

View File

@ -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. // 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'); for (const n of [heatHead, heatBar, heatState]) style(n, 'display', hot ? '' : 'none');
if (hot) { if (hot) {
const scram = e.heat >= 1; // `scrammed` is the latch (contracts v3). Heat alone can't show it: a scrammed
const throttling = e.heat > THROTTLE_AT; // 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}%`); 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); cls(heatBar, 'is-scram', scram);
text(heatState, scram ? COPY.heatScram : throttling ? COPY.heatThrottling : ''); text(heatState, scram ? COPY.heatScram : throttling ? COPY.heatThrottling : '');
cls(heatState, 'is-scram', scram); cls(heatState, 'is-scram', scram);

View File

@ -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 * Contracts v3 RULING: `MachineDef.color` is the CHASSIS/BODY colour (codex §8 grime);
* has filled it the same value LANE-RENDER tints its placeholder boxes with, so the * the emissive accent is derived from what the machine outputs. So an icon is built the
* bar and the world agree about what a quantizer looks like. * 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 * This matters for legibility, not just fidelity: DATA's chassis values are all
* docs/FKTRY_LORE.md §4 (each machine's ASSET block names its impossible element). * desaturated neutrals (#4a4e52, #3f4348, #55595e), exactly as §8 demands, and a bar of
* Order: data color, else id override, else kind fallback, else house green. Never * 21 icons in those colours is an unreadable grey wall. The accent is what tells a
* crashes on a machine we've never heard of. * 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<string, string> = { /** Accent by id — the "one impossible element" from each codex ASSET block. */
'seam-extractor': '#c8a03f', // grimy yellow chassis const ACCENT_BY_ID: Record<string, string> = {
'seam-extractor': '#c8a03f', // grimy yellow chassis, CRT head
belt: '#5a7a5a', belt: '#5a7a5a',
demuxer: '#ff3fd4', // the magenta output chute demuxer: '#ff3fd4', // the magenta output chute
quantizer: '#e8e0ff', // HF dust leaking from the seams quantizer: '#e8e0ff', // HF dust leaking from the seams
@ -22,7 +30,7 @@ const BY_ID: Record<string, string> = {
shipper: '#d8ffd8', // THE SCREEN's own light shipper: '#d8ffd8', // THE SCREEN's own light
}; };
const BY_KIND: Record<MachineKind, string> = { const ACCENT_BY_KIND: Record<MachineKind, string> = {
extractor: '#c8a03f', extractor: '#c8a03f',
belt: '#5a7a5a', belt: '#5a7a5a',
crafter: '#3fffe0', crafter: '#3fffe0',
@ -32,6 +40,34 @@ const BY_KIND: Record<MachineKind, string> = {
shipper: '#d8ffd8', shipper: '#d8ffd8',
}; };
export function machineColor(def: MachineDef): string { /** Fallback chassis for machines DATA hasn't art-directed yet. */
return def.color || BY_ID[def.id] || BY_KIND[def.kind] || '#d8ffd8'; 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<string, RecipeDef>(data.recipes.map((r) => [r.id, r]));
const items = new Map<string, ItemDef>(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';
};
} }

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { MachineDef, MachineKind, SimEvent } from '../contracts'; import type { GameData, MachineDef, MachineKind, SimEvent } from '../contracts';
import { machineColor } from './palette'; import { createAccentLookup, machineChassis } from './palette';
import { eventToast, jamText, machineFlavor } from './voice'; import { eventToast, jamText, machineFlavor } from './voice';
function def(over: Partial<MachineDef> = {}): MachineDef { function def(over: Partial<MachineDef> = {}): MachineDef {
@ -61,32 +61,66 @@ describe('eventToast', () => {
expect(eventToast(e, named)).toContain('THERMAL SCRAM'); 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', () => { it('stays quiet for the high-frequency events', () => {
for (const e of [ for (const e of [
{ kind: 'shipped', item: 'melt', count: 1, tick: 1 }, { kind: 'shipped', item: 'melt', count: 1, tick: 1 },
{ kind: 'crafted', entity: 1, recipe: 'mosh', tick: 1 }, { kind: 'crafted', entity: 1, recipe: 'mosh', tick: 1 },
{ kind: 'placed', entity: 1, def: 'belt', tick: 1 }, { kind: 'placed', entity: 1, def: 'belt', tick: 1 },
{ kind: 'removed', entity: 1, def: 'belt', tick: 1 },
] as SimEvent[]) { ] as SimEvent[]) {
expect(eventToast(e, named)).toBeNull(); expect(eventToast(e, named)).toBeNull();
} }
}); });
}); });
describe('machineColor', () => { describe('machineChassis', () => {
it('prefers the art-directed color from data', () => { it('uses the art-directed body colour from data', () => {
expect(machineColor(def({ color: '#123456' }))).toBe('#123456'); expect(machineChassis(def({ color: '#4e5a62' }))).toBe('#4e5a62');
}); });
it('falls back to the codex-derived id color', () => { it('falls back to a neutral for a machine DATA has not art-directed', () => {
expect(machineColor(def({ id: 'mosh-reactor' }))).toBe('#ff7a3f'); 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', () => { it('falls back to the codex glow for machines that output nothing', () => {
expect(machineColor(def({ id: 'brand-new-thing', kind: 'power' }))).toBe('#3fffe0'); // 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', () => { 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(/^#/);
}); });
}); });