Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# @suaveplan/controller - Universal input controller foundation — unified 32-field ControllerInput shape, BaseController abstract base with Clock-injected pollRate gating and threshold/deadzone normalisation, typed InputEventEmitter, stable CONTROLLER.* error code registry, and OpenTelemetry instrumentation. Chunk A of the @suaveplan/controller migration; device adapters and merger ship in follow-up chunks.
|
||||
|
||||
Universal input controller foundation. Ships the unified
|
||||
`ControllerInput` shape (37 normalised fields in `[0, 255]`),
|
||||
`BaseController` abstract base with injectable `Clock`/`IdGenerator`,
|
||||
`pollRate` gating and per-input threshold/deadzone normalisation, the
|
||||
typed `InputEventEmitter`, the stable `CONTROLLER.*` error-code
|
||||
registry, and the package's OpenTelemetry instruments.
|
||||
|
||||
Device adapters (`KeyboardController`, `GamepadController`,
|
||||
`TouchController`, `MouseController`) and `ControllerMerger` ship in
|
||||
Chunks B and C.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [base](./src/base/base.md) — Abstract base for every device-specific controller adapter shipped by this package. Owns the 37-field `ControllerInput`…
|
||||
- [base](./src/base/base.md) — Abstract base for every device-specific controller adapter shipped by this package. Owns the 37-field `ControllerInput`…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CONTROLLER.*` error-code registry and the typed `ControllerError` class used by every public surfac…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CONTROLLER.*` error-code registry and the typed `ControllerError` class used by every public surfac…
|
||||
- [events](./src/events/events.md) — Typed event dispatcher used by `BaseController` to fan input, state and combo events out to listeners. Centralising the…
|
||||
- [events](./src/events/events.md) — Typed event dispatcher used by `BaseController` to fan input, state and combo events out to listeners. Centralising the…
|
||||
- [gamepad](./src/gamepad/gamepad.md) — Browser-tier device adapter that polls the Web Gamepad API and normalises button + axis state into the canonical 37-fie…
|
||||
- [gamepad.internal](./src/gamepad/gamepad.internal.md) — Module-private helpers used by `GamepadController` to keep the main class file under the repository's 300-line hard cap…
|
||||
- [gamepad](./src/gamepad/gamepad.md) — Browser-tier device adapter that polls the Web Gamepad API and normalises button + axis state into the canonical 37-fie…
|
||||
- [keyboard](./src/keyboard/keyboard.md) — Browser-tier device adapter that normalises DOM `keydown`/`keyup` events into the canonical 37-field `ControllerInput`…
|
||||
- [keyboard](./src/keyboard/keyboard.md) — Browser-tier device adapter that normalises DOM `keydown`/`keyup` events into the canonical 37-field `ControllerInput`…
|
||||
- [merger](./src/merger/merger.md) — `ControllerMerger` fuses an ordered list of `BaseController` children into a single virtual controller whose `Controlle…
|
||||
- [merger](./src/merger/merger.md) — `ControllerMerger` fuses an ordered list of `BaseController` children into a single virtual controller whose `Controlle…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`: two counters tracking input and state-change thr…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`: two counters tracking input and state-change thr…
|
||||
- [types](./src/types/keys.md) — Provides the canonical iteration order over the 37 `ControllerInput` keys and a `createDefaultControllerInput()` factor…
|
||||
- [keys](./src/types/keys.md) — Provides the canonical iteration order over the 37 `ControllerInput` keys and a `createDefaultControllerInput()` factor…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- [`base/base.md`](src/base/base.md) — `BaseController` lifecycle,
|
||||
state, normalisation, pollRate gating.
|
||||
- [`events/events.md`](src/events/events.md) — `InputEventEmitter`
|
||||
typed dispatcher with disposable listeners.
|
||||
- [`error-codes.md`](src/error-codes.md) — `CONTROLLER_CODES` registry
|
||||
and `ControllerError` class.
|
||||
- [`telemetry.md`](src/telemetry.md) — `controller_inputs_total`,
|
||||
`controller_state_changes_total` counters and the pattern-match
|
||||
duration histogram.
|
||||
- [`types/keys.md`](src/types/keys.md) — `CONTROLLER_INPUT_KEYS` and
|
||||
`createDefaultControllerInput`.
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` — Chunk A foundation. Chunks B (device adapters) and C
|
||||
(merger) extend the barrel with additional named exports.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/controller
|
||||
bunx turbo test:coverage --filter=@suaveplan/controller
|
||||
```
|
||||
|
||||
The coverage gate is 100/100/100/100 (istanbul). The package's
|
||||
`tests/setup/polyfills.ts` registers an in-memory `MeterProvider` before
|
||||
source modules import the OTel meter so the telemetry assertions resolve
|
||||
against a real exporter, with no mocks.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()`.
|
||||
@@ -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()`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user