From 5a36e322467d39cd39e324e186af575823d972fe Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 20:13:23 +1000 Subject: [PATCH] [ui] migrate to the v4 typed selection protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fktry/src/ui/index.ts | 35 ++++++--- fktry/src/ui/selection.test.ts | 129 ++++++++++++++++++++------------- fktry/src/ui/selection.ts | 48 ++++++------ 3 files changed, 126 insertions(+), 86 deletions(-) diff --git a/fktry/src/ui/index.ts b/fktry/src/ui/index.ts index 493398c..c28deb7 100644 --- a/fktry/src/ui/index.ts +++ b/fktry/src/ui/index.ts @@ -15,6 +15,7 @@ import { createInspector } from './inspector'; import { createRoster, entityAt, syncRoster } from './pick'; import { createSelection } from './selection'; import { injectStyle } from './style'; +import { createTechPanel } from './techpanel'; import { createTopStrip } from './topstrip'; import { createToasts } from './toasts'; @@ -25,6 +26,7 @@ export function createUI(): UI { let inspector: ReturnType; let fax: ReturnType; let toasts: ReturnType; + let tech: ReturnType; /** Our own copy of the entity list; safe to read between frames. See pick.ts. */ const roster = createRoster(); @@ -49,8 +51,9 @@ export function createUI(): UI { inspector = createInspector(data, bus); fax = createFax(data); 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 const keys = createHotkeys(); @@ -59,10 +62,12 @@ export function createUI(): UI { keys.bind(']', () => build.pageBy(1)); keys.bind('r', () => sel.rotate()); keys.bind('x', () => sel.toggleRemove()); + keys.bind('t', () => tech.toggle()); keys.bind('escape', () => { // 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(); + else if (tech.isOpen()) tech.close(); else inspector.close(); }); keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused })); @@ -83,24 +88,28 @@ export function createUI(): UI { addEventListener('pointerdown', (e) => { 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); 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); if (hit === null) inspector.close(); 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[]) { @@ -108,8 +117,10 @@ export function createUI(): UI { syncRoster(roster, snap); top.update(snap); + build.update(snap); // padlocks track research inspector.update(snap); fax.update(snap); + tech.update(snap); for (const ev of events) { toasts.push(ev, machineName); diff --git a/fktry/src/ui/selection.test.ts b/fktry/src/ui/selection.test.ts index ea69207..cc778cf 100644 --- a/fktry/src/ui/selection.test.ts +++ b/fktry/src/ui/selection.test.ts @@ -1,76 +1,75 @@ import { describe, expect, it, vi } from 'vitest'; -import type { Command, Dir, UIBus, Vec2 } from '../contracts'; -import { createSelection, REMOVE_DEF } from './selection'; +import type { Command, SelectionState, UIBus, Vec2 } from '../contracts'; +import { createSelection } from './selection'; -type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null }; - -function makeBus(): BusWithSel { - const bus: BusWithSel = { - _sel: null, +/** A v4 host: typed selection, main.ts owns the state. */ +function makeBus() { + let state: SelectionState = null; + const bus: UIBus = { 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, + selection: () => state, + setSelection: (s) => { state = s; }, }; - return bus; + return { bus, read: () => state }; } -describe('createSelection', () => { - it('writes the selection through to bus._sel, which main.ts reads for the ghost', () => { - const bus = makeBus(); +describe('createSelection (v4 typed protocol)', () => { + it('publishes a typed build selection', () => { + const { bus, read } = makeBus(); const sel = createSelection(bus); sel.select('quantizer'); - expect(bus._sel).toEqual({ def: 'quantizer', dir: 0 }); - expect(bus.selectedBuild()).toEqual({ def: 'quantizer', dir: 0 }); + expect(read()).toEqual({ mode: 'build', def: 'quantizer', dir: 0 }); }); - it('toggles off when the selected machine is selected again', () => { - const bus = makeBus(); + it('publishes {mode:"remove"} instead of a magic def string', () => { + 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); sel.select('belt'); sel.select('belt'); expect(sel.get()).toBeNull(); - expect(bus._sel).toBeNull(); + expect(read()).toBeNull(); }); it('rotates N->E->S->W and wraps', () => { - const bus = makeBus(); + const { bus, read } = makeBus(); const sel = createSelection(bus); sel.select('belt'); - const dirs = [1, 2, 3, 0].map(() => { + const dirs = [0, 0, 0, 0].map(() => { sel.rotate(); - return sel.get()!.dir; + return (read() as { dir: number }).dir; }); expect(dirs).toEqual([1, 2, 3, 0]); }); 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); sel.select('belt'); sel.rotate(); sel.rotate(); sel.select('demuxer'); - expect(sel.get()).toEqual({ 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 }); + expect(read()).toEqual({ mode: 'build', def: 'demuxer', dir: 2 }); }); it('reports no build selection while removing, so nothing gets placed', () => { - const bus = makeBus(); + const { bus } = makeBus(); const sel = createSelection(bus); sel.toggleRemove(); 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', () => { - const bus = makeBus(); + const { bus, read } = makeBus(); const sel = createSelection(bus); sel.toggleRemove(); sel.select('belt'); 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', () => { - const bus = makeBus(); + const { bus, read } = makeBus(); const sel = createSelection(bus); sel.select('belt'); sel.toggleRemove(); 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', () => { - const bus = makeBus(); + const { bus, read } = makeBus(); const sel = createSelection(bus); sel.toggleRemove(); sel.toggleRemove(); - expect(sel.isRemoving()).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', () => { - const bus = makeBus(); + const { bus, read } = makeBus(); const sel = createSelection(bus); sel.toggleRemove(); sel.clear(); expect(sel.isRemoving()).toBe(false); - expect(bus._sel).toBeNull(); + expect(read()).toBeNull(); }); it('does not rotate the wrecking ball', () => { - const bus = makeBus(); + const { bus, read } = makeBus(); const sel = createSelection(bus); sel.toggleRemove(); 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', () => { - const sel = createSelection(makeBus()); + const { bus } = makeBus(); + const sel = createSelection(bus); const cb = vi.fn(); sel.onChange(cb); @@ -139,3 +138,31 @@ describe('createSelection', () => { 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 }); + }); +}); diff --git a/fktry/src/ui/selection.ts b/fktry/src/ui/selection.ts index 7aefeb8..aacacde 100644 --- a/fktry/src/ui/selection.ts +++ b/fktry/src/ui/selection.ts @@ -1,28 +1,16 @@ /** * LANE-UI — the pending build selection, and remove mode. * - * main.ts reads this through `bus.selectedBuild()` for its ghost + click-to-place glue, - * and the hook it documents is the `_sel` field on the bus object. UI is the only writer; - * we write through to `_sel` on every change and treat our own copy as the truth. + * v4: the selection is main.ts's state and we write it through `bus.setSelection()` + * (contracts v4 `SelectionState`). The round-3 `__remove` magic string is gone — the + * 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 - * ------------------------------------ - * 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. + * `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). */ -import type { Dir, UIBus } from '../contracts'; - -/** Ghost sentinel meaning "remove mode is armed". Shared with LANE-RENDER by agreement. */ -export const REMOVE_DEF = '__remove'; +import type { Dir, SelectionState, UIBus } from '../contracts'; export interface BuildSelection { /** The pending *build*, or null. Null whenever remove mode is armed. */ @@ -39,18 +27,32 @@ export interface BuildSelection { 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 { - const hook = bus as BusWithSel; let sel: { def: string; dir: Dir } | null = null; let removing = false; 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) { sel = next; removing = nextRemoving; - hook._sel = removing ? { def: REMOVE_DEF, dir: 0 } : next; + publish(); for (const cb of listeners) cb(); }