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

8.7 KiB

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"