From df1eee724957caa1bbb2a558324ae6ec8f6a997a Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 16:16:02 +1000 Subject: [PATCH] [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 --- fktry/src/ui/fixture.ts | 135 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 fktry/src/ui/fixture.ts diff --git a/fktry/src/ui/fixture.ts b/fktry/src/ui/fixture.ts new file mode 100644 index 0000000..389c699 --- /dev/null +++ b/fktry/src/ui/fixture.ts @@ -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 = {}; + 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; + }, + }; +}