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);