first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
# `base` — `BaseController` abstract base
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Abstract base for every device-specific controller adapter shipped by
this package. Owns the 37-field `ControllerInput` state, per-input
gating (`{ threshold, deadzone }`), the typed `InputEventEmitter`, and
the `pollRate`-gated `update()` cadence. Subclasses landing in Chunk B
(`KeyboardController`, `GamepadController`, `TouchController`,
`MouseController`) and Chunk C (`ControllerMerger`) extend this class
and override `updateState()` to translate device-specific signals into
the canonical input shape.
## Features
- Injectable `Clock` (`@suaveplan/types`) — every wall-clock read goes
through `this.clock()` so tests substitute a `FakeClock` without
touching globals.
- Injectable `IdGenerator` — every instance carries a stable `id` used
by OTel attributes and audit logs.
- `pollRate` gating — repeated `update()` calls within
`1000 / pollRate` ms are coalesced; the first one inside the window
runs `updateState()`, the rest are no-ops.
- Per-input threshold + deadzone normalisation, clamp to `[0, 255]`,
integer rounding.
- Event-suppression: `input` and `state-change` events fire only when
the post-normalisation value differs from the previous value AND the
press-state changed accordingly.
- Strict-mode opt-in — `strict: true` makes post-`destroy()` `setState`
throw `ControllerError(DESTROYED_INSTANCE)` instead of silently
no-op'ing.
- Zod-validated boundary on constructor options; failures surface as
`ControllerError(INVALID_OPTIONS)` carrying the Zod issue list.
- Idempotent `destroy()` — safe to call multiple times.
- OpenTelemetry instrumentation — every `input` and `state-change` event
records into the package's counters, attributed to the subclass's
`device` tag.
## Usage: Basic
```ts
import { BaseController } from "@suaveplan/controller";
class FakeController extends BaseController {
protected override updateState(): void {
// pump device-specific signals into setState() / pendingStateUpdates
}
}
const c = new FakeController({ device: "fake", pollRate: 30 });
c.onInput("up", (input, value) => console.log(input, value));
c.setState({ up: 200 });
console.log(c.getValue("up")); // 200
c.destroy();
```
## Usage: Advanced
```ts
import type { Clock } from "@suaveplan/types";
import { BaseController, ControllerError, CONTROLLER_CODES } from "@suaveplan/controller";
let t = 0;
const clock: Clock = () => t;
class TestController extends BaseController {
public constructor() {
super({ clock, pollRate: 10, device: "test", strict: true });
}
protected override updateState(): void {}
}
const c = new TestController();
c.setThreshold("lx", { deadzone: 40 });
c.setState({ lx: 20 });
console.log(c.getValue("lx")); // 0 — deadzone snapped to zero
c.destroy();
try {
c.setState({ up: 1 });
} catch (err) {
if (err instanceof ControllerError && err.code === CONTROLLER_CODES.DESTROYED_INSTANCE) {
console.warn("controller is shut down");
}
}
```
## API Reference
- `new BaseController({ clock?, idGenerator?, pollRate?, device?, strict? })`
(abstract — must be extended)
- `onInput(input, cb): Disposer`
- `onAnyInput(cb): Disposer`
- `onCombo(cb): Disposer`
- `getState(): ControllerInput`
- `getValue(input): number`
- `setState(partial): void`
- `setThreshold(input, { threshold?, deadzone? }): void`
- `getDirectionalVector(): { x, y }`
- `update(): void` — pollRate-gated; subclass `updateState()` runs at
most once per `1000 / pollRate` ms window
- `destroy(): void` — idempotent; clears state, listeners, pending
updates, emitter
## Implementation Notes
The class is `abstract` so consumers can't instantiate it directly;
TypeScript enforces this and the destructor logic is meaningful only in
a subclass context. Threshold normalisation runs in the order
`clamp → round → deadzone → threshold` to match the upstream behaviour,
where the deadzone is checked against the clamped magnitude before the
threshold floor is applied. The `pendingStateUpdates` map is captured
before the gating check so subclass overrides of `updateState()` that
batch multiple `setState` calls (e.g. the merger) see a consistent map
across the tick. Strict mode is wired through to the inner
`InputEventEmitter` so the destroyed-state contract is enforced
end-to-end without duplicate guards.

View File

