[ui] migrate to the v4 typed selection protocol

Writes go through bus.setSelection(); the __remove magic string and its
REMOVE_DEF export are deleted — the renderer gets a real mode from main.ts now.

Deleted my own `remove` dispatch: main.ts dispatches it on click as of v4, and
the UI doing it too was a double-fire that only looked harmless because the
second command no-oped. Verified live: one click, one removal.

selection/setSelection are optional in the contract, so a legacy host still
gets the old {def,dir} shape (sentinel included) rather than silently getting
no selection at all. Tested both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:13:23 +10:00
parent 3cec62368a
commit 5a36e32246
3 changed files with 126 additions and 86 deletions

View File

@ -15,6 +15,7 @@ import { createInspector } from './inspector';
import { createRoster, entityAt, syncRoster } from './pick'; import { createRoster, entityAt, syncRoster } from './pick';
import { createSelection } from './selection'; import { createSelection } from './selection';
import { injectStyle } from './style'; import { injectStyle } from './style';
import { createTechPanel } from './techpanel';
import { createTopStrip } from './topstrip'; import { createTopStrip } from './topstrip';
import { createToasts } from './toasts'; import { createToasts } from './toasts';
@ -25,6 +26,7 @@ export function createUI(): UI {
let inspector: ReturnType<typeof createInspector>; let inspector: ReturnType<typeof createInspector>;
let fax: ReturnType<typeof createFax>; let fax: ReturnType<typeof createFax>;
let toasts: ReturnType<typeof createToasts>; let toasts: ReturnType<typeof createToasts>;
let tech: ReturnType<typeof createTechPanel>;
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */ /** Our own copy of the entity list; safe to read between frames. See pick.ts. */
const roster = createRoster(); const roster = createRoster();
@ -49,8 +51,9 @@ export function createUI(): UI {
inspector = createInspector(data, bus); inspector = createInspector(data, bus);
fax = createFax(data); fax = createFax(data);
toasts = createToasts(); toasts = createToasts();
tech = createTechPanel(data, bus);
root.append(top.el, build.el, inspector.el, fax.el, toasts.el); root.append(top.el, build.el, inspector.el, fax.el, tech.el, toasts.el);
// ------------------------------------------------------------------ keyboard // ------------------------------------------------------------------ keyboard
const keys = createHotkeys(); const keys = createHotkeys();
@ -59,10 +62,12 @@ export function createUI(): UI {
keys.bind(']', () => build.pageBy(1)); keys.bind(']', () => build.pageBy(1));
keys.bind('r', () => sel.rotate()); keys.bind('r', () => sel.rotate());
keys.bind('x', () => sel.toggleRemove()); keys.bind('x', () => sel.toggleRemove());
keys.bind('t', () => tech.toggle());
keys.bind('escape', () => { keys.bind('escape', () => {
// One key, several jobs, in the order the player expects: put the tool down // One key, several jobs, in the order the player expects: put the tool down
// first (wrecking ball included), close the panel only when empty-handed. // first (wrecking ball included), then shut whatever is open.
if (sel.isArmed()) sel.clear(); if (sel.isArmed()) sel.clear();
else if (tech.isOpen()) tech.close();
else inspector.close(); else inspector.close();
}); });
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused })); keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
@ -83,24 +88,28 @@ export function createUI(): UI {
addEventListener('pointerdown', (e) => { addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !inWorld(e.target)) return; if (e.button !== 0 || !inWorld(e.target)) return;
if (sel.get()) return; // placing, not inspecting — that click is main.ts's // Anything held — a machine or the wrecking ball — makes this main.ts's click:
// it places, and as of v4 it dispatches the removal too. We must not also
// dispatch, or every demolition fires twice.
if (sel.isArmed()) return;
const tile = bus.pickTile(e.clientX, e.clientY); const tile = bus.pickTile(e.clientX, e.clientY);
if (!tile) return; if (!tile) return;
if (sel.isRemoving()) {
// Only fire at something that's actually there, so a stray click on open
// ground doesn't read as a broken tool. Sim owns the real teardown.
if (entityAt(roster, machines, tile) !== null) {
bus.dispatch({ kind: 'remove', pos: tile });
}
return; // remove mode stays armed: demolition is usually plural
}
const hit = entityAt(roster, machines, tile); const hit = entityAt(roster, machines, tile);
if (hit === null) inspector.close(); if (hit === null) inspector.close();
else inspector.open(hit); else inspector.open(hit);
}); });
// ?uidemo — build SIM's reference factory on boot. main.ts defines __fktryDemo
// after ui.init(), and only in DEV, so ask on the next task rather than now.
if (new URLSearchParams(location.search).has('uidemo')) {
setTimeout(() => {
const demo = (globalThis as any).__fktryDemo;
if (typeof demo === 'function') demo();
else console.info('[ui] ?uidemo: no __fktryDemo on window (production build?)');
}, 0);
}
}, },
update(snap: SimSnapshot, events: SimEvent[]) { update(snap: SimSnapshot, events: SimEvent[]) {
@ -108,8 +117,10 @@ export function createUI(): UI {
syncRoster(roster, snap); syncRoster(roster, snap);
top.update(snap); top.update(snap);
build.update(snap); // padlocks track research
inspector.update(snap); inspector.update(snap);
fax.update(snap); fax.update(snap);
tech.update(snap);
for (const ev of events) { for (const ev of events) {
toasts.push(ev, machineName); toasts.push(ev, machineName);

View File

@ -1,76 +1,75 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { Command, Dir, UIBus, Vec2 } from '../contracts'; import type { Command, SelectionState, UIBus, Vec2 } from '../contracts';
import { createSelection, REMOVE_DEF } from './selection'; import { createSelection } from './selection';
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null }; /** A v4 host: typed selection, main.ts owns the state. */
function makeBus() {
function makeBus(): BusWithSel { let state: SelectionState = null;
const bus: BusWithSel = { const bus: UIBus = {
_sel: null,
dispatch: (_cmd: Command) => {}, dispatch: (_cmd: Command) => {},
selectedBuild: () => bus._sel, selectedBuild: () => (state && state.mode === 'build' ? { def: state.def, dir: state.dir } : null),
pickTile: (_x: number, _y: number): Vec2 | null => null, pickTile: (_x: number, _y: number): Vec2 | null => null,
selection: () => state,
setSelection: (s) => { state = s; },
}; };
return bus; return { bus, read: () => state };
} }
describe('createSelection', () => { describe('createSelection (v4 typed protocol)', () => {
it('writes the selection through to bus._sel, which main.ts reads for the ghost', () => { it('publishes a typed build selection', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.select('quantizer'); sel.select('quantizer');
expect(bus._sel).toEqual({ def: 'quantizer', dir: 0 }); expect(read()).toEqual({ mode: 'build', def: 'quantizer', dir: 0 });
expect(bus.selectedBuild()).toEqual({ def: 'quantizer', dir: 0 });
}); });
it('toggles off when the selected machine is selected again', () => { it('publishes {mode:"remove"} instead of a magic def string', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(read()).toEqual({ mode: 'remove' });
});
it('publishes null when empty-handed', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.clear();
expect(read()).toBeNull();
});
it('toggles a machine off when re-selected', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.select('belt'); sel.select('belt');
sel.select('belt'); sel.select('belt');
expect(sel.get()).toBeNull(); expect(sel.get()).toBeNull();
expect(bus._sel).toBeNull(); expect(read()).toBeNull();
}); });
it('rotates N->E->S->W and wraps', () => { it('rotates N->E->S->W and wraps', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.select('belt'); sel.select('belt');
const dirs = [1, 2, 3, 0].map(() => { const dirs = [0, 0, 0, 0].map(() => {
sel.rotate(); sel.rotate();
return sel.get()!.dir; return (read() as { dir: number }).dir;
}); });
expect(dirs).toEqual([1, 2, 3, 0]); expect(dirs).toEqual([1, 2, 3, 0]);
}); });
it('keeps the facing when swapping machines — you aim once, then build a line', () => { it('keeps the facing when swapping machines — you aim once, then build a line', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.select('belt'); sel.select('belt');
sel.rotate(); sel.rotate();
sel.rotate(); sel.rotate();
sel.select('demuxer'); sel.select('demuxer');
expect(sel.get()).toEqual({ def: 'demuxer', dir: 2 }); expect(read()).toEqual({ mode: 'build', def: 'demuxer', dir: 2 });
});
it('does nothing on rotate with nothing selected', () => {
const sel = createSelection(makeBus());
sel.rotate();
expect(sel.get()).toBeNull();
});
it('publishes the __remove sentinel on the bus so RENDER can tint the target', () => {
// The ghost is the only channel the UI has to the renderer (main.ts feeds setGhost
// from selectedBuild), so this sentinel IS the remove-mode hover contract.
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
expect(bus.selectedBuild()).toEqual({ def: REMOVE_DEF, dir: 0 });
}); });
it('reports no build selection while removing, so nothing gets placed', () => { it('reports no build selection while removing, so nothing gets placed', () => {
const bus = makeBus(); const { bus } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.toggleRemove(); sel.toggleRemove();
expect(sel.get()).toBeNull(); expect(sel.get()).toBeNull();
@ -79,52 +78,52 @@ describe('createSelection', () => {
}); });
it('disarms remove when a machine is picked — you cannot build and demolish at once', () => { it('disarms remove when a machine is picked — you cannot build and demolish at once', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.toggleRemove(); sel.toggleRemove();
sel.select('belt'); sel.select('belt');
expect(sel.isRemoving()).toBe(false); expect(sel.isRemoving()).toBe(false);
expect(bus._sel).toEqual({ def: 'belt', dir: 0 }); expect(read()).toEqual({ mode: 'build', def: 'belt', dir: 0 });
}); });
it('drops the held machine when remove is armed', () => { it('drops the held machine when remove is armed', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.select('belt'); sel.select('belt');
sel.toggleRemove(); sel.toggleRemove();
expect(sel.get()).toBeNull(); expect(sel.get()).toBeNull();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 }); expect(read()).toEqual({ mode: 'remove' });
}); });
it('toggles remove off again, leaving empty hands', () => { it('toggles remove off again, leaving empty hands', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.toggleRemove(); sel.toggleRemove();
sel.toggleRemove(); sel.toggleRemove();
expect(sel.isRemoving()).toBe(false);
expect(sel.isArmed()).toBe(false); expect(sel.isArmed()).toBe(false);
expect(bus._sel).toBeNull(); expect(read()).toBeNull();
}); });
it('clear() disarms remove mode, which is what Esc rides on', () => { it('clear() disarms remove mode, which is what Esc rides on', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.toggleRemove(); sel.toggleRemove();
sel.clear(); sel.clear();
expect(sel.isRemoving()).toBe(false); expect(sel.isRemoving()).toBe(false);
expect(bus._sel).toBeNull(); expect(read()).toBeNull();
}); });
it('does not rotate the wrecking ball', () => { it('does not rotate the wrecking ball', () => {
const bus = makeBus(); const { bus, read } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);
sel.toggleRemove(); sel.toggleRemove();
sel.rotate(); sel.rotate();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 }); expect(read()).toEqual({ mode: 'remove' });
}); });
it('notifies listeners on change, and not on a no-op clear', () => { it('notifies listeners on change, and not on a no-op clear', () => {
const sel = createSelection(makeBus()); const { bus } = makeBus();
const sel = createSelection(bus);
const cb = vi.fn(); const cb = vi.fn();
sel.onChange(cb); sel.onChange(cb);
@ -139,3 +138,31 @@ describe('createSelection', () => {
expect(cb).toHaveBeenCalledTimes(3); expect(cb).toHaveBeenCalledTimes(3);
}); });
}); });
describe('legacy host fallback', () => {
// selection/setSelection are optional in the contract; a host that predates them must
// still get a selection rather than silently getting nothing.
function legacyBus() {
const bus: UIBus & { _sel?: unknown } = {
_sel: null,
dispatch: () => {},
selectedBuild: () => null,
pickTile: () => null,
};
return bus;
}
it('writes the old {def,dir} shape when setSelection is absent', () => {
const bus = legacyBus();
const sel = createSelection(bus);
sel.select('belt');
expect(bus._sel).toEqual({ def: 'belt', dir: 0 });
});
it('still speaks the deprecated __remove sentinel to a legacy host', () => {
const bus = legacyBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(bus._sel).toEqual({ def: '__remove', dir: 0 });
});
});

