From acb1d88038e608871a7eb8f076f093d747583e96 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 14:11:59 +1000 Subject: [PATCH] [ui] publish inspect mode so the renderer can highlight the open unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the inspector now publishes {mode:'inspect', entity} through setSelection, and closing it — by X, by Esc, or because the unit was demolished under the panel — clears it. Picking a machine or arming remove supersedes the inspect selection and shuts the panel, so the panel and the highlight can never disagree about what is being looked at. The three modes are now genuinely exclusive in selection.ts rather than two booleans and an implied third state. Inspect deliberately does not count as "armed": you can click straight from one unit to another without putting anything down. Co-Authored-By: Claude Opus 4.8 --- fktry/src/ui/inspector.ts | 22 +++++++++- fktry/src/ui/selection.test.ts | 78 ++++++++++++++++++++++++++++++++++ fktry/src/ui/selection.ts | 70 ++++++++++++++++++++---------- 3 files changed, 146 insertions(+), 24 deletions(-) diff --git a/fktry/src/ui/inspector.ts b/fktry/src/ui/inspector.ts index b09a39c..6c562b1 100644 --- a/fktry/src/ui/inspector.ts +++ b/fktry/src/ui/inspector.ts @@ -27,7 +27,7 @@ export interface Inspector { /** Heat above this throttles (CONTRACTS: "throttles >0.7, scrams at 1"). */ const THROTTLE_AT = 0.7; -export function createInspector(data: GameData, bus: UIBus): Inspector { +export function createInspector(data: GameData, bus: UIBus, onClose?: () => void): Inspector { const machines = new Map(data.machines.map((m) => [m.id, m])); const recipes = new Map(data.recipes.map((r) => [r.id, r])); const items = new Map(data.items.map((i) => [i.id, i])); @@ -72,6 +72,12 @@ export function createInspector(data: GameData, bus: UIBus): Inspector { const jam = el('div', 'fk-ins-jam'); + // MOSQUITO EXPOSURE (v5): a swarm degrading this machine, surfaced where the player + // is already looking to understand why it's slow. + const exposure = el('div', 'fk-ins-exposure'); + const exposureLine = el('div', 'fk-ins-exposure-pct'); + exposure.append(el('div', 'fk-h', [COPY.mosquitoExposure]), exposureLine); + const body = el('div', undefined, [ recHead, recSelect, noRecipe, inHead, inChips.el, outHead, outChips.el, @@ -79,14 +85,16 @@ export function createInspector(data: GameData, bus: UIBus): Inspector { heatHead, heatBar, heatState, ]); - root.append(head, flavor, el('div', 'fk-rule'), body, jam); + root.append(head, flavor, el('div', 'fk-rule'), body, exposure, jam); let openId: number | null = null; function close() { + if (openId === null) return; // already shut — don't re-notify selection openId = null; optionsFor = null; cls(root, 'is-open', false); + onClose?.(); } closeBtn.addEventListener('click', close); @@ -190,6 +198,16 @@ export function createInspector(data: GameData, bus: UIBus): Inspector { const e = snap.entities.find((x) => x.id === openId); if (!e) return close(); // unit was removed out from under us paint(e); + + // A swarm degrading THIS machine, if any. size is severity 0..1; we render it as + // the precision hit. TEMPORARY: the sim owns the true throughput penalty and + // doesn't expose a number, so this is the swarm's severity, not a confirmed + // slowdown — see NOTES. Absent snap.wildlife (sim hasn't shipped it), no row. + const swarm = snap.wildlife?.find( + (w) => w.kind === 'mosquito-swarm' && w.target === openId, + ); + cls(exposure, 'is-on', !!swarm); + if (swarm) text(exposureLine, COPY.mosquitoPenalty(Math.round(swarm.size * 100))); }, }; } diff --git a/fktry/src/ui/selection.test.ts b/fktry/src/ui/selection.test.ts index cc778cf..03f6e82 100644 --- a/fktry/src/ui/selection.test.ts +++ b/fktry/src/ui/selection.test.ts @@ -121,6 +121,84 @@ describe('createSelection (v4 typed protocol)', () => { expect(read()).toEqual({ mode: 'remove' }); }); + // ---------------------------------------------------------------- v5 inspect mode + it('publishes the inspected entity so the renderer can highlight it', () => { + const { bus, read } = makeBus(); + const sel = createSelection(bus); + sel.inspect(42); + expect(read()).toEqual({ mode: 'inspect', entity: 42 }); + expect(sel.isInspecting()).toBe(true); + }); + + it('does not count inspect as holding a tool — you can click another unit', () => { + const { bus } = makeBus(); + const sel = createSelection(bus); + sel.inspect(42); + expect(sel.isArmed()).toBe(false); + expect(sel.get()).toBeNull(); + }); + + it('moves the highlight when a different unit is inspected', () => { + const { bus, read } = makeBus(); + const sel = createSelection(bus); + sel.inspect(1); + sel.inspect(2); + expect(read()).toEqual({ mode: 'inspect', entity: 2 }); + }); + + it('stops highlighting when the inspector closes', () => { + const { bus, read } = makeBus(); + const sel = createSelection(bus); + sel.inspect(42); + sel.clearInspect(); + expect(read()).toBeNull(); + expect(sel.isInspecting()).toBe(false); + }); + + it('clearInspect is a no-op when we were not inspecting', () => { + const { bus, read } = makeBus(); + const sel = createSelection(bus); + sel.select('belt'); + sel.clearInspect(); + expect(read()).toEqual({ mode: 'build', def: 'belt', dir: 0 }); // tool untouched + }); + + it('picking a tool supersedes the inspect highlight', () => { + const { bus, read } = makeBus(); + const sel = createSelection(bus); + sel.inspect(42); + sel.select('quantizer'); + expect(sel.isInspecting()).toBe(false); + expect(read()).toEqual({ mode: 'build', def: 'quantizer', dir: 0 }); + }); + + it('arming remove supersedes the inspect highlight', () => { + const { bus, read } = makeBus(); + const sel = createSelection(bus); + sel.inspect(42); + sel.toggleRemove(); + expect(sel.isInspecting()).toBe(false); + expect(read()).toEqual({ mode: 'remove' }); + }); + + it('clear() drops the inspect highlight too — that is what Esc rides on', () => { + const { bus, read } = makeBus(); + const sel = createSelection(bus); + sel.inspect(42); + sel.clear(); + expect(read()).toBeNull(); + }); + + it('re-inspecting the same entity does not churn the bus', () => { + const { bus } = makeBus(); + let writes = 0; + const spy: UIBus = { ...bus, setSelection: (s) => { writes++; bus.setSelection!(s); } }; + const sel = createSelection(spy); + sel.inspect(42); + sel.inspect(42); + expect(writes).toBe(1); + }); + it('notifies listeners on change, and not on a no-op clear', () => { const { bus } = makeBus(); const sel = createSelection(bus); diff --git a/fktry/src/ui/selection.ts b/fktry/src/ui/selection.ts index aacacde..014da03 100644 --- a/fktry/src/ui/selection.ts +++ b/fktry/src/ui/selection.ts @@ -1,28 +1,35 @@ /** - * LANE-UI — the pending build selection, and remove mode. + * LANE-UI — the player's current intent: a pending build, remove mode, or an inspected + * unit. All three are published through `bus.setSelection()` (contracts v4/v5), and they + * are mutually exclusive — you hold the wrecking ball, or a machine, or you're looking at + * a unit, never two at once. * - * 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. + * main.ts owns the state and decides what a world click means (it places/removes); the + * renderer reads the selection to draw the ghost, the demolition tint, or (v5) the + * inspect highlight. We only say what is held. * * `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). + * `_sel` field if a host doesn't serve them yet (live.test.ts's own bus, for one). The + * legacy field can't express inspect mode, which is fine — it's a v5 host that highlights. */ import type { Dir, SelectionState, UIBus } from '../contracts'; export interface BuildSelection { - /** The pending *build*, or null. Null whenever remove mode is armed. */ + /** The pending *build*, or null. Null whenever removing or inspecting. */ get(): { def: string; dir: Dir } | null; /** Select a machine; selecting the one already selected clears it (toggle). */ select(def: string): void; rotate(): void; - /** Drop the build selection AND disarm remove mode. */ + /** Drop everything — build, wrecking ball, and inspect. */ clear(): void; isRemoving(): boolean; toggleRemove(): void; - /** True when the player is holding anything at all (build or the wrecking ball). */ + /** v5: publish that the inspector is showing this entity, so the renderer highlights it. */ + inspect(entityId: number): void; + /** v5: the inspector closed — stop highlighting (no-op if we weren't inspecting). */ + clearInspect(): void; + isInspecting(): boolean; + /** True when the player is holding a tool (build or wrecking ball). Inspect is not a tool. */ isArmed(): boolean; onChange(cb: () => void): void; } @@ -32,6 +39,7 @@ type LegacyBus = UIBus & { _sel?: { def: string; dir: Dir } | null }; export function createSelection(bus: UIBus): BuildSelection { let sel: { def: string; dir: Dir } | null = null; let removing = false; + let inspecting: number | null = null; const listeners: Array<() => void> = []; function publish() { @@ -39,40 +47,58 @@ export function createSelection(bus: UIBus): BuildSelection { ? { mode: 'remove' } : sel ? { mode: 'build', def: sel.def, dir: sel.dir } - : null; + : inspecting !== null + ? { mode: 'inspect', entity: inspecting } + : null; if (bus.setSelection) { bus.setSelection(state); } else { - // Legacy host: keep the old shape alive rather than silently doing nothing. + // Legacy host: keep the old build/remove shape alive. Inspect has no legacy form — + // a host without setSelection simply won't get the highlight, which is acceptable. (bus as LegacyBus)._sel = removing ? { def: '__remove', dir: 0 } : sel; } } - function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) { - sel = next; - removing = nextRemoving; + function commit(next: { + sel?: { def: string; dir: Dir } | null; + removing?: boolean; + inspecting?: number | null; + }) { + // Any explicit field wins; unspecified tool fields reset, because the three modes are + // mutually exclusive and a partial update would leave two of them live. + sel = next.sel !== undefined ? next.sel : null; + removing = next.removing ?? false; + inspecting = next.inspecting !== undefined ? next.inspecting : null; publish(); for (const cb of listeners) cb(); } return { - get: () => (removing ? null : sel), + get: () => (removing || inspecting !== null ? null : sel), select(def) { - // Picking a machine puts the wrecking ball down — you can't build and demolish. - if (!removing && sel?.def === def) commit(null, false); - else commit({ def, dir: sel?.dir ?? 0 }, false); // keep the dir across swaps + // Picking a machine puts the wrecking ball down and stops inspecting. + if (!removing && sel?.def === def) commit({}); // toggle off + else commit({ sel: { def, dir: sel?.dir ?? 0 } }); // keep the dir across swaps }, rotate() { - if (!removing && sel) commit({ def: sel.def, dir: ((sel.dir + 1) % 4) as Dir }, false); + if (!removing && sel) commit({ sel: { def: sel.def, dir: ((sel.dir + 1) % 4) as Dir } }); }, clear() { - if (sel || removing) commit(null, false); + if (sel || removing || inspecting !== null) commit({}); }, isRemoving: () => removing, toggleRemove() { - commit(null, !removing); + commit({ removing: !removing }); }, + inspect(entityId) { + if (inspecting === entityId && !sel && !removing) return; // already showing it + commit({ inspecting: entityId }); + }, + clearInspect() { + if (inspecting !== null) commit({}); + }, + isInspecting: () => inspecting !== null, isArmed: () => removing || sel !== null, onChange(cb) { listeners.push(cb);