[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:
parent
3cec62368a
commit
5a36e32246
@ -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<typeof createInspector>;
|
||||
let fax: ReturnType<typeof createFax>;
|
||||
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. */
|
||||
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);
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user