View File

@ -1,28 +1,16 @@
/** /**
* LANE-UI the pending build selection, and remove mode. * LANE-UI the pending build selection, and remove mode.
* *
* main.ts reads this through `bus.selectedBuild()` for its ghost + click-to-place glue, * v4: the selection is main.ts's state and we write it through `bus.setSelection()`
* and the hook it documents is the `_sel` field on the bus object. UI is the only writer; * (contracts v4 `SelectionState`). The round-3 `__remove` magic string is gone the
* we write through to `_sel` on every change and treat our own copy as the truth. * renderer gets a real `mode` now and so is our own `remove` dispatch: **main.ts
* dispatches the removal on click.** We only say what is held; main.ts decides what a
* click on the world means.
* *
* REMOVE MODE / the `__remove` sentinel * `selection`/`setSelection` are optional in the contract, so we fall back to the legacy
* ------------------------------------ * `_sel` field if a host doesn't serve them yet (live.test.ts's own bus, for one).
* Remove-mode hover has to tint the target machine, and the renderer's only view of what
* the player is holding is `setGhost(...)`, which main.ts feeds from `selectedBuild()`.
* The UI has no Renderer reference, so the sentinel *is* the channel: while remove mode
* is armed, `_sel` reads `{ def: '__remove', dir: 0 }`. LANE-RENDER keys its demolition
* tint off that def id (agreed via NOTES).
*
* Safe because sim's `place` handler does `const def = defs.get(cmd.def); if (def) ...`
* main.ts's click-to-place fires a `place` for `__remove`, no machine matches, nothing
* happens. The actual removal is dispatched by the UI's own handler.
*
* A `UIBus.setGhostMode(...)` would be cleaner than a magic string; CONTRACT REQUEST filed.
*/ */
import type { Dir, UIBus } from '../contracts'; import type { Dir, SelectionState, UIBus } from '../contracts';
/** Ghost sentinel meaning "remove mode is armed". Shared with LANE-RENDER by agreement. */
export const REMOVE_DEF = '__remove';
export interface BuildSelection { export interface BuildSelection {
/** The pending *build*, or null. Null whenever remove mode is armed. */ /** The pending *build*, or null. Null whenever remove mode is armed. */
@ -39,18 +27,32 @@ export interface BuildSelection {
onChange(cb: () => void): void; onChange(cb: () => void): void;
} }
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null }; type LegacyBus = UIBus & { _sel?: { def: string; dir: Dir } | null };
export function createSelection(bus: UIBus): BuildSelection { export function createSelection(bus: UIBus): BuildSelection {
const hook = bus as BusWithSel;
let sel: { def: string; dir: Dir } | null = null; let sel: { def: string; dir: Dir } | null = null;
let removing = false; let removing = false;
const listeners: Array<() => void> = []; const listeners: Array<() => void> = [];
function publish() {
const state: SelectionState = removing
? { mode: 'remove' }
: sel
? { mode: 'build', def: sel.def, dir: sel.dir }
: null;
if (bus.setSelection) {
bus.setSelection(state);
} else {
// Legacy host: keep the old shape alive rather than silently doing nothing.
(bus as LegacyBus)._sel = removing ? { def: '__remove', dir: 0 } : sel;
}
}
function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) { function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) {
sel = next; sel = next;
removing = nextRemoving; removing = nextRemoving;
hook._sel = removing ? { def: REMOVE_DEF, dir: 0 } : next; publish();
for (const cb of listeners) cb(); for (const cb of listeners) cb();
} }