[ui] click-to-inspect via bus.pickTile; drop the TAB fallback and fixture

Contract request 1 landed, so the inspector opens the way it always should
have: click a machine. pick.ts loses the window.__fktryRenderer probe and
its apology; index.ts loses the TAB manifest cycle that stood in for it.

?uifixture and fixture.ts are deleted too. The sim is real now — a brownout
is something you cause by building an extractor with no generator, which is
a better demo than the fake one ever was.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 17:27:25 +10:00
parent 34e2bb3fcf
commit 491fbf2d68
3 changed files with 14 additions and 199 deletions

View File

@ -1,135 +0,0 @@
/**
* 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;
},
};
}

View File

@ -10,10 +10,9 @@
import type { GameData, MachineDef, SimEvent, SimSnapshot, UI, UIBus } from '../contracts';
import { createBuildBar } from './buildbar';
import { createFax } from './fax';
import { createFixture, fixtureEnabled, type Fixture } from './fixture';
import { createHotkeys } from './hotkeys';
import { createInspector } from './inspector';
import { createRoster, entityAt, resolvePickTile, syncRoster, type PickTile } from './pick';
import { createRoster, entityAt, syncRoster } from './pick';
import { createSelection } from './selection';
import { injectStyle } from './style';
import { createTopStrip } from './topstrip';
@ -27,35 +26,16 @@ export function createUI(): UI {
let fax: ReturnType<typeof createFax>;
let toasts: ReturnType<typeof createToasts>;
let pickTile: PickTile | null = null;
let fixture: Fixture | null = null;
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
const roster = createRoster();
let paused = false;
let manifestIndex = -1;
function rosterIndexOf(id: number): number {
for (let i = 0; i < roster.length; i++) if (roster.entries[i].id === id) return i;
return -1;
}
function machineName(entityId: number): string {
const i = rosterIndexOf(entityId);
if (i < 0) return `UNIT ${entityId}`;
return machines.get(roster.entries[i].def)?.name ?? `UNIT ${entityId}`;
}
/**
* TEMPORARY: nothing reachable from the UI can turn a click into a tile (see pick.ts),
* so TAB walks the unit manifest instead. That keeps the inspector reachable with no
* console, which is the round's definition of done. Delete once the CONTRACT REQUEST
* for `UIBus.pickTile` lands and clicking a machine works.
*/
function cycleManifest() {
if (roster.length === 0) return;
manifestIndex = (manifestIndex + 1) % roster.length;
inspector.open(roster.entries[manifestIndex].id);
for (let i = 0; i < roster.length; i++) {
const r = roster.entries[i];
if (r.id === entityId) return machines.get(r.def)?.name ?? `UNIT ${entityId}`;
}
return `UNIT ${entityId}`;
}
return {
@ -66,20 +46,17 @@ export function createUI(): UI {
const sel = createSelection(bus);
top = createTopStrip(data);
build = createBuildBar(data, sel);
inspector = createInspector(data);
inspector = createInspector(data, bus);
fax = createFax(data);
toasts = createToasts();
root.append(top.el, build.el, inspector.el, fax.el, toasts.el);
if (fixtureEnabled()) {
fixture = createFixture(data);
console.info('[ui] fixture mode: the HUD is being driven by a fake sim (?uifixture)');
}
// ------------------------------------------------------------------ keyboard
const keys = createHotkeys();
for (let n = 1; n <= 9; n++) keys.bind(String(n), () => build.selectSlot(n));
keys.bind('[', () => build.pageBy(-1));
keys.bind(']', () => build.pageBy(1));
keys.bind('r', () => sel.rotate());
keys.bind('escape', () => {
// One key, two jobs, in the order the player expects: drop the tool first.
@ -87,7 +64,6 @@ export function createUI(): UI {
else inspector.close();
});
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
keys.bind('tab', cycleManifest);
keys.attach();
// ------------------------------------------------------------------- pointer
@ -107,26 +83,16 @@ export function createUI(): UI {
if (e.button !== 0 || !inWorld(e.target)) return;
if (sel.get()) return; // placing, not inspecting — that click is main.ts's
pickTile ??= resolvePickTile(); // renderer may register late
const tile = pickTile?.(e.clientX, e.clientY);
const tile = bus.pickTile(e.clientX, e.clientY);
if (!tile) return;
const hit = entityAt(roster, machines, tile);
if (hit === null) {
inspector.close();
} else {
inspector.open(hit);
manifestIndex = rosterIndexOf(hit);
}
if (hit === null) inspector.close();
else inspector.open(hit);
});
},
update(snap: SimSnapshot, events: SimEvent[]) {
if (fixture) {
snap = fixture.snapshot(snap.paused);
events = fixture.drain();
}
paused = snap.paused;
syncRoster(roster, snap);

View File

@ -1,27 +1,11 @@
/**
* LANE-UI figuring out which unit the player clicked.
*
* PROBLEM: `UIBus` gives us `dispatch` + `selectedBuild`, and `ui.init(root, data, bus)`
* never sees the Renderer so the UI has no access to `renderer.pickTile`, which is the
* only thing that knows the camera. CONTRACT REQUEST filed in NOTES (add `pickTile` to
* UIBus, or pass the renderer to `ui.init`).
*
* Until then: we opportunistically use `window.__fktryRenderer.pickTile` if LANE-RENDER
* happens to expose one, and the inspector otherwise falls back to TAB-cycling the unit
* manifest (see index.ts). Tileentity resolution below is ours either way.
* `bus.pickTile` (contracts v2) turns a client point into a tile; tileentity resolution
* below is ours. Together they are click-to-inspect.
*/
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
export type PickTile = (clientX: number, clientY: number) => Vec2 | null;
export function resolvePickTile(): PickTile | null {
const r = (globalThis as any).__fktryRenderer;
if (r && typeof r.pickTile === 'function') {
return (x, y) => r.pickTile(x, y) ?? null;
}
return null;
}
/**
* Flattened copy of the entities in a snapshot: exactly the fields the UI needs between
* frames (hit-testing a click, naming a jammed unit in a toast).