[ui] dev-only fake sim behind ?uifixture

The sim stub reported an empty world, so there was nothing on screen to
verify the HUD against: an empty inspector and a 0/0 meter prove nothing.
This synthesises a plausible snapshot -- entities mid-craft, a jam, heat,
shipments, a recurring brownout and commission -- so every surface can be
watched doing its job in a browser.

Runs on a wall clock rather than the sim tick: rAF stops in a backgrounded
tab, and a tick-driven fixture crawls too slowly to ever reach the brownout
window. It is a fake sim for eyeballing chrome; nothing here is or needs to
be deterministic.

Inert without the query param, dispatches no Commands, touches no other
lane. Delete once the real sim can be driven to a brownout by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 16:16:02 +10:00
parent c18309f464
commit df1eee7249

135
fktry/src/ui/fixture.ts Normal file
View File

@ -0,0 +1,135 @@
/**
* LANE-UI dev-only fake sim, for verifying the HUD before LANE-SIM lands.
*
* LANE-SIM's stub reports zero entities, zero bandwidth and no events forever, so with
* the real snapshot there is literally nothing on screen to check: an empty inspector
* and a 0/0 meter prove nothing. This module synthesises a plausible snapshot from the
* real tick so every surface can be seen doing its job in a browser.
*
* INERT unless the URL says `?uifixture`. It never dispatches Commands and never touches
* another lane; when the real sim arrives, drop the query param (or this file).
*
* See NOTES this is scaffolding, not a feature.
*/
import type { GameData, EntityState, SimEvent, SimSnapshot } from '../contracts';
export function fixtureEnabled(): boolean {
return typeof location !== 'undefined' && new URLSearchParams(location.search).has('uifixture');
}
export interface Fixture {
snapshot(paused: boolean): SimSnapshot;
/** Events for ticks that have elapsed since the last call. */
drain(): SimEvent[];
}
const SHIP_EVERY = 90; // one melt every 3s at 30tps
const MS_PER_TICK = 1000 / 30;
/** Never replay more than this many ticks of events in one drain (backgrounded tab). */
const MAX_CATCHUP = 300;
export function createFixture(data: GameData): Fixture {
const entities: EntityState[] = data.machines.map((m, i) => ({
id: i + 1,
def: m.id,
pos: { x: i * 3 - 9, y: i % 2 === 0 ? -2 : 2 },
dir: (i % 4) as 0 | 1 | 2 | 3,
recipe: m.recipes[0] ?? null,
progress: 0,
inputBuf: {},
outputBuf: {},
jammed: null,
heat: 0,
}));
const recipes = new Map(data.recipes.map((r) => [r.id, r]));
const jamVictim = entities.find((e) => e.def === 'mosh-reactor') ?? entities[0];
let lastTick = -1;
let brownoutOn = false;
// Wall clock, not the passed-in sim tick. rAF stops in a backgrounded tab, so a
// tick-driven fixture crawls and you can never actually watch the brownout arrive.
// This is a fake sim for eyeballing the HUD; nothing here has to be deterministic.
const t0 = performance.now();
const nowTick = () => Math.floor((performance.now() - t0) / MS_PER_TICK);
function brownoutAt(tick: number) {
return tick % 600 > 480; // ~4s of pain every 20s
}
return {
snapshot(paused) {
const tick = nowTick();
for (let i = 0; i < entities.length; i++) {
const e = entities[i];
const r = e.recipe ? recipes.get(e.recipe) : null;
e.progress = r ? ((tick + i * 13) % r.ticks) / r.ticks : 0;
// The mosh reactor gets to be the one that's unhappy, for the amber readout.
e.jammed = e.def === 'mosh-reactor' && tick % 300 > 240 ? 'output buffer full' : null;
e.heat = e.def === 'quantizer' ? Math.min(1, (tick % 900) / 900) : 0;
if (r) {
e.inputBuf = {};
for (const [item, need] of Object.entries(r.inputs)) {
e.inputBuf[item] = (tick + i) % (need + 2);
}
e.outputBuf = {};
for (const item of Object.keys(r.outputs)) {
e.outputBuf[item] = Math.floor(((tick + i * 7) % 200) / 40);
}
}
}
const shipped = Math.floor(tick / SHIP_EVERY);
const out = brownoutAt(tick);
const draw = 6 + (out ? 22 : Math.abs(((tick / 60) % 4) - 2) * 7);
const shippedTotal: Record<string, number> = {};
if (shipped > 0) {
shippedTotal.melt = shipped;
shippedTotal['anchor-slab'] = shipped;
}
return {
tick,
paused,
entities,
beltItems: [],
bandwidth: { gen: 20, draw, stored: out ? 0 : 40 + Math.sin(tick / 40) * 20, brownout: out },
shippedTotal,
activeCommission: data.commissions[0]?.id ?? null,
commissionProgress: { melt: shipped },
};
},
drain() {
const tick = nowTick();
const events: SimEvent[] = [];
if (lastTick < 0) {
lastTick = tick;
return events;
}
if (tick - lastTick > MAX_CATCHUP) lastTick = tick - MAX_CATCHUP;
for (let t = lastTick + 1; t <= tick; t++) {
if (t % SHIP_EVERY === 0 && t > 0) {
events.push({ kind: 'shipped', item: 'melt', count: 1, tick: t });
// Recurring rather than one-shot: the stamp is a 1.8s animation, and a
// fixture that fires it once at tick 90 is unobservable in practice.
if (t % (SHIP_EVERY * 3) === 0) {
const c = data.commissions[0];
if (c) events.push({ kind: 'commissionDone', commission: c.id, tick: t });
}
}
if (t % 300 === 241 && jamVictim) {
events.push({ kind: 'jammed', entity: jamVictim.id, reason: 'output buffer full', tick: t });
}
const out = brownoutAt(t);
if (out !== brownoutOn) {
brownoutOn = out;
events.push({ kind: 'brownout', on: out, tick: t });
}
}
lastTick = tick;
return events;
},
};
}