Files
2026-05-27 23:29:53 +01:00

203 lines
8.7 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<string, keyof ControllerInput>` (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<ControllerCode>` 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"`