[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:
parent
45003db31a
commit
ea17e95318
@ -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());
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
@ -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(/^#/);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user