[ui] publish inspect mode so the renderer can highlight the open unit

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 14:11:59 +10:00
parent 7e24eab171
commit acb1d88038
3 changed files with 146 additions and 24 deletions

View File

@ -27,7 +27,7 @@ export interface Inspector {
/** Heat above this throttles (CONTRACTS: "throttles >0.7, scrams at 1"). */ /** Heat above this throttles (CONTRACTS: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7; 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<string, MachineDef>(data.machines.map((m) => [m.id, m])); const machines = new Map<string, MachineDef>(data.machines.map((m) => [m.id, m]));
const recipes = new Map<string, RecipeDef>(data.recipes.map((r) => [r.id, r])); const recipes = new Map<string, RecipeDef>(data.recipes.map((r) => [r.id, r]));
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i])); const items = new Map<string, ItemDef>(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'); 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, [ const body = el('div', undefined, [
recHead, recSelect, noRecipe, recHead, recSelect, noRecipe,
inHead, inChips.el, outHead, outChips.el, inHead, inChips.el, outHead, outChips.el,
@ -79,14 +85,16 @@ export function createInspector(data: GameData, bus: UIBus): Inspector {
heatHead, heatBar, heatState, 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; let openId: number | null = null;
function close() { function close() {
if (openId === null) return; // already shut — don't re-notify selection
openId = null; openId = null;
optionsFor = null; optionsFor = null;
cls(root, 'is-open', false); cls(root, 'is-open', false);
onClose?.();
} }
closeBtn.addEventListener('click', close); 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); const e = snap.entities.find((x) => x.id === openId);
if (!e) return close(); // unit was removed out from under us if (!e) return close(); // unit was removed out from under us
paint(e); 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)));
}, },
}; };
} }

View File

@ -121,6 +121,84 @@ describe('createSelection (v4 typed protocol)', () => {
expect(read()).toEqual({ mode: 'remove' }); 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', () => { it('notifies listeners on change, and not on a no-op clear', () => {
const { bus } = makeBus(); const { bus } = makeBus();
const sel = createSelection(bus); const sel = createSelection(bus);

View File

@ -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()` * main.ts owns the state and decides what a world click means (it places/removes); the
* (contracts v4 `SelectionState`). The round-3 `__remove` magic string is gone the * renderer reads the selection to draw the ghost, the demolition tint, or (v5) the
* renderer gets a real `mode` now and so is our own `remove` dispatch: **main.ts * inspect highlight. We only say what is held.
* dispatches the removal on click.** We only say what is held; main.ts decides what a
* click on the world means.
* *
* `selection`/`setSelection` are optional in the contract, so we fall back to the legacy * `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'; import type { Dir, SelectionState, UIBus } from '../contracts';
export interface BuildSelection { 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; get(): { def: string; dir: Dir } | null;
/** Select a machine; selecting the one already selected clears it (toggle). */ /** Select a machine; selecting the one already selected clears it (toggle). */
select(def: string): void; select(def: string): void;
rotate(): void; rotate(): void;
/** Drop the build selection AND disarm remove mode. */ /** Drop everything — build, wrecking ball, and inspect. */
clear(): void; clear(): void;
isRemoving(): boolean; isRemoving(): boolean;
toggleRemove(): void; 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; isArmed(): boolean;
onChange(cb: () => void): void; onChange(cb: () => void): void;
} }
@ -32,6 +39,7 @@ type LegacyBus = UIBus & { _sel?: { def: string; dir: Dir } | null };
export function createSelection(bus: UIBus): BuildSelection { export function createSelection(bus: UIBus): BuildSelection {
let sel: { def: string; dir: Dir } | null = null; let sel: { def: string; dir: Dir } | null = null;
let removing = false; let removing = false;
let inspecting: number | null = null;
const listeners: Array<() => void> = []; const listeners: Array<() => void> = [];
function publish() { function publish() {
@ -39,40 +47,58 @@ export function createSelection(bus: UIBus): BuildSelection {
? { mode: 'remove' } ? { mode: 'remove' }
: sel : sel
? { mode: 'build', def: sel.def, dir: sel.dir } ? { mode: 'build', def: sel.def, dir: sel.dir }
: null; : inspecting !== null
? { mode: 'inspect', entity: inspecting }
: null;
if (bus.setSelection) { if (bus.setSelection) {
bus.setSelection(state); bus.setSelection(state);
} else { } 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; (bus as LegacyBus)._sel = removing ? { def: '__remove', dir: 0 } : sel;
} }
} }
function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) { function commit(next: {
sel = next; sel?: { def: string; dir: Dir } | null;
removing = nextRemoving; 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(); publish();
for (const cb of listeners) cb(); for (const cb of listeners) cb();
} }
return { return {
get: () => (removing ? null : sel), get: () => (removing || inspecting !== null ? null : sel),
select(def) { select(def) {
// Picking a machine puts the wrecking ball down — you can't build and demolish. // Picking a machine puts the wrecking ball down and stops inspecting.
if (!removing && sel?.def === def) commit(null, false); if (!removing && sel?.def === def) commit({}); // toggle off
else commit({ def, dir: sel?.dir ?? 0 }, false); // keep the dir across swaps else commit({ sel: { def, dir: sel?.dir ?? 0 } }); // keep the dir across swaps
}, },
rotate() { 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() { clear() {
if (sel || removing) commit(null, false); if (sel || removing || inspecting !== null) commit({});
}, },
isRemoving: () => removing, isRemoving: () => removing,
toggleRemove() { 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, isArmed: () => removing || sel !== null,
onChange(cb) { onChange(cb) {
listeners.push(cb); listeners.push(cb);