@@ -0,0 +1,126 @@
# `error-codes` — `CONTROLLER.*` registry + `ControllerError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `CONTROLLER.*` error-code registry and the typed
`ControllerError` class used by every public surface in
`@suaveplan/controller`. Centralising the codes here lets consumers
switch on `error.code` against a finite literal union without needing
`instanceof`, and survives boundary serialisation (e.g. when an error is
posted across a worker channel and reconstructed by the receiver).
## Features
- `CONTROLLER_CODES` — frozen record from reason → fully-qualified code
string, built via `@suaveplan/error`'s `defineCodes` helper.
- `ControllerError extends SuaveplanError<ControllerCode>` — concrete
error class with the `code` field narrowed to the literal union.
- Stable namespace prefix (`CONTROLLER.`) shared across this package and
every future Chunk B/C device adapter and merger code.
- Forwards `cause` and `context` to the `SuaveplanError` base so the
error wire format (validated by `@suaveplan/error`'s Zod schema) is
preserved on traversal.
## Usage: Basic
```ts
import { CONTROLLER_CODES, ControllerError } from "@suaveplan/controller";
throw new ControllerError(
CONTROLLER_CODES.INVALID_INPUT_KEY,
"Unknown input key passed to setThreshold()",
{ context: { input: "notAKey" } }
);
```
## Usage: Advanced
```ts
import { CONTROLLER_CODES, ControllerError } from "@suaveplan/controller";
function handle(err: unknown): void {
if (!(err instanceof ControllerError)) {
throw err;
}
switch (err.code) {
case CONTROLLER_CODES.INVALID_OPTIONS:
console.warn("config rejected", err.context?.issues);
break;
case CONTROLLER_CODES.INVALID_INPUT_KEY:
console.warn("unknown input", err.context?.input);
break;
case CONTROLLER_CODES.DESTROYED_INSTANCE:
console.warn("operation on destroyed controller");
break;
case CONTROLLER_CODES.KEYBOARD_TARGET_MISSING:
console.warn("keyboard target unresolved", err.context);
break;
case CONTROLLER_CODES.INVALID_KEY_MAP:
console.warn("invalid key map entry", err.context);
break;
case CONTROLLER_CODES.GAMEPAD_API_UNAVAILABLE:
console.warn("gamepad API unavailable");
break;
case CONTROLLER_CODES.INVALID_GAMEPAD_MAP:
console.warn("invalid gamepad map entry", err.context);
break;
case CONTROLLER_CODES.DUPLICATE_CHILD:
console.warn("merger child already attached", err.context);
break;
case CONTROLLER_CODES.UNKNOWN_CHILD:
console.warn("merger child not attached", err.context);
break;
}
}
```
The switch is exhaustive against `ControllerCode`; adding a new code
without handling it surfaces as a `noFallthroughCasesInSwitch`-friendly
type error in strict-mode consumers.
## API Reference
- `CONTROLLER_CODES`
- `INVALID_OPTIONS` — Zod boundary rejected the constructor options
bundle; `context.issues` carries the Zod issue array.
- `INVALID_INPUT_KEY` — `setThreshold` (or future device adapters)
received an input key not present in `CONTROLLER_INPUT_KEYS`.
- `DESTROYED_INSTANCE` — operation attempted after `destroy()` on an
instance that does not silently no-op (strict-mode opt-in).
- `KEYBOARD_TARGET_MISSING` — `KeyboardController.attach()` was
called but neither an `EventTargetLike` was injected nor a global
`window` was discoverable; `context.target` reports the resolved
value.
- `INVALID_KEY_MAP` — `KeyboardController` received a `keyMap` whose
value is not one of `CONTROLLER_INPUT_KEYS`; `context.entry`
carries the `[key, input]` tuple that failed validation.
- `GAMEPAD_API_UNAVAILABLE` — `GamepadController.update()` was
called but neither a `GamepadSource` was injected nor a global
`navigator.getGamepads` was discoverable.
- `INVALID_GAMEPAD_MAP` — `GamepadController` received a
`buttonMap`/`axisMap` whose value targets an input key not present
in `CONTROLLER_INPUT_KEYS`; `context.entry` carries the failing
tuple.
- `DUPLICATE_CHILD` — `ControllerMerger.addController()` received a
child already attached, or a second child whose `id` collides with
an existing one; `context` carries the child id.
- `UNKNOWN_CHILD` — `ControllerMerger.removeController()` was called
with a child that is not currently attached.
- `ControllerCode` — literal union of every code string.
- `class ControllerError extends SuaveplanError<ControllerCode>` —
constructor: `(code, message, { cause?, context? })`.
## Implementation Notes
Codes are registered with `defineCodes("CONTROLLER", [...] as const)` so
the returned record is `Object.freeze`d at module load. Chunks B and C
will need to extend the union; the pattern is to declare a second
`defineCodes(...)` block in their own modules and merge at the package
barrel via a union type. The class deliberately does not capture the
`Clock` directly — `SuaveplanError`'s base captures `Date.now()` via the
optional `clock` option, which the controller package opts out of so the
error timestamps remain unambiguously wall-clock for log correlation.

View File

@@ -0,0 +1,90 @@
# `events` — `InputEventEmitter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Typed event dispatcher used by `BaseController` to fan input, state and
combo events out to listeners. Centralising the dispatcher in its own
module keeps the lifecycle FSM in `BaseController` focused on
normalisation logic, and lets downstream consumers (e.g. the merger in
Chunk C) reuse the same disposable-listener contract without depending
on the base class.
## Features
- Three typed channels: `onInput(key, cb)`, `onStateChange(cb)`,
`onCombo(cb)`. Every registration returns a `Disposer` that removes
exactly the registered listener without affecting siblings.
- Per-key bucket storage with `Set` semantics: O(1) add, O(1) remove,
iteration in insertion order.
- Last-value tracking — each `emitInput` passes the previous value to
the listener so consumers can compute deltas without storing state.
- Strict-mode opt-in: `new InputEventEmitter({ strict: true })` throws
`ControllerError(DESTROYED_INSTANCE)` when an `on*` method is called
after `destroy()`. Default mode silently returns a no-op disposer.
- Zod-validated options boundary — invalid construction throws
`ControllerError(INVALID_OPTIONS)` with the Zod issue array in
`context.issues`.
- Idempotent `destroy()` — calling twice is safe.
## Usage: Basic
```ts
import { InputEventEmitter } from "@suaveplan/controller";
const emitter = new InputEventEmitter();
const off = emitter.onInput("up", (input, value, prev) => {
console.log(input, value, prev);
});
emitter.emitInput("up", 255);
off();
emitter.destroy();
```
## Usage: Advanced
```ts
import { InputEventEmitter, ControllerError, CONTROLLER_CODES } from "@suaveplan/controller";
const emitter = new InputEventEmitter({ strict: true });
emitter.destroy();
try {
emitter.onInput("a", () => undefined);
} catch (err) {
if (err instanceof ControllerError && err.code === CONTROLLER_CODES.DESTROYED_INSTANCE) {
console.warn("emitter no longer accepting listeners");
}
}
```
The strict mode is what `BaseController` opts into to enforce its own
destroyed-state guarantees; default mode preserves the upstream emitter
shape used by older fan-out callers that expect post-destroy
registrations to be silent.
## API Reference
- `new InputEventEmitter({ strict? }: InputEventEmitterOptions)`
- `onInput(input, cb): Disposer`
- `onStateChange(cb): Disposer`
- `onCombo(cb): Disposer`
- `emitInput(input, value): void`
- `emitStateChange(state, oldState?): void`
- `emitCombo(combo): void`
- `clear(): void`
- `destroy(): void`
- `destroyed: boolean` (getter)
## Implementation Notes
The per-key bucket is allocated lazily so an emitter with no listeners
on a given key pays no map cost. Disposers capture the `callback` and
`input` by closure rather than indexing into the listener registry, so
multiple `onInput("up", cb)` calls with the same callback are coalesced
by the `Set` (no double-fire), and disposing one call removes the entry
cleanly. `emitInput` records the new value into `lastInputValues` even
when there is no registered listener, so a late subscriber's first event
sees the correct `prevValue`.

View File

@@ -0,0 +1,114 @@
# `gamepad.internal` — gamepad-adapter validators + global resolver
## Purpose
Module-private helpers used by `GamepadController` to keep the main
class file under the repository's 300-line hard cap. Centralising
these as standalone functions also makes them easy to fuzz from
focused unit tests in `gamepad.test.ts` without instantiating a
full controller.
The exports are not part of the package's public API — they are
re-bound by name inside `GamepadController` and the package barrel
does not surface them. Treat the file as the implementation backing
store for the gamepad adapter's option-validation and global
discovery surface.
## Features
- `validateButtonMap(map)` — verifies every entry in a
`GamepadButtonMap` targets a key listed in
`CONTROLLER_INPUT_KEYS`. Returns a frozen shallow copy on
success; throws `ControllerError(INVALID_GAMEPAD_MAP)` on the
first invalid entry with the offending tuple in `context.entry`.
- `validateAxisMap(map)` — verifies each
`positive` / `negative` / `analog` binding inside every axis
entry. Each entry is independently frozen, then the outer record
is frozen. Errors carry the `[rawIndex, role, value]` tuple in
`context.entry`.
- `resolveGlobalGamepadSource()` — discovers
`globalThis.navigator.getGamepads` lazily, returning a bound
`GamepadSource` when the global is present and a function. Returns
`undefined` otherwise — the adapter only throws
`GAMEPAD_API_UNAVAILABLE` when `update()` is later called without
having injected a source.
## Usage: Basic
```ts
import {
validateButtonMap,
validateAxisMap,
} from "./gamepad.internal.js";
const buttonMap = validateButtonMap({ 0: "a", 1: "b" });
const axisMap = validateAxisMap({
0: { positive: "right", negative: "left", analog: "lx" },
});
```
## Usage: Advanced
The validators throw at the first invalid entry, so callers that
want to collect every problem can wrap each entry in a try/catch:
```ts
import {
validateButtonMap,
validateAxisMap,
} from "./gamepad.internal.js";
const candidate = { 0: "a", 99: "bogus" } as Record<number, string>;
const errors: unknown[] = [];
try {
validateButtonMap(candidate as Parameters<typeof validateButtonMap>[0]);
} catch (err) {
errors.push(err);
}
if (errors.length === 0) {
// candidate is valid
}
// Hosts without `navigator` (Node, Bun outside a DOM polyfill)
// receive `undefined` and must inject a source explicitly:
import { resolveGlobalGamepadSource } from "./gamepad.internal.js";
const source = resolveGlobalGamepadSource();
if (source === undefined) {
// fall back to a virtual source
}
```
## API Reference
- `validateButtonMap(map: GamepadButtonMap): GamepadButtonMap` —
validates + freezes a button map. Throws
`ControllerError(INVALID_GAMEPAD_MAP)` on invalid entries.
- `validateAxisMap(map: GamepadAxisMap): GamepadAxisMap` —
validates + freezes an axis map, including each per-binding
inner object. Throws `ControllerError(INVALID_GAMEPAD_MAP)` on
invalid entries.
- `resolveGlobalGamepadSource(): GamepadSource | undefined` — best
-effort discovery of the host's `navigator.getGamepads`. Always
returns synchronously and never throws.
## Implementation Notes
- Validators take the input map as a value (not a class field) so
the unit tests can exercise them without standing up a full
`GamepadController`. The maps are checked once at construction
time; consumers cannot mutate the runtime-bound versions.
- `Object.freeze` is applied at two levels for axis maps (each
binding and the outer record) so accidental rebinding via
`axisMap[2] = …` surfaces as a `TypeError` in strict-mode
consumers rather than silently mutating the adapter.
- `resolveGlobalGamepadSource` binds the discovered function to
`navigator` so callers that later strip the receiver (e.g.
destructuring) keep the correct `this`. Returning `() =>
getGamepads()` rather than the raw bound function keeps the
signature predictable when the underlying global is replaced
after construction (rare, but observed in some test harnesses).
- The function `globalThis as unknown as { navigator?: … }` cast is
the only place in the gamepad module that touches a host global;
centralising it here keeps the cast localised and means the
Biome `noExplicitAny` rule never has to allow exceptions in the
main class file.

View File

@@ -0,0 +1,142 @@
# `gamepad` — `GamepadController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Browser-tier device adapter that polls the Web Gamepad API and
normalises button + axis state into the canonical 37-field
`ControllerInput` shape exposed by `BaseController`. The adapter
covers the W3C "standard mapping" layout (face buttons, bumpers,
triggers, sticks, D-pad, system buttons) out of the box and exposes
the button + axis maps so non-standard pads or custom layouts can be
described per-instance.
The class never reads from `navigator` at module-load time. The
`GamepadSource` is either supplied via `options.source` or resolved
lazily from `globalThis.navigator.getGamepads()` when `update()`
runs. Tests inject a deterministic source returning fixture pads and
drive the adapter directly — no jsdom or `navigator` polyfill
required.
## Features
- Standard W3C button mapping for all 17 indices, including analog
triggers (`lt`/`rt`) and D-pad-to-digital mapping.
- Standard axis mapping covering both digital halves
(`right`/`left`/`up`/`down`) and centred analog representations
(`lx`/`ly`/`rx`/`ry`).
- Per-axis deadzone (defaults to `0.1`) applied before the magnitude
is rescaled to the `[0, 255]` field range.
- Per-instance `padIndex` so two adapters on the same source can
drive split-screen co-op without further routing.
- Connection diff — when the source returns `null`, `undefined`, or
a pad with `connected: false`, the adapter zeroes the mapped
fields exactly once and stops emitting until the pad reappears.
- Validates every map entry against `CONTROLLER_INPUT_KEYS`; invalid
entries throw `ControllerError(INVALID_GAMEPAD_MAP)` with the
offending tuple in `context.entry`.
- Inherits the full `BaseController` surface — `onInput`,
`onAnyInput`, `setThreshold`, `getDirectionalVector`,
telemetry instrumentation.
## Usage: Basic
```ts
import { GamepadController } from "@suaveplan/controller/gamepad";
const gp = new GamepadController();
gp.onInput("a", (_input, value) => {
if (value === 255) console.log("A pressed");
});
// pump every frame
function tick() {
gp.update();
requestAnimationFrame(tick);
}
tick();
```
## Usage: Advanced
Drive the adapter from a synthetic source with deterministic timing
— no browser globals required:
```ts
import { GamepadController } from "@suaveplan/controller/gamepad";
import type { GamepadLike } from "@suaveplan/controller/gamepad";
let pad: GamepadLike | null = null;
const gp = new GamepadController({
source: () => [pad],
deadzone: 0.2,
padIndex: 0,
axisMap: {
0: { positive: "right", negative: "left", analog: "lx" },
1: { positive: "down", negative: "up", analog: "ly" },
},
buttonMap: { 0: "a", 1: "b" },
clock: () => performance.now(),
pollRate: 120,
});
pad = {
buttons: [
{ pressed: true, value: 1 },
{ pressed: false, value: 0 },
],
axes: [0.75, -0.5],
};
gp.update();
console.log(gp.getValue("a")); // 255
console.log(gp.getValue("right")); // 191
```
## API Reference
- `class GamepadController extends BaseController`
- `constructor(options?: GamepadControllerOptions)`
- `update(): void` — polls the source, diffs against last frame,
pushes updates through `setState()`. Throws
`ControllerError(GAMEPAD_API_UNAVAILABLE)` when no source is
available. No-op after `destroy()`.
- `getDeadzone(): number` — resolved deadzone.
- `getPadIndex(): number` — bound pad index.
- `getButtonMap(): GamepadButtonMap` — immutable button map.
- `getAxisMap(): GamepadAxisMap` — immutable axis map.
- `DEFAULT_GAMEPAD_BUTTON_MAP: GamepadButtonMap` — W3C standard
mapping.
- `DEFAULT_GAMEPAD_AXIS_MAP: GamepadAxisMap` — W3C standard axis
layout (left + right sticks).
- `interface GamepadControllerOptions extends BaseControllerOptions`
— adds `source`, `buttonMap`, `axisMap`, `deadzone`, `padIndex`.
- `interface GamepadLike` — structural subset of `Gamepad`
(`buttons[i].pressed`, `buttons[i].value`, `axes[i]`,
`connected`).
- `type GamepadSource = () => readonly (GamepadLike | null | undefined)[]`.
- `interface GamepadAxisBinding` — `positive`, `negative`, `analog`
fields, all optional.
## Implementation Notes
- Button values are taken from `buttons[i].value` when present so
analog triggers report the full `[0, 255]` range. Pads that omit
`value` (older drivers, software emulators) fall back to a binary
`pressed ? 255 : 0`.
- Axis values are clamped to `[-1, 1]` before the deadzone test so
drivers that report slightly over-range readings (common on dead
or worn sticks) cannot push the field above `255`.
- The disconnect zero-out is one-shot — guarded by a `wasConnected`
flag — so a permanently absent pad does not flood downstream
consumers with redundant `value=0` events every poll.
- The Zod options schema is `.strict()` so unknown options are
rejected at the boundary as `ControllerError(INVALID_OPTIONS)`;
field-level rejections (e.g. `deadzone: -1`) flow through the
same code with the Zod issues in `context.issues`.
- `super.update()` re-applies the `pollRate` gate, so callers may
invoke `update()` more often than the configured rate without
paying repeated traversal cost — the source function is only
invoked once per gated tick.

View File

@@ -0,0 +1,134 @@
# `keyboard` — `KeyboardController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Browser-tier device adapter that normalises DOM `keydown`/`keyup`
events into the canonical 37-field `ControllerInput` shape exposed by
`BaseController`. The adapter exists so games, UIs, and headless test
harnesses can consume the same controller surface whether the user is
pressing physical keys, dispatching synthetic events from a test
double, or driving the adapter from a non-DOM emitter such as a
custom virtual keyboard.
The class never touches a global at module load time. The target is
either supplied via `options.target` or resolved lazily from
`globalThis.window` when `attach()` runs. Tests inject an
`EventTargetLike` double and drive the adapter directly — no jsdom or
browser environment is required.
## Features
- Configurable `keyMap` covering arrow keys, WASD, ZXCV face buttons,
QERT triggers, and `Digit1`–`Digit4` function keys out of the box.
- Validates every key-map entry against `CONTROLLER_INPUT_KEYS` at
construction; invalid entries throw
`ControllerError(INVALID_KEY_MAP)` with the offending tuple in
`context.entry`.
- `attach()` / `detach()` are idempotent and survive `destroy()`
cleanly (no listeners leak, `attach()` after destroy is a no-op).
- Repeats and double-presses are suppressed (`event.repeat === true`
and already-pressed keys are ignored).
- Optional `preventDefault: true` to suppress default browser
behaviour (e.g. spacebar scrolling) without leaking that decision
into the input pipeline.
- Inherits the full `BaseController` surface — `onInput`,
`onAnyInput`, `onCombo`, `setThreshold`, `getDirectionalVector`,
`update()`, telemetry instrumentation.
## Usage: Basic
```ts
import { KeyboardController } from "@suaveplan/controller/keyboard";
const kb = new KeyboardController();
kb.onInput("up", (_input, value) => {
console.log(value === 255 ? "pressed up" : "released up");
});
// later
kb.destroy();
```
## Usage: Advanced
Drive the adapter from a synthetic emitter — no DOM required:
```ts
import { KeyboardController } from "@suaveplan/controller/keyboard";
import type { EventTargetLike } from "@suaveplan/controller/keyboard";
const listeners = new Map<string, Set<(e: unknown) => void>>();
const target: EventTargetLike = {
addEventListener(type, listener) {
const bucket = listeners.get(type) ?? new Set();
bucket.add(listener as never);
listeners.set(type, bucket);
},
removeEventListener(type, listener) {
listeners.get(type)?.delete(listener as never);
},
};
const kb = new KeyboardController({
target,
keyMap: { Space: "a", KeyJ: "b" },
preventDefault: true,
pollRate: 120,
});
const dispatch = (type: string, event: unknown) => {
for (const cb of listeners.get(type) ?? []) cb(event as never);
};
dispatch("keydown", { code: "Space" });
console.log(kb.getValue("a")); // 255
dispatch("keyup", { code: "Space" });
kb.destroy();
```
## API Reference
- `class KeyboardController extends BaseController`
- `constructor(options?: KeyboardControllerOptions)`
- `attach(): void` — bind `keydown`/`keyup` to the resolved
target. Throws `ControllerError(KEYBOARD_TARGET_MISSING)` when
no target is available.
- `detach(): void` — idempotent removal of listeners.
- `getPressedKeys(): readonly string[]` — snapshot of physical
keys currently held down (by `code` or `key` identifier).
- `getKeyMap(): KeyboardKeyMap` — immutable key → input map.
- `destroy(): void` — detaches listeners and clears state.
- `DEFAULT_KEYBOARD_KEY_MAP: KeyboardKeyMap` — frozen default map
used when `options.keyMap` is omitted.
- `interface KeyboardControllerOptions extends BaseControllerOptions`
— adds `target`, `keyMap`, `autoAttach`, `preventDefault`.
- `interface EventTargetLike` — structural subset of `EventTarget`.
- `interface KeyboardEventLike` — structural subset of
`KeyboardEvent` (`code`, `key`, `repeat`, `preventDefault`).
- `type KeyboardKeyMap = Readonly<Record<string, keyof ControllerInput>>`.
## Implementation Notes
- The adapter writes through `BaseController.setState({ [input]:
value })` rather than reaching into `pendingStateUpdates`. This
keeps the OTel counters (`controller_inputs_total`,
`controller_state_changes_total`) and the `InputEventEmitter`
firing on the same code path as direct programmatic updates.
- Key identification consults `event.code` first, then `event.key`.
Both must be strings; the adapter rejects events whose identifier
is `undefined`. This matches the upstream behaviour and aligns
with W3C UI Events.
- Repeat suppression is double-guarded: the adapter ignores
`event.repeat === true` and also short-circuits when the key is
already tracked as pressed.
- The Zod options schema covers every public option; rejections are
surfaced as `ControllerError(INVALID_OPTIONS)` with the Zod issues
in `context.issues`. The schema is `.strict()` so unknown keys are
rejected at the boundary — typos surface as construction errors
rather than silent ignores.
- `setState` accepts a partial `ControllerInput`, so the adapter
computes the field per event rather than buffering. This keeps the
per-event latency below the `pollRate` gate that `BaseController`
applies to `update()`.

View File

@@ -0,0 +1,169 @@
# `merger` — `ControllerMerger`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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()`.

View File

@@ -0,0 +1,88 @@
# `telemetry` — OpenTelemetry instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`:
two counters tracking input and state-change throughput per device, and
one histogram for the pattern engine's match-attempt duration. Both
counters are exercised by `BaseController` via the `recordInput` and
`recordStateChange` helpers; the histogram is reserved for the pattern
matcher landing in Chunk B/C.
## Features
- Lazy-bound meter — instruments resolve against the globally registered
`MeterProvider` on first import and fall back to a no-op meter when
none is registered. Production callers pay zero cost for OTel.
- `controller_inputs_total` counter with `device` + `input` attributes
for fine-grained per-input throughput.
- `controller_state_changes_total` counter with the `device` attribute.
- `controller_pattern_match_duration_ms` histogram (unit `ms`) for the
pattern engine.
- Typed attribute bundles (`InputCounterAttributes`,
`StateCounterAttributes`) prevent string-typo bugs at the call site.
## Usage: Basic
```ts
import { recordInput, recordStateChange } from "@suaveplan/controller";
recordInput({ device: "keyboard", input: "up" });
recordStateChange({ device: "keyboard" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { recordInput } from "@suaveplan/controller";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
recordInput({ device: "gamepad", input: "a" });
await provider.forceFlush();
const points = exporter.getMetrics();
```
The same pattern is exercised in the package's polyfill setup; see
`tests/setup/polyfills.ts` for the canonical wire-up used by the
in-memory MeterProvider assertions.
## API Reference
- `inputsCounter: Counter` — OTel counter `controller_inputs_total`.
- `stateChangesCounter: Counter` —
`controller_state_changes_total`.
- `patternMatchDurationHistogram: Histogram` —
`controller_pattern_match_duration_ms`.
- `recordInput({ device, input }): void` — single-call helper.
- `recordStateChange({ device }): void` — single-call helper.
- `recordPatternMatchDuration(durationMs): void` — histogram helper.
## Implementation Notes
The instruments are module-scoped so the meter is resolved exactly once
per process. Tests inject their `MeterProvider` via
`metrics.setGlobalMeterProvider` BEFORE the source module is imported —
the package's `tests/setup/polyfills.ts` runs as a setup file and
guarantees the binding order. Switching to a deferred-resolve pattern
would require every adapter to plumb a meter object through its
constructor; the current shape keeps the device-adapter APIs free of
observability boilerplate while still allowing override via OTel's
`setGlobalMeterProvider` contract.

View File

@@ -0,0 +1,93 @@
# `controller.types` — shared types
## Purpose
Defines the canonical TypeScript types shared across every module in
`@suaveplan/controller`. Centralising the public type vocabulary here
keeps the shape of `ControllerInput` consistent across `BaseController`,
the event emitter, the device adapters (Chunks B) and the merger
(Chunk C), and lets downstream consumers (`@suaveplan/ecs`,
`@suaveplan/game-server`) import the shape from a single subpath without
pulling the runtime in.
## Features
- `ControllerInput` — the canonical 37-field input shape every adapter
normalises into. Fields are integers in `[0, 255]` post-normalisation.
- `ControllerState` — backward-compatible alias for `ControllerInput`.
- `Vector2D` — return type of `BaseController.getDirectionalVector()`.
- `InputThresholds` — `{ threshold?, deadzone? }` gating bundle accepted
by `setThreshold(input, ...)`.
- `InputChangeCallback`, `StateChangeCallback`, `ComboCallback` — the
three listener signatures used by `InputEventEmitter`.
- `ControllerSnapshot` — `{ input, value, timestamp }` point-in-time
record used by the pattern engine that lands in a follow-up chunk.
- `InputCounterAttributes`, `StateCounterAttributes` — typed attribute
bundles for the OTel counters in `telemetry.ts`.
## Usage: Basic
```ts
import type { ControllerInput, Vector2D } from "@suaveplan/controller";
function magnitude(v: Vector2D): number {
return Math.hypot(v.x, v.y);
}
function isJumping(s: ControllerInput): boolean {
return s.a >= 200;
}
```
## Usage: Advanced
```ts
import type {
ControllerInput,
InputChangeCallback,
InputThresholds,
} from "@suaveplan/controller";
interface AbilityBinding<K extends keyof ControllerInput> {
input: K;
thresholds: InputThresholds;
onTrigger: InputChangeCallback;
}
function bind<K extends keyof ControllerInput>(b: AbilityBinding<K>): AbilityBinding<K> {
return b;
}
const jump = bind({
input: "a",
thresholds: { deadzone: 32, threshold: 200 },
onTrigger: (_input, value, prev) => {
if (value >= 200 && prev < 200) console.log("jump!");
},
});
```
## API Reference
- `interface ControllerInput` — 37-field numeric shape.
- `type ControllerState = ControllerInput` — alias.
- `interface Vector2D { x: number; y: number }`.
- `interface InputThresholds { threshold?: number; deadzone?: number }`.
- `type InputChangeCallback = (input, value, prev) => void`.
- `type StateChangeCallback = (state, oldState?) => void`.
- `type ComboCallback = (combo: string[]) => void`.
- `interface ControllerSnapshot { input, value, timestamp }`.
- `interface InputCounterAttributes { device, input }`.
- `interface StateCounterAttributes { device }`.
## Implementation Notes
Every type is declared with explicit field order so adding a new key
shows up as an intentional diff in code review. The 37 fields are split
into named comment-bands (directional, face, triggers, function, analog
sticks, stick buttons, analog triggers, motion, touch, special) so the
file scans like the spec's grouped description. The runtime iteration
order over these keys lives in `keys.ts` — keep the two files in lockstep
when adding or removing a key. All callback shapes accept the typed
`keyof ControllerInput` discriminator (rather than `string`) so misspelled
keys are caught at the call site.

View File

@@ -0,0 +1,80 @@
# Controller input keys
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the canonical iteration order over the 37 `ControllerInput` keys
and a `createDefaultControllerInput()` factory that returns a fresh
zero-initialised state object. Centralising the key list here prevents the
37-field shape from drifting across `BaseController`, the device adapters,
and the merger — every consumer that walks the shape iterates this same
constant.
## Features
- `CONTROLLER_INPUT_KEYS` — `readonly` tuple of the 37 keys in stable
documented order (directional → face → triggers → function → analog
sticks → stick buttons → analog triggers → motion → touch → special).
- `createDefaultControllerInput()` — factory returning a new object with
every key initialised to `0`.
- Deterministic ordering, suitable for the merger's per-field max
reduction and for any serialiser that needs reproducible output.
- Zero allocation cost beyond the returned object — the key list is
shared module state.
## Usage: Basic
```ts
import {
CONTROLLER_INPUT_KEYS,
createDefaultControllerInput,
} from "@suaveplan/controller";
const state = createDefaultControllerInput();
console.log(state.up); // 0
console.log(CONTROLLER_INPUT_KEYS.length); // 37
```
The shape has exactly 37 enumerated keys (4 directional + 4 face + 4
trigger + 4 function + 4 analog axes + 2 stick buttons + 2 analog
triggers + 6 motion + 3 touch + 4 special). The exported tuple
preserves the exact iteration order the merger relies on.
## Usage: Advanced
```ts
import {
CONTROLLER_INPUT_KEYS,
createDefaultControllerInput,
type ControllerInput,
} from "@suaveplan/controller";
function mergeMax(a: ControllerInput, b: ControllerInput): ControllerInput {
const out = createDefaultControllerInput();
for (const key of CONTROLLER_INPUT_KEYS) {
out[key] = Math.max(a[key], b[key]);
}
return out;
}
```
The merger that ships in Chunk C uses exactly this loop shape.
## API Reference
- `CONTROLLER_INPUT_KEYS: readonly (keyof ControllerInput)[]` — frozen
tuple of the 32 controller-input keys.
- `createDefaultControllerInput(): ControllerInput` — returns a brand-new
object with every field set to `0`.
## Implementation Notes
The key list is declared `as const` so TypeScript infers the literal
tuple type and downstream code can narrow `keyof ControllerInput` against
it without a runtime check. The factory iterates the same tuple to
guarantee that adding or removing a key flows through one source of
truth. Both exports are tree-shakeable; consumers that only need one pay
no cost for the other.