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 toControllerInput
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 within1000/pollRatems withneedsUpdate === 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 andsetState({ lx: 20 })is called - THEN
getValue("lx") === 0and noinputevent fires forlx
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 akeydownevent 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(belowaxisDeadzone0.15) is read forlx - 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 === 100ANDa === 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_CODESand walks every production call site that usesdefineCodesmembership - THEN every code is referenced by a
throw new ControllerError(code, …)or carried on aReport.codefield
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
MeterProvideris registered and aKeyboardControllerfires threekeydownevents - 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
ControllerErroris thrown withcode === CONTROLLER_CODES.INVALID_OPTIONSandcontext.issuesis a non-empty array
Verified by: src/keyboard/keyboard-controller.test.ts::"invalid options throw ControllerError.INVALID_OPTIONS"