# Capability Spec Delta: controller (controller) **Package**: `@suaveplan/controller` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Unified ControllerInput shape The package SHALL export a `ControllerInput` interface with exactly 32 numeric fields covering directional inputs (`up`/`down`/`left`/`right`), four face buttons (`a`/`b`/`x`/`y`), four triggers (`t1`-`t4`), four function buttons (`f1`-`f4`), two analog sticks (`lx`/`ly`/`rx`/`ry`), two stick buttons (`l3`/`r3`), two analog triggers (`lt`/`rt`), three gyro axes (`gx`/`gy`/`gz`), three motion axes (`mx`/`my`/`mz`), three touch fields (`tx`/`ty`/`tp`), and four special fields (`vl`/`vh`/`bt`/`pi`). Every field SHALL hold an integer in `[0, 255]` after normalisation. #### Scenario: shape exposes 32 fields - **WHEN** consumer constructs the literal default state returned by `BaseController.getState()` - **THEN** the returned object has exactly 32 own enumerable string keys, every value is `0`, and TypeScript narrows the result to `ControllerInput` **Verified by:** `src/base/base-controller.test.ts::"getState returns 32-field zero-initialised ControllerInput"` --- ### Requirement: BaseController lifecycle and threshold normalisation The package SHALL expose `BaseController` with an injectable `Clock` (defaults to system clock), a configurable `pollRate` (Hz, default 60), per-input thresholds (`{ threshold?, deadzone? }`), and a `destroy()` method that clears all state and listeners idempotently. Input updates SHALL be clamped to `[0, 255]`, rounded to integer, gated by deadzone (`|v| < deadzone → 0`) and threshold (`v < threshold → 0`), and emit `input` + `state-change` events only when the post-normalisation value differs from the previous value. #### Scenario: pollRate gates update frequency - **WHEN** `update()` is called twice within `1000/pollRate` ms with `needsUpdate === true` - **THEN** `updateState()` runs exactly once **Verified by:** `src/base/base-controller.test.ts::"update respects pollRate gating"` #### Scenario: deadzone zeroes sub-threshold values - **WHEN** `setThreshold("lx", { deadzone: 30 })` is set and `setState({ lx: 20 })` is called - **THEN** `getValue("lx") === 0` and no `input` event fires for `lx` **Verified by:** `src/base/base-controller.test.ts::"deadzone zeroes sub-threshold values"` #### Scenario: destroy is idempotent - **WHEN** `destroy()` is called twice in succession - **THEN** the second call is a no-op (no error, no double-cleanup) **Verified by:** `src/base/base-controller.test.ts::"destroy is idempotent"` --- ### Requirement: KeyboardController DOM adapter The package SHALL expose `KeyboardController(target: EventTarget, options?)` where `options` includes an optional `keyMap: Record` (defaults to a WASD + arrow + space + numeric map). It SHALL register `keydown` and `keyup` listeners on `target`, set the mapped input to `255` on `keydown` and `0` on `keyup`, and unregister both listeners on `destroy()`. #### Scenario: keydown sets mapped input to 255 - **WHEN** `target.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }))` is fired - **THEN** `getValue("up") === 255` **Verified by:** `src/keyboard/keyboard-controller.test.ts::"keydown sets mapped input to 255"` #### Scenario: destroy unregisters DOM listeners - **WHEN** `destroy()` is called then a `keydown` event is dispatched - **THEN** no state change occurs and no event fires **Verified by:** `src/keyboard/keyboard-controller.test.ts::"destroy unregisters DOM listeners"` --- ### Requirement: GamepadController polling adapter The package SHALL expose `GamepadController(options?)` where `options` includes an injectable `getGamepads: () => (Gamepad | null)[]` (defaults to `navigator.getGamepads`) and an `axisDeadzone` (default 0.15). On each `update()` it SHALL read the gamepad at `playerIndex` (default 0), map standard button indices to `ControllerInput` fields, scale axis values from `[-1, 1]` to `[0, 255]` (centred at 128) after applying `axisDeadzone`, and emit changes through the inherited `BaseController` pipeline. #### Scenario: standard button maps to face button - **WHEN** mocked `getGamepads()` returns `{ buttons: [{ value: 1, pressed: true }, ...] }` for button index 0 - **THEN** `getValue("a") === 255` **Verified by:** `src/gamepad/gamepad-controller.test.ts::"standard button 0 maps to face button a"` #### Scenario: axis deadzone snaps to centre - **WHEN** axis value `0.05` (below `axisDeadzone` 0.15) is read for `lx` - **THEN** `getValue("lx") === 128` (centred zero) **Verified by:** `src/gamepad/gamepad-controller.test.ts::"axis deadzone snaps to centre"` --- ### Requirement: ControllerMerger N-way max-reduction The package SHALL expose `ControllerMerger(options?)` with `addController(c)`, `removeController(c)`, and `update()`. On each `update()`, every child controller's state SHALL be polled and the merged `ControllerInput` SHALL hold `Math.max(...children[input])` for each of the 32 fields. `removeController` SHALL be safe when the controller is not present. #### Scenario: merged value is per-field max across children - **WHEN** child A reports `{ up: 100, a: 0 }` and child B reports `{ up: 50, a: 200 }` - **THEN** merged state has `up === 100` AND `a === 200` **Verified by:** `src/controller-merger/controller-merger.test.ts::"merged value is per-field max across children"` #### Scenario: removeController is safe when absent - **WHEN** `removeController(c)` is called for a controller never added - **THEN** the call returns without throwing and no listeners are disturbed **Verified by:** `src/controller-merger/controller-merger.test.ts::"removeController is safe when absent"` --- ### Requirement: InputEventEmitter listener registry The package SHALL expose `InputEventEmitter` with `onInput(input, cb)`, `onStateChange(cb)`, `onCombo(cb)` (all returning a disposer), and `emitInput`/`emitStateChange`/`emitCombo` methods. Disposers SHALL remove the exact listener registered without affecting others. #### Scenario: disposer removes only the registered listener - **WHEN** two listeners are registered for the same input and only one disposer is invoked - **THEN** the remaining listener still fires on `emitInput` **Verified by:** `src/event-emitter/event-emitter.test.ts::"disposer removes only the registered listener"` --- ### Requirement: ControllerError + stable code registry The package SHALL export `CONTROLLER_CODES = defineCodes("CONTROLLER", [...] as const)` and a `ControllerError extends SuaveplanError` class. Every code in the registry SHALL be thrown from at least one production path; consumers SHALL be able to discriminate errors via `error.code` without `instanceof`. #### Scenario: every registered code is reachable - **WHEN** the conformance test enumerates `CONTROLLER_CODES` and walks every production call site that uses `defineCodes` membership - **THEN** every code is referenced by a `throw new ControllerError(code, …)` or carried on a `Report.code` field **Verified by:** `src/errors/errors.test.ts::"every CONTROLLER_CODES entry is thrown by a real call site"` --- ### Requirement: OTel instrumentation The package SHALL register two instruments via `@opentelemetry/api`: `controller_inputs_total` (counter, attributes `device` and `input`) incremented on every emitted `input` event, and `controller_state_changes_total` (counter, attribute `device`) incremented on every `state-change` event. Both SHALL be no-ops when no `MeterProvider` is registered. #### Scenario: counter increments on input event - **WHEN** an in-memory `MeterProvider` is registered and a `KeyboardController` fires three `keydown` events - **THEN** `controller_inputs_total{device="keyboard"}` reports a cumulative value of 3 **Verified by:** `src/observability/observability.test.ts::"controller_inputs_total increments on input event"` --- ### Requirement: Zod-validated public boundaries The package SHALL define Zod schemas for `BaseControllerOptionsSchema`, `KeyboardOptionsSchema`, `GamepadOptionsSchema`, `ControllerMergerOptionsSchema`, and `ControllerInputSchema`, and SHALL parse every constructor `options` argument through the matching schema; failures SHALL throw `ControllerError(CONTROLLER_CODES.INVALID_OPTIONS, …)` with the Zod issue list in `error.context.issues`. #### Scenario: invalid options throw a typed error - **WHEN** `new KeyboardController(target, { pollRate: -1 })` is constructed - **THEN** a `ControllerError` is thrown with `code === CONTROLLER_CODES.INVALID_OPTIONS` and `context.issues` is a non-empty array **Verified by:** `src/keyboard/keyboard-controller.test.ts::"invalid options throw ControllerError.INVALID_OPTIONS"`