# `merger` — `ControllerMerger` _(no submodules)_ ## Purpose `ControllerMerger` fuses an ordered list of `BaseController` children into a single virtual controller whose `ControllerInput` snapshot is the per-field maximum of every attached child. It is the layer that lets a game treat keyboard, gamepad, touch, and remote network controllers as one unified action source without re-binding consumers when a player adds or removes a device mid-session. The merger extends `BaseController` so it transparently inherits the typed `InputEventEmitter`, threshold/deadzone normalisation, OpenTelemetry counters, and lifecycle guards. Children are observed through their public `onAnyInput` and `onCombo` subscriptions only — the merger never peeks at protected state, so any subclass of `BaseController` (current or future) plugs in without an adapter shim. ## Features - Per-field `Math.max` reduction across N children, walked in stable insertion order over the canonical `CONTROLLER_INPUT_KEYS`. - Subscription-based wiring — child state-change events are funnelled into per-child snapshots, then merged on `update()`. - Combo events from any child are re-emitted through the merger's own `eventEmitter` so consumers only listen in one place. - Duplicate-detection by both instance identity and `child.id`, raising `ControllerError(CONTROLLER.DUPLICATE_CHILD)` early. - `removeController()` validates membership and throws `ControllerError(CONTROLLER.UNKNOWN_CHILD)` for stale references. - Lifecycle propagation — `destroy()` disposes every subscription and destroys every child before clearing local state. - pollRate gating inherited from `BaseController`, driven by the same injected `Clock` so tests can use a `FakeClock` without globals. ## Usage: Basic ```ts import { BaseController, ControllerMerger, } from "@suaveplan/controller"; // Two concrete children produced by Chunk B's adapters. declare const keyboard: BaseController; declare const gamepad: BaseController; const merger = new ControllerMerger({ children: [keyboard, gamepad] }); merger.onAnyInput((state) => { // Single subscription regardless of how many devices are connected. render(state); }); // Drive every tick of the game loop. function tick(): void { merger.update(); requestAnimationFrame(tick); } ``` ## Usage: Advanced ```ts import { BaseController, CONTROLLER_CODES, ControllerError, ControllerMerger, } from "@suaveplan/controller"; import type { Clock, IdGenerator } from "@suaveplan/types"; const fakeClock: Clock = (() => { let t = 0; return () => (t += 16); })(); let n = 0; const ids: IdGenerator = () => `m-${++n}`; const merger = new ControllerMerger({ clock: fakeClock, idGenerator: ids, pollRate: 120, strict: true, }); declare const padA: BaseController; declare const padB: BaseController; merger.addController(padA); try { merger.addController(padA); } catch (err) { if ( err instanceof ControllerError && err.code === CONTROLLER_CODES.DUPLICATE_CHILD ) { console.warn("already attached:", err.context); } } merger.addController(padB); merger.setThreshold("lx", { deadzone: 8, threshold: 32 }); merger.update(); merger.removeController(padA); merger.destroy(); // destroys padB as well ``` ## API Reference - `class ControllerMerger extends BaseController` - `constructor(options?: ControllerMergerOptions)` — accepts every `BaseControllerOptions` field plus `children?: readonly BaseController[]` seeded eagerly via `addController()`. - `addController(child: BaseController): void` — subscribes to `onAnyInput` + `onCombo`, captures the current snapshot, marks the merger dirty. Throws `CONTROLLER.DUPLICATE_CHILD` on identity or `id` collision. - `removeController(child: BaseController): void` — disposes subscriptions, drops the snapshot, marks dirty. Throws `CONTROLLER.UNKNOWN_CHILD` when the child is not attached. - `getChildren(): MergerChildren` — read-only snapshot of children in insertion order. - `get childCount(): number` — current attachment count. - `update(): void` — polls every child, then runs the per-field max reduction over `CONTROLLER_INPUT_KEYS` and routes diffs through `updateInput` so threshold/deadzone gating + event emission stay consistent. - `destroy(): void` — disposes all subscriptions, destroys every child, clears local state, then defers to `BaseController.destroy`. - `interface ControllerMergerOptions extends BaseControllerOptions` - `children?: readonly BaseController[]` — initial seed. - `type MergerChildren = readonly BaseController[]` ## Implementation Notes The merger captures child snapshots **eagerly on subscription** so the first `update()` after construction reflects whatever state the child already has, even if no further input events fire between `addController` and `update`. Snapshots are stored keyed by `child.id` rather than by identity reference so the lookup remains O(1) and the duplicate guard shares the same key space. `update()` is overridden in full rather than using the base `updateState()` template. The base `update()` short-circuits when `needsUpdate` is false, which would skip child polling entirely — a visible bug in a tick-driven game loop where children must always be polled regardless of whether the merger itself observed prior changes. The override polls children first, then applies the same pollRate gating the base class would have applied before mutating local state. Per-field reduction uses `Math.max` because every input value lives in `[0, 255]` and the desired semantic is "any source wins" — a pressed button on the keyboard plus a released button on the gamepad should read as pressed, and a partially deflected stick on one pad should not be cancelled by a centred stick on another. This matches the upstream contract and avoids subtle precedence rules between devices. `destroy()` propagates to children because the merger owns the wiring; leaving children alive after the merger goes away leaves dangling subscriptions on the children's emitters and is the failure mode removed-without-destroy would otherwise create. Callers that need independent child lifecycles call `removeController()` before `destroy()`.