[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;
/**
* 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());

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.
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);

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
* 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<string, string> = {
'seam-extractor': '#c8a03f', // grimy yellow chassis
/** Accent by id — the "one impossible element" from each codex ASSET block. */
const ACCENT_BY_ID: Record<string, string> = {
'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<string, string> = {
shipper: '#d8ffd8', // THE SCREEN's own light
};
const BY_KIND: Record<MachineKind, string> = {
const ACCENT_BY_KIND: Record<MachineKind, string> = {
extractor: '#c8a03f',
belt: '#5a7a5a',
crafter: '#3fffe0',
@ -32,6 +40,34 @@ const BY_KIND: Record<MachineKind, string> = {
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<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 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> = {}): 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(/^#/);
});
});