Initial Commit
This commit is contained in:
66
packages/gaming/games/controller/README.md
Normal file
66
packages/gaming/games/controller/README.md
Normal file
@@ -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.
|
||||
|
||||
61
packages/gaming/games/controller/package.json
Normal file
61
packages/gaming/games/controller/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@suaveplan/controller",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
121
packages/gaming/games/controller/src/base/base.md
Normal file
121
packages/gaming/games/controller/src/base/base.md
Normal 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.
|
||||
454
packages/gaming/games/controller/src/base/base.test.ts
Normal file
454
packages/gaming/games/controller/src/base/base.test.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
CONTROLLER_INPUT_KEYS,
|
||||
createDefaultControllerInput,
|
||||
} from "../types/keys.js";
|
||||
import { BaseController } from "./base.js";
|
||||
|
||||
class TestController extends BaseController {
|
||||
/** Expose protected `updateState` so we can drive it from tests. */
|
||||
public override updateState(): void {
|
||||
// Subclasses populate pendingStateUpdates externally
|
||||
}
|
||||
/** Test hook to call updateInput directly for normalisation paths. */
|
||||
public callUpdateInput(
|
||||
input: Parameters<TestController["updateInput"]>[0],
|
||||
value: number
|
||||
): void {
|
||||
this.updateInput(input, value);
|
||||
}
|
||||
/** Test hook to enqueue without triggering processStateUpdates yet. */
|
||||
public enqueue(
|
||||
input: Parameters<TestController["updateInput"]>[0],
|
||||
value: number,
|
||||
time: number
|
||||
): void {
|
||||
this.pendingStateUpdates.set(input, { value, time });
|
||||
this.needsUpdate = true;
|
||||
}
|
||||
public flush(): void {
|
||||
this.processStateUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
function makeClock(): {
|
||||
clock: Clock;
|
||||
advance: (ms: number) => void;
|
||||
now: () => number;
|
||||
} {
|
||||
let t = 0;
|
||||
return {
|
||||
clock: () => t,
|
||||
advance: (ms) => {
|
||||
t += ms;
|
||||
},
|
||||
now: () => t,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BaseController — construction", () => {
|
||||
it("defaults options", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
expect(c.id).toMatch(/^controller-\d+$/);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("accepts injected clock + idGenerator", { timeout: 5000 }, () => {
|
||||
let calls = 0;
|
||||
const ids: IdGenerator = () => `ctrl-${++calls}`;
|
||||
const { clock } = makeClock();
|
||||
const c = new TestController({ clock, idGenerator: ids });
|
||||
expect(c.id).toBe("ctrl-1");
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on pollRate < 1", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new TestController({ pollRate: 0 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
if (err instanceof ControllerError) {
|
||||
expect(err.code).toBe(CONTROLLER_CODES.INVALID_OPTIONS);
|
||||
expect(
|
||||
(err.context as { issues: unknown[] }).issues.length
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on pollRate > 1000", { timeout: 5000 }, () => {
|
||||
expect(() => new TestController({ pollRate: 5000 })).toThrow(
|
||||
ControllerError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on non-function clock", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() => new TestController({ clock: 42 as unknown as Clock })
|
||||
).toThrow(ControllerError);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on non-function idGenerator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new TestController({
|
||||
idGenerator: "x" as unknown as IdGenerator,
|
||||
})
|
||||
).toThrow(ControllerError);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on empty device", { timeout: 5000 }, () => {
|
||||
expect(() => new TestController({ device: "" })).toThrow(
|
||||
ControllerError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on unknown option key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new TestController({
|
||||
bogus: 1,
|
||||
} as unknown as ConstructorParameters<typeof BaseController>[0])
|
||||
).toThrow(ControllerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — getState", () => {
|
||||
it("returns a 37-field zero-initialised ControllerInput", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
const s = c.getState();
|
||||
expect(Object.keys(s).length).toBe(CONTROLLER_INPUT_KEYS.length);
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
expect(s[key]).toBe(0);
|
||||
}
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("returns a fresh object — mutation does not leak", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
const s = c.getState();
|
||||
s.up = 200;
|
||||
expect(c.getValue("up")).toBe(0);
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — setState normalisation", () => {
|
||||
it("clamps to [0, 255] and rounds", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
c.setState({ up: 999 });
|
||||
expect(c.getValue("up")).toBe(255);
|
||||
c.setState({ down: -5 });
|
||||
expect(c.getValue("down")).toBe(0);
|
||||
c.setState({ a: 12.6 });
|
||||
expect(c.getValue("a")).toBe(13);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("deadzone zeroes sub-threshold values and emits no input event", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
c.setThreshold("lx", { deadzone: 30 });
|
||||
let inputs = 0;
|
||||
c.onInput("lx", () => {
|
||||
inputs += 1;
|
||||
});
|
||||
c.setState({ lx: 20 });
|
||||
expect(c.getValue("lx")).toBe(0);
|
||||
expect(inputs).toBe(0);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("threshold zeroes values below threshold", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
c.setThreshold("rx", { threshold: 100 });
|
||||
c.setState({ rx: 50 });
|
||||
expect(c.getValue("rx")).toBe(0);
|
||||
c.setState({ rx: 150 });
|
||||
expect(c.getValue("rx")).toBe(150);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("emits input + state-change only when value differs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
let inputs = 0;
|
||||
let states = 0;
|
||||
c.onInput("up", () => {
|
||||
inputs += 1;
|
||||
});
|
||||
c.onAnyInput(() => {
|
||||
states += 1;
|
||||
});
|
||||
c.setState({ up: 100 });
|
||||
c.setState({ up: 100 }); // no change
|
||||
c.setState({ up: 200 });
|
||||
expect(inputs).toBe(2);
|
||||
expect(states).toBe(2);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("setState ignores non-numeric values", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
c.setState({ up: "abc" as unknown as number });
|
||||
expect(c.getValue("up")).toBe(0);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("setState throws INVALID_INPUT_KEY on unknown key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
expect(() =>
|
||||
c.setState({ bogus: 1 } as unknown as { up: number })
|
||||
).toThrow(ControllerError);
|
||||
try {
|
||||
c.setState({ bogus: 1 } as unknown as { up: number });
|
||||
} catch (err) {
|
||||
if (err instanceof ControllerError) {
|
||||
expect(err.code).toBe(CONTROLLER_CODES.INVALID_INPUT_KEY);
|
||||
}
|
||||
}
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("setState during processing is a no-op", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
let called = 0;
|
||||
c.onInput("up", () => {
|
||||
called += 1;
|
||||
// re-entrant call: should be ignored
|
||||
c.setState({ down: 100 });
|
||||
});
|
||||
c.setState({ up: 100 });
|
||||
expect(called).toBe(1);
|
||||
expect(c.getValue("down")).toBe(0);
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — setThreshold", () => {
|
||||
it("throws INVALID_INPUT_KEY on unknown key", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
expect(() =>
|
||||
c.setThreshold(
|
||||
"bogus" as unknown as Parameters<typeof c.setThreshold>[0],
|
||||
{ threshold: 100 }
|
||||
)
|
||||
).toThrow(ControllerError);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("defaults missing fields to 0", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
c.setThreshold("up", {});
|
||||
// No throw, normalises with zero gates
|
||||
c.setState({ up: 10 });
|
||||
expect(c.getValue("up")).toBe(10);
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — getDirectionalVector", () => {
|
||||
it("returns right-left and down-up", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
c.setState({ right: 200, left: 50, down: 100, up: 10 });
|
||||
expect(c.getDirectionalVector()).toEqual({ x: 150, y: 90 });
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — update / pollRate gating", () => {
|
||||
it("update is a no-op when needsUpdate is false", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
// No setState yet → needsUpdate is false
|
||||
let calls = 0;
|
||||
const original = c.updateState.bind(c);
|
||||
c.updateState = () => {
|
||||
calls += 1;
|
||||
original();
|
||||
};
|
||||
c.update();
|
||||
expect(calls).toBe(0);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("update respects pollRate gating", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const c = new TestController({ clock, pollRate: 10 }); // 100ms window
|
||||
let runs = 0;
|
||||
c.updateState = () => {
|
||||
runs += 1;
|
||||
};
|
||||
c.enqueue("up", 100, 0);
|
||||
c.update(); // first run — lastUpdate = 0
|
||||
expect(runs).toBe(1);
|
||||
// Force needsUpdate back on without advancing time — must be gated
|
||||
c.needsUpdate = true;
|
||||
c.update();
|
||||
expect(runs).toBe(1);
|
||||
advance(150);
|
||||
c.needsUpdate = true;
|
||||
c.update();
|
||||
expect(runs).toBe(2);
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("update on destroyed controller is no-op", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
c.destroy();
|
||||
let runs = 0;
|
||||
c.updateState = () => {
|
||||
runs += 1;
|
||||
};
|
||||
c.update();
|
||||
expect(runs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — destroy", () => {
|
||||
it("destroy is idempotent", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
c.destroy();
|
||||
expect(() => c.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it("destroy clears state and listeners", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
c.setState({ up: 200 });
|
||||
let fired = 0;
|
||||
c.onInput("up", () => {
|
||||
fired += 1;
|
||||
});
|
||||
c.destroy();
|
||||
expect(c.getValue("up")).toBe(0);
|
||||
c.setState({ up: 100 });
|
||||
expect(fired).toBe(0);
|
||||
});
|
||||
|
||||
it("setState after destroy in non-strict mode is silent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
c.destroy();
|
||||
expect(() => c.setState({ up: 100 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("setState after destroy in strict mode throws DESTROYED_INSTANCE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController({ strict: true });
|
||||
c.destroy();
|
||||
try {
|
||||
c.setState({ up: 100 });
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
if (err instanceof ControllerError) {
|
||||
expect(err.code).toBe(CONTROLLER_CODES.DESTROYED_INSTANCE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("updateInput is a no-op on destroyed controller (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
c.destroy();
|
||||
c.callUpdateInput("up", 100);
|
||||
// State was already cleared by destroy and updateInput skips
|
||||
expect(c.getValue("up")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — base updateState default", () => {
|
||||
it("default updateState is a no-op when not overridden", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
class Default extends BaseController {}
|
||||
const c = new Default();
|
||||
c.setState({ up: 100 });
|
||||
c.update();
|
||||
// No throw, value still propagated via setState pipeline
|
||||
expect(c.getValue("up")).toBe(100);
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — processStateUpdates reentrance", () => {
|
||||
it("returns early if called recursively (defensive guard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
let reentry = 0;
|
||||
c.onInput("up", () => {
|
||||
// We are inside processStateUpdates here; recursive flush should be a no-op.
|
||||
c.flush();
|
||||
reentry += 1;
|
||||
});
|
||||
c.setState({ up: 200 });
|
||||
expect(reentry).toBe(1);
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — combo forwarding", () => {
|
||||
it("onCombo proxies to the inner emitter", { timeout: 5000 }, () => {
|
||||
const c = new TestController();
|
||||
const seen: string[][] = [];
|
||||
c.onCombo((combo) => seen.push(combo));
|
||||
// Drive emitCombo via emitter directly through a subclass-style call
|
||||
(
|
||||
c as unknown as {
|
||||
eventEmitter: { emitCombo: (s: string[]) => void };
|
||||
}
|
||||
).eventEmitter.emitCombo(["up", "a"]);
|
||||
expect(seen).toEqual([["up", "a"]]);
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseController — internal state shape", () => {
|
||||
it("createDefaultControllerInput is the canonical default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new TestController();
|
||||
expect(c.getState()).toEqual(createDefaultControllerInput());
|
||||
c.destroy();
|
||||
});
|
||||
|
||||
it("press-only deltas are not skipped — value remains, pressed flips", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Forces the branch where normalized === lastValue but wasPressed !== isPressed.
|
||||
// Achievable by toggling threshold-gating after setting a value.
|
||||
const c = new TestController();
|
||||
let inputs = 0;
|
||||
c.onInput("up", () => {
|
||||
inputs += 1;
|
||||
});
|
||||
c.setState({ up: 100 });
|
||||
// Now both pressed and value === 100. Re-emit same value → no event.
|
||||
c.setState({ up: 100 });
|
||||
expect(inputs).toBe(1);
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
278
packages/gaming/games/controller/src/base/base.ts
Normal file
278
packages/gaming/games/controller/src/base/base.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { InputEventEmitter } from "../events/events.js";
|
||||
import { recordInput, recordStateChange } from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
ComboCallback,
|
||||
ControllerInput,
|
||||
InputChangeCallback,
|
||||
InputThresholds,
|
||||
StateChangeCallback,
|
||||
Vector2D,
|
||||
} from "../types/controller.types.js";
|
||||
import {
|
||||
CONTROLLER_INPUT_KEYS,
|
||||
createDefaultControllerInput,
|
||||
} from "../types/keys.js";
|
||||
import type {
|
||||
BaseControllerOptions,
|
||||
InputBookkeeping,
|
||||
ThresholdMap,
|
||||
} from "./base.types.js";
|
||||
|
||||
const BaseControllerOptionsSchema = z
|
||||
.object({
|
||||
clock: z.custom<Clock>((v) => typeof v === "function").optional(),
|
||||
idGenerator: z
|
||||
.custom<IdGenerator>((v) => typeof v === "function")
|
||||
.optional(),
|
||||
pollRate: z.number().int().min(1).max(1000).optional(),
|
||||
device: z.string().min(1).optional(),
|
||||
strict: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const VALID_KEYS = new Set<keyof ControllerInput>(CONTROLLER_INPUT_KEYS);
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
let instanceCounter = 0;
|
||||
const defaultIdGenerator: IdGenerator = () => `controller-${++instanceCounter}`;
|
||||
|
||||
/**
|
||||
* Abstract base class for every device-specific controller adapter.
|
||||
*
|
||||
* Owns the 37-field `ControllerInput` state, per-input gating
|
||||
* (`{ threshold, deadzone }`), the typed `InputEventEmitter`, and the
|
||||
* `pollRate`-gated `update()` cadence. Subclasses (`KeyboardController`,
|
||||
* `GamepadController`, `ControllerMerger`) extend this class and
|
||||
* override `updateState()` to read device state into `pendingStateUpdates`.
|
||||
*
|
||||
* All wall-clock reads go through the injected `Clock` so subclasses
|
||||
* and tests can replace it with a `FakeClock` without touching globals.
|
||||
*/
|
||||
export abstract class BaseController {
|
||||
protected state: ControllerInput = createDefaultControllerInput();
|
||||
protected readonly thresholds: ThresholdMap = this.createThresholdMap();
|
||||
protected readonly eventEmitter: InputEventEmitter;
|
||||
protected isDestroyed = false;
|
||||
protected readonly pollRate: number;
|
||||
protected lastUpdate = Number.NEGATIVE_INFINITY;
|
||||
protected readonly lastInputStates = new Map<
|
||||
keyof ControllerInput,
|
||||
InputBookkeeping
|
||||
>();
|
||||
protected needsUpdate = false;
|
||||
protected isProcessingUpdate = false;
|
||||
protected readonly pendingStateUpdates = new Map<
|
||||
keyof ControllerInput,
|
||||
{ value: number; time: number }
|
||||
>();
|
||||
protected readonly clock: Clock;
|
||||
protected readonly idGenerator: IdGenerator;
|
||||
protected readonly device: string;
|
||||
protected readonly strict: boolean;
|
||||
public readonly id: string;
|
||||
|
||||
protected constructor(options: BaseControllerOptions = {}) {
|
||||
const parsed = BaseControllerOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_OPTIONS,
|
||||
"BaseController received invalid options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
|
||||
this.pollRate = options.pollRate ?? 60;
|
||||
this.device = options.device ?? "base";
|
||||
this.strict = options.strict ?? false;
|
||||
this.id = this.idGenerator();
|
||||
this.eventEmitter = new InputEventEmitter({ strict: this.strict });
|
||||
}
|
||||
|
||||
public onInput(
|
||||
input: keyof ControllerInput,
|
||||
callback: InputChangeCallback
|
||||
): () => void {
|
||||
return this.eventEmitter.onInput(input, callback);
|
||||
}
|
||||
|
||||
public onAnyInput(callback: StateChangeCallback): () => void {
|
||||
return this.eventEmitter.onStateChange(callback);
|
||||
}
|
||||
|
||||
public onCombo(callback: ComboCallback): () => void {
|
||||
return this.eventEmitter.onCombo(callback);
|
||||
}
|
||||
|
||||
public getState(): ControllerInput {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
public getValue(input: keyof ControllerInput): number {
|
||||
return this.state[input];
|
||||
}
|
||||
|
||||
public setState(newState: Partial<ControllerInput>): void {
|
||||
if (this.isProcessingUpdate) {
|
||||
return;
|
||||
}
|
||||
if (this.isDestroyed) {
|
||||
this.guardDestroyed();
|
||||
return;
|
||||
}
|
||||
const now = this.clock();
|
||||
for (const [rawKey, value] of Object.entries(newState)) {
|
||||
const key = rawKey as keyof ControllerInput;
|
||||
if (!VALID_KEYS.has(key)) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_INPUT_KEY,
|
||||
`Unknown input key: ${rawKey}`,
|
||||
{ context: { input: rawKey } }
|
||||
);
|
||||
}
|
||||
if (typeof value !== "number") {
|
||||
continue;
|
||||
}
|
||||
this.pendingStateUpdates.set(key, { value, time: now });
|
||||
}
|
||||
this.needsUpdate = true;
|
||||
this.processStateUpdates();
|
||||
}
|
||||
|
||||
public setThreshold(
|
||||
input: keyof ControllerInput,
|
||||
thresholds: InputThresholds
|
||||
): void {
|
||||
if (!VALID_KEYS.has(input)) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_INPUT_KEY,
|
||||
`Unknown input key: ${String(input)}`,
|
||||
{ context: { input } }
|
||||
);
|
||||
}
|
||||
this.thresholds[input] = {
|
||||
threshold: thresholds.threshold ?? 0,
|
||||
deadzone: thresholds.deadzone ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
public getDirectionalVector(): Vector2D {
|
||||
return {
|
||||
x: this.state.right - this.state.left,
|
||||
y: this.state.down - this.state.up,
|
||||
};
|
||||
}
|
||||
|
||||
public update(): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
if (!this.needsUpdate) {
|
||||
return;
|
||||
}
|
||||
const now = this.clock();
|
||||
const minUpdateInterval = 1000 / this.pollRate;
|
||||
if (now - this.lastUpdate < minUpdateInterval) {
|
||||
return;
|
||||
}
|
||||
this.lastUpdate = now;
|
||||
this.updateState();
|
||||
this.needsUpdate = false;
|
||||
}
|
||||
|
||||
/** Overridden by subclasses to read device state into pending updates. */
|
||||
protected updateState(): void {
|
||||
/* base no-op */
|
||||
}
|
||||
|
||||
protected processStateUpdates(): void {
|
||||
if (this.isProcessingUpdate) {
|
||||
return;
|
||||
}
|
||||
this.isProcessingUpdate = true;
|
||||
try {
|
||||
for (const [input, { value }] of this.pendingStateUpdates) {
|
||||
this.updateInput(input, value);
|
||||
}
|
||||
this.pendingStateUpdates.clear();
|
||||
} finally {
|
||||
this.isProcessingUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected updateInput(
|
||||
input: keyof ControllerInput,
|
||||
rawValue: number
|
||||
): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(255, Math.round(rawValue)));
|
||||
const { threshold = 0, deadzone = 0 } = this.thresholds[input];
|
||||
|
||||
let normalized = clamped;
|
||||
if (Math.abs(normalized) < deadzone) {
|
||||
normalized = 0;
|
||||
}
|
||||
if (normalized > 0 && threshold > 0 && normalized < threshold) {
|
||||
normalized = 0;
|
||||
}
|
||||
|
||||
const lastState = this.lastInputStates.get(input);
|
||||
const lastValue = lastState?.value ?? 0;
|
||||
const wasPressed = lastState?.pressed ?? false;
|
||||
const isPressed = normalized > 0;
|
||||
|
||||
if (normalized === lastValue && wasPressed === isPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.clock();
|
||||
this.lastInputStates.set(input, {
|
||||
value: normalized,
|
||||
pressed: isPressed,
|
||||
time: now,
|
||||
});
|
||||
const oldState = { ...this.state };
|
||||
this.state[input] = normalized;
|
||||
this.eventEmitter.emitInput(input, normalized);
|
||||
recordInput({ device: this.device, input: input as string });
|
||||
this.eventEmitter.emitStateChange(this.state, oldState);
|
||||
recordStateChange({ device: this.device });
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.isDestroyed = true;
|
||||
this.eventEmitter.destroy();
|
||||
this.pendingStateUpdates.clear();
|
||||
this.lastInputStates.clear();
|
||||
this.state = createDefaultControllerInput();
|
||||
}
|
||||
|
||||
private guardDestroyed(): void {
|
||||
if (this.strict) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.DESTROYED_INSTANCE,
|
||||
"Operation attempted on a destroyed controller"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createThresholdMap(): ThresholdMap {
|
||||
const out = {} as ThresholdMap;
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
out[key] = {};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
55
packages/gaming/games/controller/src/base/base.types.ts
Normal file
55
packages/gaming/games/controller/src/base/base.types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import type {
|
||||
ControllerInput,
|
||||
InputThresholds,
|
||||
} from "../types/controller.types.js";
|
||||
|
||||
/** Construction options accepted by `BaseController`. */
|
||||
export interface BaseControllerOptions {
|
||||
/**
|
||||
* Wall-clock source used for `pollRate` gating and listener-state
|
||||
* timestamps. Defaults to `() => Date.now()` when omitted.
|
||||
*/
|
||||
clock?: Clock;
|
||||
/**
|
||||
* Identifier factory used to label the controller instance (e.g. for
|
||||
* telemetry attributes). Defaults to a monotonically increasing
|
||||
* `"controller-<n>"` counter that lives on the class.
|
||||
*/
|
||||
idGenerator?: IdGenerator;
|
||||
/**
|
||||
* Maximum updates per second the controller will process. Values
|
||||
* outside `[1, 1000]` are rejected at the Zod boundary. Defaults to
|
||||
* 60 Hz.
|
||||
*/
|
||||
pollRate?: number;
|
||||
/**
|
||||
* Device label written to OTel attributes (e.g. `"keyboard"`,
|
||||
* `"gamepad"`). Subclasses set their canonical value here; the base
|
||||
* default is `"base"`.
|
||||
*/
|
||||
device?: string;
|
||||
/**
|
||||
* Strict-mode opt-in — when `true`, operations on a destroyed
|
||||
* controller throw `ControllerError(DESTROYED_INSTANCE)`. Defaults
|
||||
* to `false` (silent no-op, matching the upstream contract).
|
||||
*/
|
||||
strict?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal per-input bookkeeping used by `BaseController.updateInput`.
|
||||
* Exposed via the types module so test files can pin behaviour without
|
||||
* reaching into the protected surface.
|
||||
*/
|
||||
export interface InputBookkeeping {
|
||||
value: number;
|
||||
pressed: boolean;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/** Snapshot the controller exports via `getState()`. */
|
||||
export type ControllerSnapshotState = Readonly<ControllerInput>;
|
||||
|
||||
/** Map from input key to gating configuration. */
|
||||
export type ThresholdMap = Record<keyof ControllerInput, InputThresholds>;
|
||||
7
packages/gaming/games/controller/src/base/index.ts
Normal file
7
packages/gaming/games/controller/src/base/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { BaseController } from "./base.js";
|
||||
export type {
|
||||
BaseControllerOptions,
|
||||
ControllerSnapshotState,
|
||||
InputBookkeeping,
|
||||
ThresholdMap,
|
||||
} from "./base.types.js";
|
||||
126
packages/gaming/games/controller/src/error-codes/error-codes.md
Normal file
126
packages/gaming/games/controller/src/error-codes/error-codes.md
Normal 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.
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CONTROLLER_CODES, ControllerError } from "./error-codes.js";
|
||||
|
||||
describe("CONTROLLER_CODES", () => {
|
||||
it("namespaces every code under CONTROLLER.", { timeout: 5000 }, () => {
|
||||
for (const value of Object.values(CONTROLLER_CODES)) {
|
||||
expect(value.startsWith("CONTROLLER.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the Chunk A foundation codes", { timeout: 5000 }, () => {
|
||||
expect(CONTROLLER_CODES.INVALID_OPTIONS).toBe(
|
||||
"CONTROLLER.INVALID_OPTIONS"
|
||||
);
|
||||
expect(CONTROLLER_CODES.INVALID_INPUT_KEY).toBe(
|
||||
"CONTROLLER.INVALID_INPUT_KEY"
|
||||
);
|
||||
expect(CONTROLLER_CODES.DESTROYED_INSTANCE).toBe(
|
||||
"CONTROLLER.DESTROYED_INSTANCE"
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the Chunk B device-adapter codes", { timeout: 5000 }, () => {
|
||||
expect(CONTROLLER_CODES.KEYBOARD_TARGET_MISSING).toBe(
|
||||
"CONTROLLER.KEYBOARD_TARGET_MISSING"
|
||||
);
|
||||
expect(CONTROLLER_CODES.INVALID_KEY_MAP).toBe(
|
||||
"CONTROLLER.INVALID_KEY_MAP"
|
||||
);
|
||||
expect(CONTROLLER_CODES.GAMEPAD_API_UNAVAILABLE).toBe(
|
||||
"CONTROLLER.GAMEPAD_API_UNAVAILABLE"
|
||||
);
|
||||
expect(CONTROLLER_CODES.INVALID_GAMEPAD_MAP).toBe(
|
||||
"CONTROLLER.INVALID_GAMEPAD_MAP"
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the Chunk C merger codes", { timeout: 5000 }, () => {
|
||||
expect(CONTROLLER_CODES.DUPLICATE_CHILD).toBe(
|
||||
"CONTROLLER.DUPLICATE_CHILD"
|
||||
);
|
||||
expect(CONTROLLER_CODES.UNKNOWN_CHILD).toBe("CONTROLLER.UNKNOWN_CHILD");
|
||||
});
|
||||
|
||||
it("returns a frozen registry", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(CONTROLLER_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerError", () => {
|
||||
it("constructs with code, message, cause and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_OPTIONS,
|
||||
"boom",
|
||||
{ cause, context: { issues: [{ path: ["x"], message: "bad" }] } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.name).toBe("ControllerError");
|
||||
expect(err.code).toBe(CONTROLLER_CODES.INVALID_OPTIONS);
|
||||
expect(err.message).toBe("boom");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({
|
||||
issues: [{ path: ["x"], message: "bad" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("constructs without optional fields", { timeout: 5000 }, () => {
|
||||
const err = new ControllerError(
|
||||
CONTROLLER_CODES.DESTROYED_INSTANCE,
|
||||
"destroyed"
|
||||
);
|
||||
expect(err.code).toBe(CONTROLLER_CODES.DESTROYED_INSTANCE);
|
||||
expect(err.cause).toBeUndefined();
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/controller`.
|
||||
*
|
||||
* Codes follow `CONTROLLER.<REASON>` and are resolved via
|
||||
* `@suaveplan/error`'s `defineCodes`. Consumers SHOULD discriminate on
|
||||
* `error.code` rather than `instanceof` so the namespace survives module
|
||||
* boundaries and runtime serialisation.
|
||||
*
|
||||
* Chunk A registers the foundation codes (Zod boundary, threshold-key
|
||||
* validation, destroyed-state guard). Chunks B (device adapters) and C
|
||||
* (merger) append device + merger codes by re-declaring the union; this
|
||||
* file is the canonical definition list.
|
||||
*/
|
||||
export const CONTROLLER_CODES = defineCodes("CONTROLLER", [
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_INPUT_KEY",
|
||||
"DESTROYED_INSTANCE",
|
||||
"KEYBOARD_TARGET_MISSING",
|
||||
"INVALID_KEY_MAP",
|
||||
"GAMEPAD_API_UNAVAILABLE",
|
||||
"INVALID_GAMEPAD_MAP",
|
||||
"DUPLICATE_CHILD",
|
||||
"UNKNOWN_CHILD",
|
||||
] as const);
|
||||
|
||||
export type ControllerCode =
|
||||
(typeof CONTROLLER_CODES)[keyof typeof CONTROLLER_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for every `@suaveplan/controller` failure path.
|
||||
*
|
||||
* Extends `SuaveplanError<ControllerCode>` so the `code` field narrows
|
||||
* against the literal union and consumers get full type-checking on
|
||||
* `switch (err.code)` blocks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { CONTROLLER_CODES, ControllerError } from "@suaveplan/controller";
|
||||
*
|
||||
* try {
|
||||
* controller.setThreshold("notAKey" as never, { threshold: 100 });
|
||||
* } catch (err) {
|
||||
* if (err instanceof ControllerError && err.code === CONTROLLER_CODES.INVALID_INPUT_KEY) {
|
||||
* console.error("unknown input key", err.context);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ControllerError extends SuaveplanError<ControllerCode> {
|
||||
public constructor(
|
||||
code: ControllerCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "ControllerError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { ControllerCode } from "./error-codes.js";
|
||||
export {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "./error-codes.js";
|
||||
90
packages/gaming/games/controller/src/events/events.md
Normal file
90
packages/gaming/games/controller/src/events/events.md
Normal 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`.
|
||||
309
packages/gaming/games/controller/src/events/events.test.ts
Normal file
309
packages/gaming/games/controller/src/events/events.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createDefaultControllerInput } from "../types/keys.js";
|
||||
import { InputEventEmitter } from "./events.js";
|
||||
|
||||
describe("InputEventEmitter — construction", () => {
|
||||
it("accepts no options", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
expect(e.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts strict: true", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter({ strict: true });
|
||||
expect(e.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("throws ControllerError(INVALID_OPTIONS) on bad options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new InputEventEmitter({
|
||||
strict: "yes" as unknown as boolean,
|
||||
})
|
||||
).toThrow(ControllerError);
|
||||
try {
|
||||
new InputEventEmitter({ strict: "yes" as unknown as boolean });
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
if (err instanceof ControllerError) {
|
||||
expect(err.code).toBe(CONTROLLER_CODES.INVALID_OPTIONS);
|
||||
expect(
|
||||
(err.context as { issues: unknown[] }).issues.length
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown option keys via strict zod schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new InputEventEmitter({
|
||||
unexpected: 1,
|
||||
} as unknown as { strict?: boolean })
|
||||
).toThrow(ControllerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputEventEmitter — onInput / emitInput", () => {
|
||||
it("invokes registered listeners with (input, value, prevValue)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
const calls: [string, number, number][] = [];
|
||||
e.onInput("up", (input, value, prev) => {
|
||||
calls.push([input as string, value, prev]);
|
||||
});
|
||||
e.emitInput("up", 100);
|
||||
e.emitInput("up", 200);
|
||||
expect(calls).toEqual([
|
||||
["up", 100, 0],
|
||||
["up", 200, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("disposer removes only the registered listener", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
const offA = e.onInput("up", () => {
|
||||
a += 1;
|
||||
});
|
||||
e.onInput("up", () => {
|
||||
b += 1;
|
||||
});
|
||||
offA();
|
||||
e.emitInput("up", 1);
|
||||
expect(a).toBe(0);
|
||||
expect(b).toBe(1);
|
||||
});
|
||||
|
||||
it("disposer cleans up empty bucket on the inputListeners map", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
const off = e.onInput("up", () => undefined);
|
||||
off();
|
||||
// Re-registering recreates the bucket — exercises lazy path again
|
||||
let called = 0;
|
||||
e.onInput("up", () => {
|
||||
called += 1;
|
||||
});
|
||||
e.emitInput("up", 1);
|
||||
expect(called).toBe(1);
|
||||
});
|
||||
|
||||
it("disposer is safe to call after clear()", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
const off = e.onInput("up", () => undefined);
|
||||
e.clear();
|
||||
expect(() => off()).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns no-op disposer in non-strict mode after destroy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
e.destroy();
|
||||
const off = e.onInput("up", () => undefined);
|
||||
expect(typeof off).toBe("function");
|
||||
expect(() => off()).not.toThrow();
|
||||
});
|
||||
|
||||
it("emitInput is a no-op after destroy", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
let count = 0;
|
||||
e.onInput("up", () => {
|
||||
count += 1;
|
||||
});
|
||||
e.destroy();
|
||||
e.emitInput("up", 100);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("emitInput without listeners still tracks lastInputValues", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
e.emitInput("up", 10);
|
||||
const calls: number[] = [];
|
||||
e.onInput("up", (_input, _value, prev) => {
|
||||
calls.push(prev);
|
||||
});
|
||||
e.emitInput("up", 20);
|
||||
expect(calls).toEqual([10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputEventEmitter — onStateChange / emitStateChange", () => {
|
||||
it("invokes state listeners with state + optional old state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
const seen: Array<[number, number | undefined]> = [];
|
||||
e.onStateChange((state, old) => {
|
||||
seen.push([state.up, old?.up]);
|
||||
});
|
||||
const state = createDefaultControllerInput();
|
||||
state.up = 100;
|
||||
e.emitStateChange(state, { up: 50 });
|
||||
e.emitStateChange(state);
|
||||
expect(seen).toEqual([
|
||||
[100, 50],
|
||||
[100, undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emitStateChange is a no-op after destroy", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
let count = 0;
|
||||
e.onStateChange(() => {
|
||||
count += 1;
|
||||
});
|
||||
e.destroy();
|
||||
e.emitStateChange(createDefaultControllerInput());
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("disposer removes the state listener", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
let count = 0;
|
||||
const off = e.onStateChange(() => {
|
||||
count += 1;
|
||||
});
|
||||
off();
|
||||
e.emitStateChange(createDefaultControllerInput());
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("returns no-op disposer on onStateChange after destroy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
e.destroy();
|
||||
const off = e.onStateChange(() => undefined);
|
||||
expect(() => off()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputEventEmitter — onCombo / emitCombo", () => {
|
||||
it("invokes combo listeners with the sequence", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
const seen: string[][] = [];
|
||||
e.onCombo((c) => {
|
||||
seen.push(c);
|
||||
});
|
||||
e.emitCombo(["up", "a"]);
|
||||
expect(seen).toEqual([["up", "a"]]);
|
||||
});
|
||||
|
||||
it("disposer removes the combo listener", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
let count = 0;
|
||||
const off = e.onCombo(() => {
|
||||
count += 1;
|
||||
});
|
||||
off();
|
||||
e.emitCombo(["up"]);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("emitCombo is a no-op after destroy", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
let count = 0;
|
||||
e.onCombo(() => {
|
||||
count += 1;
|
||||
});
|
||||
e.destroy();
|
||||
e.emitCombo(["up"]);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("returns no-op disposer on onCombo after destroy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
e.destroy();
|
||||
const off = e.onCombo(() => undefined);
|
||||
expect(() => off()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputEventEmitter — strict mode", () => {
|
||||
it("throws DESTROYED_INSTANCE on onInput after destroy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter({ strict: true });
|
||||
e.destroy();
|
||||
try {
|
||||
e.onInput("up", () => undefined);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
if (err instanceof ControllerError) {
|
||||
expect(err.code).toBe(CONTROLLER_CODES.DESTROYED_INSTANCE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DESTROYED_INSTANCE on onStateChange after destroy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter({ strict: true });
|
||||
e.destroy();
|
||||
expect(() => e.onStateChange(() => undefined)).toThrow(ControllerError);
|
||||
});
|
||||
|
||||
it("throws DESTROYED_INSTANCE on onCombo after destroy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter({ strict: true });
|
||||
e.destroy();
|
||||
expect(() => e.onCombo(() => undefined)).toThrow(ControllerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputEventEmitter — lifecycle", () => {
|
||||
it("destroy is idempotent", { timeout: 5000 }, () => {
|
||||
const e = new InputEventEmitter();
|
||||
e.destroy();
|
||||
expect(e.destroyed).toBe(true);
|
||||
expect(() => e.destroy()).not.toThrow();
|
||||
expect(e.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("clear removes all listeners but keeps the emitter usable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new InputEventEmitter();
|
||||
let count = 0;
|
||||
e.onInput("up", () => {
|
||||
count += 1;
|
||||
});
|
||||
e.onStateChange(() => {
|
||||
count += 1;
|
||||
});
|
||||
e.onCombo(() => {
|
||||
count += 1;
|
||||
});
|
||||
e.clear();
|
||||
e.emitInput("up", 1);
|
||||
e.emitStateChange(createDefaultControllerInput());
|
||||
e.emitCombo(["up"]);
|
||||
expect(count).toBe(0);
|
||||
// Still alive — can register again
|
||||
e.onInput("up", () => {
|
||||
count += 1;
|
||||
});
|
||||
e.emitInput("up", 1);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
172
packages/gaming/games/controller/src/events/events.ts
Normal file
172
packages/gaming/games/controller/src/events/events.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
ComboCallback,
|
||||
ControllerInput,
|
||||
InputChangeCallback,
|
||||
StateChangeCallback,
|
||||
} from "../types/controller.types.js";
|
||||
import type {
|
||||
Disposer,
|
||||
InputEventEmitterApi,
|
||||
InputEventEmitterOptions,
|
||||
} from "./events.types.js";
|
||||
|
||||
const InputEventEmitterOptionsSchema = z
|
||||
.object({
|
||||
strict: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const noop: Disposer = () => {
|
||||
/* no-op */
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed event dispatcher for `BaseController` and downstream consumers.
|
||||
*
|
||||
* Listeners are stored in `Set`s so registration order is preserved and
|
||||
* disposal is O(1). Every `on*` method returns a `Disposer` that removes
|
||||
* exactly the listener that was registered, never affecting siblings.
|
||||
*
|
||||
* In non-strict mode (default) post-`destroy()` registrations return a
|
||||
* no-op disposer, matching the upstream emitter contract. In strict mode
|
||||
* they throw `ControllerError(DESTROYED_INSTANCE)`.
|
||||
*/
|
||||
export class InputEventEmitter implements InputEventEmitterApi {
|
||||
private readonly inputListeners = new Map<
|
||||
keyof ControllerInput,
|
||||
Set<InputChangeCallback>
|
||||
>();
|
||||
private readonly stateListeners = new Set<StateChangeCallback>();
|
||||
private readonly comboListeners = new Set<ComboCallback>();
|
||||
private readonly lastInputValues = new Map<keyof ControllerInput, number>();
|
||||
private readonly strict: boolean;
|
||||
private isDestroyed = false;
|
||||
|
||||
public constructor(options: InputEventEmitterOptions = {}) {
|
||||
const parsed = InputEventEmitterOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_OPTIONS,
|
||||
"InputEventEmitter received invalid options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.strict = options.strict ?? false;
|
||||
}
|
||||
|
||||
public get destroyed(): boolean {
|
||||
return this.isDestroyed;
|
||||
}
|
||||
|
||||
public onInput(
|
||||
input: keyof ControllerInput,
|
||||
callback: InputChangeCallback
|
||||
): Disposer {
|
||||
if (this.isDestroyed) {
|
||||
return this.guardDestroyed();
|
||||
}
|
||||
let bucket = this.inputListeners.get(input);
|
||||
if (bucket === undefined) {
|
||||
bucket = new Set();
|
||||
this.inputListeners.set(input, bucket);
|
||||
}
|
||||
bucket.add(callback);
|
||||
return () => {
|
||||
const listeners = this.inputListeners.get(input);
|
||||
if (listeners === undefined) {
|
||||
return;
|
||||
}
|
||||
listeners.delete(callback);
|
||||
if (listeners.size === 0) {
|
||||
this.inputListeners.delete(input);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public onStateChange(callback: StateChangeCallback): Disposer {
|
||||
if (this.isDestroyed) {
|
||||
return this.guardDestroyed();
|
||||
}
|
||||
this.stateListeners.add(callback);
|
||||
return () => {
|
||||
this.stateListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
public onCombo(callback: ComboCallback): Disposer {
|
||||
if (this.isDestroyed) {
|
||||
return this.guardDestroyed();
|
||||
}
|
||||
this.comboListeners.add(callback);
|
||||
return () => {
|
||||
this.comboListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
public emitInput(input: keyof ControllerInput, value: number): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const prevValue = this.lastInputValues.get(input) ?? 0;
|
||||
this.lastInputValues.set(input, value);
|
||||
const listeners = this.inputListeners.get(input);
|
||||
if (listeners === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const callback of listeners) {
|
||||
callback(input, value, prevValue);
|
||||
}
|
||||
}
|
||||
|
||||
public emitStateChange(
|
||||
state: ControllerInput,
|
||||
oldState?: Partial<ControllerInput>
|
||||
): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
for (const callback of this.stateListeners) {
|
||||
callback(state, oldState);
|
||||
}
|
||||
}
|
||||
|
||||
public emitCombo(combo: string[]): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
for (const callback of this.comboListeners) {
|
||||
callback(combo);
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.inputListeners.clear();
|
||||
this.stateListeners.clear();
|
||||
this.comboListeners.clear();
|
||||
this.lastInputValues.clear();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.isDestroyed = true;
|
||||
this.clear();
|
||||
}
|
||||
|
||||
private guardDestroyed(): Disposer {
|
||||
if (this.strict) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.DESTROYED_INSTANCE,
|
||||
"InputEventEmitter has been destroyed"
|
||||
);
|
||||
}
|
||||
return noop;
|
||||
}
|
||||
}
|
||||
39
packages/gaming/games/controller/src/events/events.types.ts
Normal file
39
packages/gaming/games/controller/src/events/events.types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
ComboCallback,
|
||||
ControllerInput,
|
||||
InputChangeCallback,
|
||||
StateChangeCallback,
|
||||
} from "../types/controller.types.js";
|
||||
|
||||
/** Disposer returned by every `on*` registration. */
|
||||
export type Disposer = () => void;
|
||||
|
||||
/** Construction options for `InputEventEmitter`. */
|
||||
export interface InputEventEmitterOptions {
|
||||
/**
|
||||
* Strict mode — when `true`, post-`destroy()` `on*` registrations throw
|
||||
* `ControllerError(DESTROYED_INSTANCE, …)` instead of silently returning
|
||||
* a no-op disposer. Defaults to `false` for back-compat with the
|
||||
* upstream emitter shape.
|
||||
*/
|
||||
strict?: boolean;
|
||||
}
|
||||
|
||||
/** Shape of the typed emitter exported from this module. */
|
||||
export interface InputEventEmitterApi {
|
||||
onInput(
|
||||
input: keyof ControllerInput,
|
||||
callback: InputChangeCallback
|
||||
): Disposer;
|
||||
onStateChange(callback: StateChangeCallback): Disposer;
|
||||
onCombo(callback: ComboCallback): Disposer;
|
||||
emitInput(input: keyof ControllerInput, value: number): void;
|
||||
emitStateChange(
|
||||
state: ControllerInput,
|
||||
oldState?: Partial<ControllerInput>
|
||||
): void;
|
||||
emitCombo(combo: string[]): void;
|
||||
clear(): void;
|
||||
destroy(): void;
|
||||
readonly destroyed: boolean;
|
||||
}
|
||||
6
packages/gaming/games/controller/src/events/index.ts
Normal file
6
packages/gaming/games/controller/src/events/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { InputEventEmitter } from "./events.js";
|
||||
export type {
|
||||
Disposer,
|
||||
InputEventEmitterApi,
|
||||
InputEventEmitterOptions,
|
||||
} from "./events.types.js";
|
||||
114
packages/gaming/games/controller/src/gamepad/gamepad.internal.md
Normal file
114
packages/gaming/games/controller/src/gamepad/gamepad.internal.md
Normal 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.
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { CONTROLLER_INPUT_KEYS } from "../types/keys.js";
|
||||
import type {
|
||||
GamepadAxisBinding,
|
||||
GamepadAxisMap,
|
||||
GamepadButtonMap,
|
||||
GamepadLike,
|
||||
GamepadSource,
|
||||
} from "./gamepad.types.js";
|
||||
|
||||
const VALID_INPUT_KEYS = new Set<string>(
|
||||
CONTROLLER_INPUT_KEYS as readonly string[]
|
||||
);
|
||||
|
||||
/** Validate every button-map entry against `CONTROLLER_INPUT_KEYS`. */
|
||||
export function validateButtonMap(map: GamepadButtonMap): GamepadButtonMap {
|
||||
for (const [index, input] of Object.entries(map)) {
|
||||
if (!VALID_INPUT_KEYS.has(input as string)) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_GAMEPAD_MAP,
|
||||
`GamepadController buttonMap entry "${index}" targets unknown input "${input}"`,
|
||||
{ context: { entry: [index, input] } }
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ...map });
|
||||
}
|
||||
|
||||
/** Validate every axis-map binding against `CONTROLLER_INPUT_KEYS`. */
|
||||
export function validateAxisMap(map: GamepadAxisMap): GamepadAxisMap {
|
||||
const frozen: Record<number, GamepadAxisBinding> = {};
|
||||
for (const [rawIndex, binding] of Object.entries(map)) {
|
||||
const checks: Array<
|
||||
["positive" | "negative" | "analog", string | undefined]
|
||||
> = [
|
||||
["positive", binding.positive],
|
||||
["negative", binding.negative],
|
||||
["analog", binding.analog],
|
||||
];
|
||||
for (const [role, value] of checks) {
|
||||
if (value !== undefined && !VALID_INPUT_KEYS.has(value)) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_GAMEPAD_MAP,
|
||||
`GamepadController axisMap entry "${rawIndex}.${role}" targets unknown input "${value}"`,
|
||||
{ context: { entry: [rawIndex, role, value] } }
|
||||
);
|
||||
}
|
||||
}
|
||||
frozen[Number(rawIndex)] = Object.freeze({ ...binding });
|
||||
}
|
||||
return Object.freeze(frozen);
|
||||
}
|
||||
|
||||
/** Resolve `navigator.getGamepads` from the host without referencing it at module-load. */
|
||||
export function resolveGlobalGamepadSource(): GamepadSource | undefined {
|
||||
const nav = (
|
||||
globalThis as unknown as {
|
||||
navigator?: {
|
||||
getGamepads?: () => readonly (GamepadLike | null | undefined)[];
|
||||
};
|
||||
}
|
||||
).navigator;
|
||||
if (nav !== undefined && typeof nav.getGamepads === "function") {
|
||||
const getGamepads = nav.getGamepads.bind(nav);
|
||||
return () => getGamepads();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { GamepadController } from "./gamepad.js";
|
||||
import type { GamepadLike } from "./gamepad.types.js";
|
||||
|
||||
function makeClock(): {
|
||||
clock: Clock;
|
||||
advance: (ms: number) => void;
|
||||
} {
|
||||
let t = 0;
|
||||
return {
|
||||
clock: () => t,
|
||||
advance: (ms) => {
|
||||
t += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makePad(
|
||||
buttons: ReadonlyArray<{ pressed: boolean; value?: number } | null>,
|
||||
axes: readonly number[],
|
||||
extra: { connected?: boolean } = {}
|
||||
): GamepadLike {
|
||||
return {
|
||||
buttons: buttons.map((b) =>
|
||||
b === null ? { pressed: false, value: 0 } : b
|
||||
),
|
||||
axes,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GamepadController — connection lifecycle", () => {
|
||||
it("zeros state on disconnect (null pad)", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
let connected = true;
|
||||
const pads: () => (GamepadLike | null)[] = () =>
|
||||
connected ? [makePad([{ pressed: true, value: 1 }], [])] : [null];
|
||||
const gp = new GamepadController({ clock, source: pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
connected = false;
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("zeros state on undefined slot", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
let connected = true;
|
||||
const pads: () => (GamepadLike | undefined)[] = () =>
|
||||
connected
|
||||
? [makePad([{ pressed: true, value: 1 }], [])]
|
||||
: [undefined];
|
||||
const gp = new GamepadController({ clock, source: pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
connected = false;
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("handles connected: false explicitly", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
let connected = true;
|
||||
const pads = (): (GamepadLike | null)[] => [
|
||||
makePad([{ pressed: true, value: 1 }], [], { connected }),
|
||||
];
|
||||
const gp = new GamepadController({ clock, source: pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
connected = false;
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("does not zero again if already disconnected", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () => [null],
|
||||
});
|
||||
let changes = 0;
|
||||
gp.onAnyInput(() => {
|
||||
changes += 1;
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(changes).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("reads from the requested padIndex", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [null, makePad([{ pressed: true, value: 1 }], [])];
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () => pads,
|
||||
padIndex: 1,
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("ignores connected: false pad on first observation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () => [makePad([], [], { connected: false })],
|
||||
});
|
||||
let changes = 0;
|
||||
gp.onAnyInput(() => {
|
||||
changes += 1;
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(changes).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("treats released button with missing value as 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [
|
||||
makePad(
|
||||
[
|
||||
{
|
||||
pressed: false,
|
||||
} as unknown as { pressed: boolean; value?: number },
|
||||
],
|
||||
[]
|
||||
),
|
||||
];
|
||||
const gp = new GamepadController({ clock, source: () => pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("zero-all skips analog reset when axisMap lacks analog binding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
let connected = true;
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () =>
|
||||
connected
|
||||
? [makePad([{ pressed: true, value: 1 }], [0.5])]
|
||||
: [null],
|
||||
axisMap: { 0: { positive: "right", negative: "left" } },
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
connected = false;
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("lx")).toBe(0);
|
||||
expect(gp.getValue("right")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("uses defaults when no options argument is passed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const prior = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
getGamepads: () => [makePad([{ pressed: true, value: 1 }], [])],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const gp = new GamepadController();
|
||||
expect(gp.getDeadzone()).toBeCloseTo(0.1);
|
||||
gp.destroy();
|
||||
} finally {
|
||||
if (prior === undefined) {
|
||||
Reflect.deleteProperty(globalThis, "navigator");
|
||||
} else {
|
||||
Object.defineProperty(globalThis, "navigator", prior);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
142
packages/gaming/games/controller/src/gamepad/gamepad.md
Normal file
142
packages/gaming/games/controller/src/gamepad/gamepad.md
Normal 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.
|
||||
391
packages/gaming/games/controller/src/gamepad/gamepad.test.ts
Normal file
391
packages/gaming/games/controller/src/gamepad/gamepad.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DEFAULT_GAMEPAD_AXIS_MAP,
|
||||
DEFAULT_GAMEPAD_BUTTON_MAP,
|
||||
GamepadController,
|
||||
} from "./gamepad.js";
|
||||
import type { GamepadLike, GamepadSource } from "./gamepad.types.js";
|
||||
|
||||
function makeClock(): {
|
||||
clock: Clock;
|
||||
advance: (ms: number) => void;
|
||||
} {
|
||||
let t = 0;
|
||||
return {
|
||||
clock: () => t,
|
||||
advance: (ms) => {
|
||||
t += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makePad(
|
||||
buttons: ReadonlyArray<{ pressed: boolean; value?: number } | null>,
|
||||
axes: readonly number[],
|
||||
extra: { connected?: boolean } = {}
|
||||
): GamepadLike {
|
||||
return {
|
||||
buttons: buttons.map((b) =>
|
||||
b === null ? { pressed: false, value: 0 } : b
|
||||
),
|
||||
axes,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GamepadController — construction", () => {
|
||||
it("uses keyboard defaults and exposes them", { timeout: 5000 }, () => {
|
||||
const gp = new GamepadController({ source: () => [] });
|
||||
expect(gp.getButtonMap()).toEqual(DEFAULT_GAMEPAD_BUTTON_MAP);
|
||||
expect(gp.getAxisMap()).toEqual(DEFAULT_GAMEPAD_AXIS_MAP);
|
||||
expect(gp.getDeadzone()).toBeCloseTo(0.1);
|
||||
expect(gp.getPadIndex()).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS for bad source", { timeout: 5000 }, () => {
|
||||
const err = (() => {
|
||||
try {
|
||||
return new GamepadController({
|
||||
source: "not-a-fn" as unknown as GamepadSource,
|
||||
});
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})();
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect((err as ControllerError).code).toBe(
|
||||
CONTROLLER_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_GAMEPAD_MAP for bad button input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = (() => {
|
||||
try {
|
||||
return new GamepadController({
|
||||
source: () => [],
|
||||
buttonMap: { 0: "nope" as never },
|
||||
});
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})();
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect((err as ControllerError).code).toBe(
|
||||
CONTROLLER_CODES.INVALID_GAMEPAD_MAP
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_GAMEPAD_MAP for bad axis positive", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = (() => {
|
||||
try {
|
||||
return new GamepadController({
|
||||
source: () => [],
|
||||
axisMap: { 0: { positive: "nope" as never } },
|
||||
});
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})();
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect((err as ControllerError).code).toBe(
|
||||
CONTROLLER_CODES.INVALID_GAMEPAD_MAP
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_GAMEPAD_MAP for bad axis negative", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = (() => {
|
||||
try {
|
||||
return new GamepadController({
|
||||
source: () => [],
|
||||
axisMap: { 0: { negative: "bad" as never } },
|
||||
});
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})();
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect((err as ControllerError).code).toBe(
|
||||
CONTROLLER_CODES.INVALID_GAMEPAD_MAP
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_GAMEPAD_MAP for bad axis analog", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = (() => {
|
||||
try {
|
||||
return new GamepadController({
|
||||
source: () => [],
|
||||
axisMap: { 0: { analog: "bad" as never } },
|
||||
});
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})();
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect((err as ControllerError).code).toBe(
|
||||
CONTROLLER_CODES.INVALID_GAMEPAD_MAP
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves globalThis.navigator.getGamepads", { timeout: 5000 }, () => {
|
||||
const prior = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
getGamepads: () => [makePad([{ pressed: true, value: 1 }], [])],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { clock, advance } = makeClock();
|
||||
const gp = new GamepadController({ clock });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
gp.destroy();
|
||||
} finally {
|
||||
if (prior === undefined) {
|
||||
Reflect.deleteProperty(globalThis, "navigator");
|
||||
} else {
|
||||
Object.defineProperty(globalThis, "navigator", prior);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws GAMEPAD_API_UNAVAILABLE when no source resolvable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const prior = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
const { clock, advance } = makeClock();
|
||||
const gp = new GamepadController({ clock });
|
||||
advance(100);
|
||||
expect(() => gp.update()).toThrowError(ControllerError);
|
||||
} finally {
|
||||
if (prior === undefined) {
|
||||
Reflect.deleteProperty(globalThis, "navigator");
|
||||
} else {
|
||||
Object.defineProperty(globalThis, "navigator", prior);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("update is a no-op after destroy", { timeout: 5000 }, () => {
|
||||
const gp = new GamepadController({ source: () => [] });
|
||||
gp.destroy();
|
||||
gp.update(); // should not throw
|
||||
});
|
||||
|
||||
it("accepts injected clock, idGenerator, pollRate, strict and device", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const gp = new GamepadController({
|
||||
source: () => [],
|
||||
clock: () => 0,
|
||||
idGenerator: () => "gp-1",
|
||||
pollRate: 30,
|
||||
strict: true,
|
||||
device: "custom-pad",
|
||||
});
|
||||
expect(gp.id).toBe("gp-1");
|
||||
gp.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GamepadController — buttons", () => {
|
||||
it("maps a pressed button to 255", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads: (GamepadLike | null)[] = [
|
||||
makePad([{ pressed: true, value: 1 }], []),
|
||||
];
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () => pads,
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("uses analog button value when present", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([{ pressed: true, value: 0.5 }], [])];
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () => pads,
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBeGreaterThan(120);
|
||||
expect(gp.getValue("a")).toBeLessThan(135);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("falls back to pressed when value is missing", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [
|
||||
makePad(
|
||||
[
|
||||
{
|
||||
pressed: true,
|
||||
} as unknown as { pressed: boolean; value?: number },
|
||||
],
|
||||
[]
|
||||
),
|
||||
];
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () => pads,
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("skips undefined buttons", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
// a pad with one defined button and the rest absent
|
||||
const pad: GamepadLike = {
|
||||
buttons: [{ pressed: true, value: 1 }],
|
||||
axes: [],
|
||||
};
|
||||
const gp = new GamepadController({ clock, source: () => [pad] });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
// 'b' is not driven because index 1 is undefined
|
||||
expect(gp.getValue("b")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("clamps over-range analog button values", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([{ pressed: true, value: 1.7 }], [])];
|
||||
const gp = new GamepadController({ clock, source: () => pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("a")).toBe(255);
|
||||
gp.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GamepadController — axes", () => {
|
||||
it("drives positive half of axis", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([], [0.5, 0, 0, 0])];
|
||||
const gp = new GamepadController({ clock, source: () => pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("right")).toBe(128); // 0.5 * 255 = 127.5 -> 128
|
||||
expect(gp.getValue("left")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("drives negative half of axis", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([], [-0.75, 0, 0, 0])];
|
||||
const gp = new GamepadController({ clock, source: () => pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("left")).toBe(191); // 0.75 * 255 ≈ 191
|
||||
expect(gp.getValue("right")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("applies deadzone", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([], [0.05, 0, 0, 0])];
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () => pads,
|
||||
deadzone: 0.1,
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("right")).toBe(0);
|
||||
expect(gp.getValue("left")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("emits centred analog value", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([], [0, 0, 0, 0])];
|
||||
const gp = new GamepadController({ clock, source: () => pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("lx")).toBe(128);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("clamps over-range axis values", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([], [1.5, 0, 0, 0])];
|
||||
const gp = new GamepadController({ clock, source: () => pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("right")).toBe(255);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("ignores NaN axes", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([], [Number.NaN, 0, 0, 0])];
|
||||
const gp = new GamepadController({ clock, source: () => pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("right")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("skips undefined axis indices", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([], [])]; // empty axes array
|
||||
const gp = new GamepadController({ clock, source: () => pads });
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("lx")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
|
||||
it("respects axis bindings with only positive defined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const pads = [makePad([], [0.6])];
|
||||
const gp = new GamepadController({
|
||||
clock,
|
||||
source: () => pads,
|
||||
axisMap: { 0: { positive: "right" } },
|
||||
});
|
||||
advance(100);
|
||||
gp.update();
|
||||
expect(gp.getValue("right")).toBe(153);
|
||||
// 'left' was never bound -> stays 0
|
||||
expect(gp.getValue("left")).toBe(0);
|
||||
gp.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// Connection lifecycle, padIndex selection, default-options, and edge
|
||||
// cases are exercised in `gamepad.lifecycle.test.ts` to keep each test
|
||||
// file under the 500-line repository limit.
|
||||
260
packages/gaming/games/controller/src/gamepad/gamepad.ts
Normal file
260
packages/gaming/games/controller/src/gamepad/gamepad.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import z from "zod";
|
||||
import { BaseController } from "../base/base.js";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { ControllerInput } from "../types/controller.types.js";
|
||||
import {
|
||||
resolveGlobalGamepadSource,
|
||||
validateAxisMap,
|
||||
validateButtonMap,
|
||||
} from "./gamepad.internal.js";
|
||||
import type {
|
||||
GamepadAxisMap,
|
||||
GamepadButtonMap,
|
||||
GamepadControllerOptions,
|
||||
GamepadLike,
|
||||
GamepadSource,
|
||||
} from "./gamepad.types.js";
|
||||
|
||||
const GamepadOptionsSchema = z
|
||||
.object({
|
||||
clock: z
|
||||
.custom<NonNullable<GamepadControllerOptions["clock"]>>(
|
||||
(v) => typeof v === "function"
|
||||
)
|
||||
.optional(),
|
||||
idGenerator: z
|
||||
.custom<NonNullable<GamepadControllerOptions["idGenerator"]>>(
|
||||
(v) => typeof v === "function"
|
||||
)
|
||||
.optional(),
|
||||
pollRate: z.number().int().min(1).max(1000).optional(),
|
||||
device: z.string().min(1).optional(),
|
||||
strict: z.boolean().optional(),
|
||||
source: z
|
||||
.custom<GamepadSource>((v) => typeof v === "function")
|
||||
.optional(),
|
||||
buttonMap: z.record(z.string(), z.string()).optional(),
|
||||
axisMap: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
positive: z.string().optional(),
|
||||
negative: z.string().optional(),
|
||||
analog: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.optional(),
|
||||
deadzone: z.number().min(0).max(0.99).optional(),
|
||||
padIndex: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
/** W3C standard-mapping button index → `ControllerInput` field. */
|
||||
export const DEFAULT_GAMEPAD_BUTTON_MAP: GamepadButtonMap = Object.freeze({
|
||||
0: "a",
|
||||
1: "b",
|
||||
2: "x",
|
||||
3: "y",
|
||||
4: "t1",
|
||||
5: "t2",
|
||||
6: "lt",
|
||||
7: "rt",
|
||||
8: "f1",
|
||||
9: "f2",
|
||||
10: "l3",
|
||||
11: "r3",
|
||||
12: "up",
|
||||
13: "down",
|
||||
14: "left",
|
||||
15: "right",
|
||||
16: "f3",
|
||||
});
|
||||
|
||||
/** W3C standard-mapping axis layout (left + right sticks). */
|
||||
export const DEFAULT_GAMEPAD_AXIS_MAP: GamepadAxisMap = Object.freeze({
|
||||
0: { positive: "right", negative: "left", analog: "lx" },
|
||||
1: { positive: "down", negative: "up", analog: "ly" },
|
||||
2: { analog: "rx" },
|
||||
3: { analog: "ry" },
|
||||
});
|
||||
|
||||
const DEFAULT_DEADZONE = 0.1;
|
||||
|
||||
/**
|
||||
* Browser-tier Web Gamepad API adapter.
|
||||
*
|
||||
* Polls the injected `GamepadSource` (defaulting to
|
||||
* `navigator.getGamepads()` when available) each `update()` tick,
|
||||
* diffs button + axis state against the previous frame, and pushes
|
||||
* normalised values into the canonical `ControllerInput` shape via
|
||||
* `BaseController.setState`.
|
||||
*/
|
||||
export class GamepadController extends BaseController {
|
||||
private readonly source: GamepadSource | undefined;
|
||||
private readonly buttonMap: GamepadButtonMap;
|
||||
private readonly axisMap: GamepadAxisMap;
|
||||
private readonly deadzone: number;
|
||||
private readonly padIndex: number;
|
||||
private wasConnected = false;
|
||||
|
||||
public constructor(options: GamepadControllerOptions = {}) {
|
||||
const parsed = GamepadOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_OPTIONS,
|
||||
"GamepadController received invalid options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const baseOptions: Partial<GamepadControllerOptions> = {
|
||||
device: options.device ?? "gamepad",
|
||||
};
|
||||
if (options.clock !== undefined) {
|
||||
baseOptions.clock = options.clock;
|
||||
}
|
||||
if (options.idGenerator !== undefined) {
|
||||
baseOptions.idGenerator = options.idGenerator;
|
||||
}
|
||||
if (options.pollRate !== undefined) {
|
||||
baseOptions.pollRate = options.pollRate;
|
||||
}
|
||||
if (options.strict !== undefined) {
|
||||
baseOptions.strict = options.strict;
|
||||
}
|
||||
super(baseOptions);
|
||||
this.buttonMap = validateButtonMap(
|
||||
options.buttonMap ?? DEFAULT_GAMEPAD_BUTTON_MAP
|
||||
);
|
||||
this.axisMap = validateAxisMap(
|
||||
options.axisMap ?? DEFAULT_GAMEPAD_AXIS_MAP
|
||||
);
|
||||
this.deadzone = options.deadzone ?? DEFAULT_DEADZONE;
|
||||
this.padIndex = options.padIndex ?? 0;
|
||||
this.source = options.source ?? resolveGlobalGamepadSource();
|
||||
this.needsUpdate = true;
|
||||
}
|
||||
|
||||
public override update(): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
if (this.source === undefined) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.GAMEPAD_API_UNAVAILABLE,
|
||||
"GamepadController.update() requires a source or a navigator.getGamepads global"
|
||||
);
|
||||
}
|
||||
this.needsUpdate = true;
|
||||
super.update();
|
||||
}
|
||||
|
||||
public getDeadzone(): number {
|
||||
return this.deadzone;
|
||||
}
|
||||
|
||||
public getPadIndex(): number {
|
||||
return this.padIndex;
|
||||
}
|
||||
|
||||
public getButtonMap(): GamepadButtonMap {
|
||||
return this.buttonMap;
|
||||
}
|
||||
|
||||
public getAxisMap(): GamepadAxisMap {
|
||||
return this.axisMap;
|
||||
}
|
||||
|
||||
protected override updateState(): void {
|
||||
// `update()` guards `source === undefined` already, so this method
|
||||
// is only reached when a source is configured.
|
||||
const source = this.source as GamepadSource;
|
||||
const pads = source();
|
||||
const pad = pads[this.padIndex];
|
||||
if (pad === null || pad === undefined) {
|
||||
this.handleDisconnect();
|
||||
return;
|
||||
}
|
||||
if (pad.connected === false) {
|
||||
this.handleDisconnect();
|
||||
return;
|
||||
}
|
||||
this.wasConnected = true;
|
||||
this.applyButtons(pad);
|
||||
this.applyAxes(pad);
|
||||
}
|
||||
|
||||
private handleDisconnect(): void {
|
||||
if (this.wasConnected) {
|
||||
this.zeroAll();
|
||||
this.wasConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private applyButtons(pad: GamepadLike): void {
|
||||
const updates: Partial<ControllerInput> = {};
|
||||
for (const [rawIndex, input] of Object.entries(this.buttonMap)) {
|
||||
const button = pad.buttons[Number(rawIndex)];
|
||||
if (button === undefined) {
|
||||
continue;
|
||||
}
|
||||
const analog =
|
||||
typeof button.value === "number"
|
||||
? button.value
|
||||
: button.pressed
|
||||
? 1
|
||||
: 0;
|
||||
updates[input as keyof ControllerInput] = Math.round(
|
||||
Math.max(0, Math.min(1, analog)) * 255
|
||||
);
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
this.setState(updates);
|
||||
}
|
||||
}
|
||||
|
||||
private applyAxes(pad: GamepadLike): void {
|
||||
const updates: Partial<ControllerInput> = {};
|
||||
for (const [rawIndex, binding] of Object.entries(this.axisMap)) {
|
||||
const raw = pad.axes[Number(rawIndex)];
|
||||
if (typeof raw !== "number" || Number.isNaN(raw)) {
|
||||
continue;
|
||||
}
|
||||
const clamped = Math.max(-1, Math.min(1, raw));
|
||||
const magnitude = Math.abs(clamped);
|
||||
const past = magnitude >= this.deadzone;
|
||||
if (binding.positive !== undefined) {
|
||||
updates[binding.positive] =
|
||||
past && clamped > 0 ? Math.round(magnitude * 255) : 0;
|
||||
}
|
||||
if (binding.negative !== undefined) {
|
||||
updates[binding.negative] =
|
||||
past && clamped < 0 ? Math.round(magnitude * 255) : 0;
|
||||
}
|
||||
if (binding.analog !== undefined) {
|
||||
updates[binding.analog] = Math.round(128 + clamped * 127.5);
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
this.setState(updates);
|
||||
}
|
||||
}
|
||||
|
||||
private zeroAll(): void {
|
||||
const updates: Partial<ControllerInput> = {};
|
||||
for (const input of Object.values(this.buttonMap)) {
|
||||
updates[input] = 0;
|
||||
}
|
||||
for (const binding of Object.values(this.axisMap)) {
|
||||
if (binding.positive !== undefined) updates[binding.positive] = 0;
|
||||
if (binding.negative !== undefined) updates[binding.negative] = 0;
|
||||
if (binding.analog !== undefined) updates[binding.analog] = 128;
|
||||
}
|
||||
this.setState(updates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { BaseControllerOptions } from "../base/base.types.js";
|
||||
import type { ControllerInput } from "../types/controller.types.js";
|
||||
|
||||
/**
|
||||
* Minimal structural view of a single Web Gamepad API entry.
|
||||
*
|
||||
* The adapter only ever reads `buttons[i].pressed` /
|
||||
* `buttons[i].value` and `axes[i]`. Anything broader is intentionally
|
||||
* left out of the contract so test doubles do not need to fabricate
|
||||
* vibration actuators, mapping strings, or connection metadata.
|
||||
*/
|
||||
export interface GamepadLike {
|
||||
readonly buttons: ReadonlyArray<{
|
||||
readonly pressed: boolean;
|
||||
readonly value?: number;
|
||||
}>;
|
||||
readonly axes: readonly number[];
|
||||
readonly connected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point for the Web Gamepad API.
|
||||
*
|
||||
* Returns the array of pads available at this tick. Entries may be
|
||||
* `null` for empty slots — exactly mirroring
|
||||
* `navigator.getGamepads()`.
|
||||
*/
|
||||
export type GamepadSource = () => readonly (GamepadLike | null | undefined)[];
|
||||
|
||||
/** Map from gamepad button index → `ControllerInput` key. */
|
||||
export type GamepadButtonMap = Readonly<Record<number, keyof ControllerInput>>;
|
||||
|
||||
/**
|
||||
* Map from gamepad axis index → `ControllerInput` keys for the
|
||||
* positive and negative halves of the axis.
|
||||
*
|
||||
* For example, axis 0 (left stick X) typically maps to
|
||||
* `{ positive: "right", negative: "left", analog: "lx" }`.
|
||||
*
|
||||
* - `positive` — input that receives `|v| * 255` when `v > deadzone`.
|
||||
* - `negative` — input that receives `|v| * 255` when `v < -deadzone`.
|
||||
* - `analog` — optional input that always receives the centred
|
||||
* `128 + v * 127.5` analog representation regardless of sign.
|
||||
*/
|
||||
export interface GamepadAxisBinding {
|
||||
positive?: keyof ControllerInput;
|
||||
negative?: keyof ControllerInput;
|
||||
analog?: keyof ControllerInput;
|
||||
}
|
||||
|
||||
/** Indexed map of axis bindings. */
|
||||
export type GamepadAxisMap = Readonly<Record<number, GamepadAxisBinding>>;
|
||||
|
||||
/**
|
||||
* Construction options for `GamepadController`.
|
||||
*
|
||||
* Extends `BaseControllerOptions` with the Web Gamepad API-specific
|
||||
* surface (source injection, button + axis maps, deadzone, pad
|
||||
* selection).
|
||||
*/
|
||||
export interface GamepadControllerOptions extends BaseControllerOptions {
|
||||
/**
|
||||
* Returns the array of pads available this tick. Defaults to
|
||||
* `navigator.getGamepads()` when present at construction. When
|
||||
* absent and not injected, `update()` throws
|
||||
* `ControllerError(GAMEPAD_API_UNAVAILABLE)`.
|
||||
*/
|
||||
source?: GamepadSource;
|
||||
/** Override the default standard-mapping button table. */
|
||||
buttonMap?: GamepadButtonMap;
|
||||
/** Override the default standard-mapping axis table. */
|
||||
axisMap?: GamepadAxisMap;
|
||||
/**
|
||||
* Analog axis deadzone in the `[0, 1)` range. Values with
|
||||
* absolute magnitude below this snap to centre. Defaults to
|
||||
* `0.1`.
|
||||
*/
|
||||
deadzone?: number;
|
||||
/**
|
||||
* Zero-based pad index this controller binds to. Defaults to
|
||||
* `0` — the first pad reported by the source.
|
||||
*/
|
||||
padIndex?: number;
|
||||
}
|
||||
13
packages/gaming/games/controller/src/gamepad/index.ts
Normal file
13
packages/gaming/games/controller/src/gamepad/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
DEFAULT_GAMEPAD_AXIS_MAP,
|
||||
DEFAULT_GAMEPAD_BUTTON_MAP,
|
||||
GamepadController,
|
||||
} from "./gamepad.js";
|
||||
export type {
|
||||
GamepadAxisBinding,
|
||||
GamepadAxisMap,
|
||||
GamepadButtonMap,
|
||||
GamepadControllerOptions,
|
||||
GamepadLike,
|
||||
GamepadSource,
|
||||
} from "./gamepad.types.js";
|
||||
77
packages/gaming/games/controller/src/index.ts
Normal file
77
packages/gaming/games/controller/src/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// @suaveplan/controller — public surface barrel.
|
||||
//
|
||||
// Foundation (`BaseController`, `InputEventEmitter`, telemetry, error codes,
|
||||
// types) + device adapters (`KeyboardController`, `GamepadController`) +
|
||||
// composition (`ControllerMerger`). `TouchController`/`MouseController`
|
||||
// remain deferred per openspec/changes/enterprise-controller (Priority 2).
|
||||
|
||||
export { BaseController } from "./base/base.js";
|
||||
export type {
|
||||
BaseControllerOptions,
|
||||
ControllerSnapshotState,
|
||||
InputBookkeeping,
|
||||
ThresholdMap,
|
||||
} from "./base/base.types.js";
|
||||
export {
|
||||
CONTROLLER_CODES,
|
||||
type ControllerCode,
|
||||
ControllerError,
|
||||
} from "./error-codes/index.js";
|
||||
export { InputEventEmitter } from "./events/events.js";
|
||||
export type {
|
||||
Disposer,
|
||||
InputEventEmitterApi,
|
||||
InputEventEmitterOptions,
|
||||
} from "./events/events.types.js";
|
||||
export {
|
||||
DEFAULT_GAMEPAD_AXIS_MAP,
|
||||
DEFAULT_GAMEPAD_BUTTON_MAP,
|
||||
GamepadController,
|
||||
} from "./gamepad/gamepad.js";
|
||||
export type {
|
||||
GamepadAxisBinding,
|
||||
GamepadAxisMap,
|
||||
GamepadButtonMap,
|
||||
GamepadControllerOptions,
|
||||
GamepadLike,
|
||||
GamepadSource,
|
||||
} from "./gamepad/gamepad.types.js";
|
||||
export {
|
||||
DEFAULT_KEYBOARD_KEY_MAP,
|
||||
KeyboardController,
|
||||
} from "./keyboard/keyboard.js";
|
||||
export type {
|
||||
EventTargetLike,
|
||||
KeyboardControllerOptions,
|
||||
KeyboardEventLike,
|
||||
KeyboardKeyMap,
|
||||
} from "./keyboard/keyboard.types.js";
|
||||
export { ControllerMerger } from "./merger/merger.js";
|
||||
export type {
|
||||
ControllerMergerOptions,
|
||||
MergerChildren,
|
||||
} from "./merger/merger.types.js";
|
||||
export {
|
||||
inputsCounter,
|
||||
patternMatchDurationHistogram,
|
||||
recordInput,
|
||||
recordPatternMatchDuration,
|
||||
recordStateChange,
|
||||
stateChangesCounter,
|
||||
} from "./telemetry/index.js";
|
||||
export type {
|
||||
ComboCallback,
|
||||
ControllerInput,
|
||||
ControllerSnapshot,
|
||||
ControllerState,
|
||||
InputChangeCallback,
|
||||
InputCounterAttributes,
|
||||
InputThresholds,
|
||||
StateChangeCallback,
|
||||
StateCounterAttributes,
|
||||
Vector2D,
|
||||
} from "./types/controller.types.js";
|
||||
export {
|
||||
CONTROLLER_INPUT_KEYS,
|
||||
createDefaultControllerInput,
|
||||
} from "./types/keys.js";
|
||||
7
packages/gaming/games/controller/src/keyboard/index.ts
Normal file
7
packages/gaming/games/controller/src/keyboard/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { DEFAULT_KEYBOARD_KEY_MAP, KeyboardController } from "./keyboard.js";
|
||||
export type {
|
||||
EventTargetLike,
|
||||
KeyboardControllerOptions,
|
||||
KeyboardEventLike,
|
||||
KeyboardKeyMap,
|
||||
} from "./keyboard.types.js";
|
||||
134
packages/gaming/games/controller/src/keyboard/keyboard.md
Normal file
134
packages/gaming/games/controller/src/keyboard/keyboard.md
Normal 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()`.
|
||||
331
packages/gaming/games/controller/src/keyboard/keyboard.test.ts
Normal file
331
packages/gaming/games/controller/src/keyboard/keyboard.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { DEFAULT_KEYBOARD_KEY_MAP, KeyboardController } from "./keyboard.js";
|
||||
import type { EventTargetLike, KeyboardEventLike } from "./keyboard.types.js";
|
||||
|
||||
interface MockTarget extends EventTargetLike {
|
||||
readonly down: Set<(e: KeyboardEventLike) => void>;
|
||||
readonly up: Set<(e: KeyboardEventLike) => void>;
|
||||
dispatchDown(event: KeyboardEventLike): void;
|
||||
dispatchUp(event: KeyboardEventLike): void;
|
||||
}
|
||||
|
||||
function createTarget(): MockTarget {
|
||||
const down = new Set<(e: KeyboardEventLike) => void>();
|
||||
const up = new Set<(e: KeyboardEventLike) => void>();
|
||||
return {
|
||||
down,
|
||||
up,
|
||||
addEventListener(type, listener) {
|
||||
if (type === "keydown") down.add(listener);
|
||||
else up.add(listener);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (type === "keydown") down.delete(listener);
|
||||
else up.delete(listener);
|
||||
},
|
||||
dispatchDown(event) {
|
||||
for (const cb of down) cb(event);
|
||||
},
|
||||
dispatchUp(event) {
|
||||
for (const cb of up) cb(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("KeyboardController — construction", () => {
|
||||
it("attaches automatically by default", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
expect(target.down.size).toBe(1);
|
||||
expect(target.up.size).toBe(1);
|
||||
kb.destroy();
|
||||
expect(target.down.size).toBe(0);
|
||||
expect(target.up.size).toBe(0);
|
||||
});
|
||||
|
||||
it("respects autoAttach: false", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target, autoAttach: false });
|
||||
expect(target.down.size).toBe(0);
|
||||
kb.attach();
|
||||
expect(target.down.size).toBe(1);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS when target lacks addEventListener", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new KeyboardController({
|
||||
target: { foo: 1 } as unknown as EventTargetLike,
|
||||
})
|
||||
).toThrowError(ControllerError);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS for pollRate out of range", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = (() => {
|
||||
try {
|
||||
return new KeyboardController({ pollRate: 0 });
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})();
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect((err as ControllerError).code).toBe(
|
||||
CONTROLLER_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_KEY_MAP for unknown input target", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const target = createTarget();
|
||||
const err = (() => {
|
||||
try {
|
||||
return new KeyboardController({
|
||||
target,
|
||||
keyMap: { KeyZ: "notAnInput" as never },
|
||||
});
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})();
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect((err as ControllerError).code).toBe(
|
||||
CONTROLLER_CODES.INVALID_KEY_MAP
|
||||
);
|
||||
expect((err as ControllerError).context).toEqual({
|
||||
entry: ["KeyZ", "notAnInput"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws KEYBOARD_TARGET_MISSING when no target resolvable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = (() => {
|
||||
try {
|
||||
return new KeyboardController({ autoAttach: true });
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
})();
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
expect((err as ControllerError).code).toBe(
|
||||
CONTROLLER_CODES.KEYBOARD_TARGET_MISSING
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves globalThis.window when present", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const g = globalThis as { window?: unknown };
|
||||
const prior = g.window;
|
||||
g.window = target;
|
||||
try {
|
||||
const kb = new KeyboardController();
|
||||
expect(target.down.size).toBe(1);
|
||||
kb.destroy();
|
||||
} finally {
|
||||
if (prior === undefined) delete g.window;
|
||||
else g.window = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the resolved key map and default map", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
expect(kb.getKeyMap().KeyW).toBe("up");
|
||||
expect(DEFAULT_KEYBOARD_KEY_MAP.ArrowUp).toBe("up");
|
||||
kb.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyboardController — key events", () => {
|
||||
it("maps keydown via event.code", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
target.dispatchDown({ code: "KeyW" });
|
||||
expect(kb.getValue("up")).toBe(255);
|
||||
target.dispatchUp({ code: "KeyW" });
|
||||
expect(kb.getValue("up")).toBe(0);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("falls back to event.key when code is unknown", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
target.dispatchDown({ code: "Unknown", key: "w" });
|
||||
expect(kb.getValue("up")).toBe(255);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("ignores unmapped keys and missing identifiers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
target.dispatchDown({ code: "NotAKey" });
|
||||
target.dispatchDown({});
|
||||
expect(kb.getValue("up")).toBe(0);
|
||||
target.dispatchUp({ code: "NotAKey" });
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("ignores repeated keydown events", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
let changes = 0;
|
||||
kb.onInput("up", () => {
|
||||
changes += 1;
|
||||
});
|
||||
target.dispatchDown({ code: "KeyW" });
|
||||
target.dispatchDown({ code: "KeyW" });
|
||||
target.dispatchDown({ code: "KeyW", repeat: true });
|
||||
expect(changes).toBe(1);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("ignores keyup without prior keydown", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
target.dispatchUp({ code: "KeyW" });
|
||||
expect(kb.getValue("up")).toBe(0);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("calls preventDefault when configured", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target, preventDefault: true });
|
||||
let prevented = 0;
|
||||
target.dispatchDown({
|
||||
code: "KeyW",
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
});
|
||||
target.dispatchUp({
|
||||
code: "KeyW",
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
});
|
||||
expect(prevented).toBe(2);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("does not call preventDefault when not configured", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
let prevented = 0;
|
||||
target.dispatchDown({
|
||||
code: "KeyW",
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
});
|
||||
// missing preventDefault on event should not crash
|
||||
target.dispatchUp({ code: "KeyW" });
|
||||
expect(prevented).toBe(0);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("getPressedKeys reflects current state", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
target.dispatchDown({ code: "KeyW" });
|
||||
target.dispatchDown({ code: "KeyA" });
|
||||
expect(kb.getPressedKeys().sort()).toEqual(["KeyA", "KeyW"]);
|
||||
target.dispatchUp({ code: "KeyW" });
|
||||
expect(kb.getPressedKeys()).toEqual(["KeyA"]);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("no-ops after destroy", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
kb.destroy();
|
||||
target.dispatchDown({ code: "KeyW" });
|
||||
expect(kb.getValue("up")).toBe(0);
|
||||
// second destroy is idempotent
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("attach is idempotent and noop after destroy", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
kb.attach();
|
||||
expect(target.down.size).toBe(1);
|
||||
kb.destroy();
|
||||
kb.attach();
|
||||
expect(target.down.size).toBe(0);
|
||||
});
|
||||
|
||||
it("detach is idempotent and tolerates missing target", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target, autoAttach: false });
|
||||
kb.detach();
|
||||
expect(target.down.size).toBe(0);
|
||||
kb.attach();
|
||||
kb.detach();
|
||||
kb.detach();
|
||||
expect(target.down.size).toBe(0);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("accepts injected clock, idGenerator, pollRate and strict", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const target = createTarget();
|
||||
let t = 0;
|
||||
const kb = new KeyboardController({
|
||||
target,
|
||||
clock: () => t,
|
||||
idGenerator: () => "kb-1",
|
||||
pollRate: 30,
|
||||
strict: true,
|
||||
});
|
||||
expect(kb.id).toBe("kb-1");
|
||||
target.dispatchDown({ code: "KeyW" });
|
||||
t += 100;
|
||||
kb.update();
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("uses event.key when event.code is undefined", { timeout: 5000 }, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
target.dispatchDown({ key: "w" });
|
||||
expect(kb.getValue("up")).toBe(255);
|
||||
target.dispatchUp({ key: "w" });
|
||||
expect(kb.getValue("up")).toBe(0);
|
||||
kb.destroy();
|
||||
});
|
||||
|
||||
it("ignores key events delivered after destroy via retained listener", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const target = createTarget();
|
||||
const kb = new KeyboardController({ target });
|
||||
const listener = [...target.down][0];
|
||||
if (listener === undefined) {
|
||||
throw new Error("expected one keydown listener");
|
||||
}
|
||||
kb.destroy();
|
||||
listener({ code: "KeyW" });
|
||||
expect(kb.getValue("up")).toBe(0);
|
||||
});
|
||||
});
|
||||
279
packages/gaming/games/controller/src/keyboard/keyboard.ts
Normal file
279
packages/gaming/games/controller/src/keyboard/keyboard.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import z from "zod";
|
||||
import { BaseController } from "../base/base.js";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { ControllerInput } from "../types/controller.types.js";
|
||||
import { CONTROLLER_INPUT_KEYS } from "../types/keys.js";
|
||||
import type {
|
||||
EventTargetLike,
|
||||
KeyboardControllerOptions,
|
||||
KeyboardEventLike,
|
||||
KeyboardKeyMap,
|
||||
} from "./keyboard.types.js";
|
||||
|
||||
const KeyboardOptionsSchema = z
|
||||
.object({
|
||||
clock: z
|
||||
.custom<NonNullable<KeyboardControllerOptions["clock"]>>(
|
||||
(v) => typeof v === "function"
|
||||
)
|
||||
.optional(),
|
||||
idGenerator: z
|
||||
.custom<NonNullable<KeyboardControllerOptions["idGenerator"]>>(
|
||||
(v) => typeof v === "function"
|
||||
)
|
||||
.optional(),
|
||||
pollRate: z.number().int().min(1).max(1000).optional(),
|
||||
device: z.string().min(1).optional(),
|
||||
strict: z.boolean().optional(),
|
||||
target: z
|
||||
.custom<EventTargetLike>(
|
||||
(v) =>
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
typeof (v as EventTargetLike).addEventListener ===
|
||||
"function" &&
|
||||
typeof (v as EventTargetLike).removeEventListener ===
|
||||
"function"
|
||||
)
|
||||
.optional(),
|
||||
keyMap: z.record(z.string(), z.string()).optional(),
|
||||
autoAttach: z.boolean().optional(),
|
||||
preventDefault: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const VALID_INPUT_KEYS = new Set<string>(
|
||||
CONTROLLER_INPUT_KEYS as readonly string[]
|
||||
);
|
||||
|
||||
/** WASD + arrows + face buttons + triggers + function digits. */
|
||||
export const DEFAULT_KEYBOARD_KEY_MAP: KeyboardKeyMap = Object.freeze({
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
KeyW: "up",
|
||||
w: "up",
|
||||
W: "up",
|
||||
KeyS: "down",
|
||||
s: "down",
|
||||
S: "down",
|
||||
KeyA: "left",
|
||||
a: "left",
|
||||
A: "left",
|
||||
KeyD: "right",
|
||||
d: "right",
|
||||
D: "right",
|
||||
KeyZ: "a",
|
||||
z: "a",
|
||||
Z: "a",
|
||||
KeyX: "b",
|
||||
x: "b",
|
||||
X: "b",
|
||||
KeyC: "x",
|
||||
c: "x",
|
||||
C: "x",
|
||||
KeyV: "y",
|
||||
v: "y",
|
||||
V: "y",
|
||||
KeyQ: "t1",
|
||||
q: "t1",
|
||||
Q: "t1",
|
||||
KeyE: "t2",
|
||||
e: "t2",
|
||||
E: "t2",
|
||||
KeyR: "t3",
|
||||
r: "t3",
|
||||
R: "t3",
|
||||
KeyT: "t4",
|
||||
t: "t4",
|
||||
T: "t4",
|
||||
Digit1: "f1",
|
||||
"1": "f1",
|
||||
Digit2: "f2",
|
||||
"2": "f2",
|
||||
Digit3: "f3",
|
||||
"3": "f3",
|
||||
Digit4: "f4",
|
||||
"4": "f4",
|
||||
});
|
||||
|
||||
/**
|
||||
* Browser-tier keyboard adapter that drives the canonical
|
||||
* `ControllerInput` shape from `keydown`/`keyup` events.
|
||||
*
|
||||
* The adapter never touches a global at module-load time; the event
|
||||
* target is resolved lazily by `attach()`. Inject an
|
||||
* `EventTargetLike` in tests to drive the adapter from synthetic
|
||||
* events without jsdom.
|
||||
*/
|
||||
export class KeyboardController extends BaseController {
|
||||
private readonly keyMap: KeyboardKeyMap;
|
||||
private readonly preventDefault: boolean;
|
||||
private readonly target: EventTargetLike | undefined;
|
||||
private readonly pressedKeys = new Set<string>();
|
||||
private readonly boundKeyDown: (event: KeyboardEventLike) => void;
|
||||
private readonly boundKeyUp: (event: KeyboardEventLike) => void;
|
||||
private isAttached = false;
|
||||
|
||||
public constructor(options: KeyboardControllerOptions = {}) {
|
||||
const parsed = KeyboardOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_OPTIONS,
|
||||
"KeyboardController received invalid options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const baseOptions: Partial<KeyboardControllerOptions> = {
|
||||
device: options.device ?? "keyboard",
|
||||
};
|
||||
if (options.clock !== undefined) {
|
||||
baseOptions.clock = options.clock;
|
||||
}
|
||||
if (options.idGenerator !== undefined) {
|
||||
baseOptions.idGenerator = options.idGenerator;
|
||||
}
|
||||
if (options.pollRate !== undefined) {
|
||||
baseOptions.pollRate = options.pollRate;
|
||||
}
|
||||
if (options.strict !== undefined) {
|
||||
baseOptions.strict = options.strict;
|
||||
}
|
||||
super(baseOptions);
|
||||
this.keyMap = KeyboardController.validateKeyMap(
|
||||
options.keyMap ?? DEFAULT_KEYBOARD_KEY_MAP
|
||||
);
|
||||
this.preventDefault = options.preventDefault ?? false;
|
||||
this.target =
|
||||
options.target ?? KeyboardController.resolveGlobalTarget();
|
||||
this.boundKeyDown = (event) => {
|
||||
this.handleKeyEvent(event, true);
|
||||
};
|
||||
this.boundKeyUp = (event) => {
|
||||
this.handleKeyEvent(event, false);
|
||||
};
|
||||
if ((options.autoAttach ?? true) === true) {
|
||||
this.attach();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind `keydown`/`keyup` listeners to the resolved target. Safe to
|
||||
* call repeatedly — subsequent calls are no-ops until `detach()`.
|
||||
*/
|
||||
public attach(): void {
|
||||
if (this.isAttached || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
if (this.target === undefined) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.KEYBOARD_TARGET_MISSING,
|
||||
"KeyboardController.attach() called with no target",
|
||||
{ context: { target: this.target } }
|
||||
);
|
||||
}
|
||||
this.target.addEventListener("keydown", this.boundKeyDown);
|
||||
this.target.addEventListener("keyup", this.boundKeyUp);
|
||||
this.isAttached = true;
|
||||
}
|
||||
|
||||
/** Idempotent detach; safe across destroy. */
|
||||
public detach(): void {
|
||||
if (!this.isAttached || this.target === undefined) {
|
||||
this.isAttached = false;
|
||||
return;
|
||||
}
|
||||
this.target.removeEventListener("keydown", this.boundKeyDown);
|
||||
this.target.removeEventListener("keyup", this.boundKeyUp);
|
||||
this.isAttached = false;
|
||||
}
|
||||
|
||||
/** Snapshot of physical keys currently held down. */
|
||||
public getPressedKeys(): readonly string[] {
|
||||
return Array.from(this.pressedKeys);
|
||||
}
|
||||
|
||||
/** Resolved key → input map (immutable). */
|
||||
public getKeyMap(): KeyboardKeyMap {
|
||||
return this.keyMap;
|
||||
}
|
||||
|
||||
protected override updateState(): void {
|
||||
/* event-driven; nothing to poll */
|
||||
}
|
||||
|
||||
public override destroy(): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.detach();
|
||||
this.pressedKeys.clear();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
private handleKeyEvent(event: KeyboardEventLike, isDown: boolean): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const keyId = event.code ?? event.key;
|
||||
if (keyId === undefined) {
|
||||
return;
|
||||
}
|
||||
const input =
|
||||
(event.code !== undefined ? this.keyMap[event.code] : undefined) ??
|
||||
(event.key !== undefined ? this.keyMap[event.key] : undefined);
|
||||
if (input === undefined) {
|
||||
return;
|
||||
}
|
||||
if (this.preventDefault && typeof event.preventDefault === "function") {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (isDown) {
|
||||
if (event.repeat === true) {
|
||||
return;
|
||||
}
|
||||
if (this.pressedKeys.has(keyId)) {
|
||||
return;
|
||||
}
|
||||
this.pressedKeys.add(keyId);
|
||||
this.setState({ [input]: 255 } as Partial<ControllerInput>);
|
||||
return;
|
||||
}
|
||||
if (!this.pressedKeys.has(keyId)) {
|
||||
return;
|
||||
}
|
||||
this.pressedKeys.delete(keyId);
|
||||
this.setState({ [input]: 0 } as Partial<ControllerInput>);
|
||||
}
|
||||
|
||||
private static validateKeyMap(map: KeyboardKeyMap): KeyboardKeyMap {
|
||||
for (const [key, input] of Object.entries(map)) {
|
||||
if (!VALID_INPUT_KEYS.has(input as string)) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_KEY_MAP,
|
||||
`KeyboardController keyMap entry "${key}" targets unknown input "${input}"`,
|
||||
{ context: { entry: [key, input] } }
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ...map });
|
||||
}
|
||||
|
||||
private static resolveGlobalTarget(): EventTargetLike | undefined {
|
||||
const w = (globalThis as unknown as { window?: EventTargetLike })
|
||||
.window;
|
||||
if (
|
||||
w !== undefined &&
|
||||
typeof w.addEventListener === "function" &&
|
||||
typeof w.removeEventListener === "function"
|
||||
) {
|
||||
return w;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BaseControllerOptions } from "../base/base.types.js";
|
||||
import type { ControllerInput } from "../types/controller.types.js";
|
||||
|
||||
/**
|
||||
* Minimal structural view of an `EventTarget` that the
|
||||
* `KeyboardController` calls into. Mirrors the subset of the DOM
|
||||
* `EventTarget` interface we actually need so the adapter can be
|
||||
* driven from a test double or a non-DOM emitter without requiring
|
||||
* jsdom or any browser global.
|
||||
*/
|
||||
export interface EventTargetLike {
|
||||
addEventListener(
|
||||
type: "keydown" | "keyup",
|
||||
listener: (event: KeyboardEventLike) => void
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "keydown" | "keyup",
|
||||
listener: (event: KeyboardEventLike) => void
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural view of a `KeyboardEvent`.
|
||||
*
|
||||
* The adapter only ever touches `code`, `key`, `repeat`, and
|
||||
* `preventDefault`. Anything broader would force a DOM dependency on
|
||||
* universal consumers; this shape is satisfied by both the real
|
||||
* `KeyboardEvent` and the test doubles in `keyboard.test.ts`.
|
||||
*/
|
||||
export interface KeyboardEventLike {
|
||||
code?: string;
|
||||
key?: string;
|
||||
repeat?: boolean;
|
||||
preventDefault?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map from a keyboard `code` (e.g. `"KeyW"`) or `key` (e.g. `"w"`) to
|
||||
* the `ControllerInput` field the press should drive.
|
||||
*
|
||||
* Lookups consult `code` first, then `key`. Both are case-sensitive.
|
||||
*/
|
||||
export type KeyboardKeyMap = Readonly<Record<string, keyof ControllerInput>>;
|
||||
|
||||
/**
|
||||
* Construction options accepted by `KeyboardController`.
|
||||
*
|
||||
* Extends `BaseControllerOptions` with the keyboard-specific surface
|
||||
* (target injection, key-map override, auto-attach toggle).
|
||||
*/
|
||||
export interface KeyboardControllerOptions extends BaseControllerOptions {
|
||||
/**
|
||||
* Event target the adapter binds `keydown`/`keyup` to. Defaults to
|
||||
* `globalThis.window` when present. When absent and not injected,
|
||||
* `attach()` throws `ControllerError(KEYBOARD_TARGET_MISSING)`.
|
||||
*/
|
||||
target?: EventTargetLike;
|
||||
/**
|
||||
* Override the default key → input map. The provided object is
|
||||
* deep-frozen on construction and validated against
|
||||
* `CONTROLLER_INPUT_KEYS`; an invalid entry throws
|
||||
* `ControllerError(INVALID_KEY_MAP)`.
|
||||
*/
|
||||
keyMap?: KeyboardKeyMap;
|
||||
/**
|
||||
* When `true` (the default), the constructor calls `attach()`
|
||||
* automatically. Set to `false` if you need to defer binding (e.g.
|
||||
* SSR rehydration where the target only becomes available after
|
||||
* mount).
|
||||
*/
|
||||
autoAttach?: boolean;
|
||||
/**
|
||||
* When `true`, the adapter calls `event.preventDefault()` on every
|
||||
* mapped key event. Defaults to `false` so callers opt in to
|
||||
* suppressing default browser behaviour (e.g. spacebar scroll).
|
||||
*/
|
||||
preventDefault?: boolean;
|
||||
}
|
||||
5
packages/gaming/games/controller/src/merger/index.ts
Normal file
5
packages/gaming/games/controller/src/merger/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ControllerMerger } from "./merger.js";
|
||||
export type {
|
||||
ControllerMergerOptions,
|
||||
MergerChildren,
|
||||
} from "./merger.types.js";
|
||||
169
packages/gaming/games/controller/src/merger/merger.md
Normal file
169
packages/gaming/games/controller/src/merger/merger.md
Normal 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()`.
|
||||
413
packages/gaming/games/controller/src/merger/merger.test.ts
Normal file
413
packages/gaming/games/controller/src/merger/merger.test.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import { BaseController } from "../base/base.js";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { ControllerInput } from "../types/controller.types.js";
|
||||
import { CONTROLLER_INPUT_KEYS } from "../types/keys.js";
|
||||
import { ControllerMerger } from "./merger.js";
|
||||
|
||||
/** Concrete `BaseController` exposing `emitCombo` and direct state poke. */
|
||||
class FakeChild extends BaseController {
|
||||
public constructor(
|
||||
device = "fake",
|
||||
opts: ConstructorParameters<typeof BaseController>[0] = {}
|
||||
) {
|
||||
super({ device, ...opts });
|
||||
}
|
||||
public emitComboPublic(combo: string[]): void {
|
||||
this.eventEmitter.emitCombo(combo);
|
||||
}
|
||||
public override updateState(): void {
|
||||
/* no-op for tests */
|
||||
}
|
||||
}
|
||||
|
||||
function makeClock(): {
|
||||
clock: Clock;
|
||||
advance: (ms: number) => void;
|
||||
now: () => number;
|
||||
} {
|
||||
let t = 0;
|
||||
return {
|
||||
clock: () => t,
|
||||
advance: (ms) => {
|
||||
t += ms;
|
||||
},
|
||||
now: () => t,
|
||||
};
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
const makeIdGen =
|
||||
(prefix: string): IdGenerator =>
|
||||
() =>
|
||||
`${prefix}-${++idCounter}`;
|
||||
|
||||
describe("ControllerMerger — construction", () => {
|
||||
it("defaults device to 'merger' and exposes 0 children", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new ControllerMerger();
|
||||
expect(m.childCount).toBe(0);
|
||||
expect(m.getChildren()).toEqual([]);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("seeds children via options", { timeout: 5000 }, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const b = new FakeChild("b", { idGenerator: makeIdGen("b") });
|
||||
const m = new ControllerMerger({ children: [a, b] });
|
||||
expect(m.childCount).toBe(2);
|
||||
expect(m.getChildren()).toEqual([a, b]);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("rejects pollRate via Zod (inherited boundary)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new ControllerMerger({ pollRate: 0 })).toThrow(
|
||||
ControllerError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerMerger — addController", () => {
|
||||
it("attaches a child and captures its current state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new FakeChild("c", { idGenerator: makeIdGen("c") });
|
||||
c.setState({ up: 100 });
|
||||
const m = new ControllerMerger();
|
||||
m.addController(c);
|
||||
m.update();
|
||||
expect(m.getValue("up")).toBe(100);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("throws DUPLICATE_CHILD on the same instance", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new FakeChild("c", { idGenerator: makeIdGen("c") });
|
||||
const m = new ControllerMerger({ children: [c] });
|
||||
try {
|
||||
m.addController(c);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
if (err instanceof ControllerError) {
|
||||
expect(err.code).toBe(CONTROLLER_CODES.DUPLICATE_CHILD);
|
||||
expect((err.context as { id: string }).id).toBe(c.id);
|
||||
}
|
||||
}
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("throws DUPLICATE_CHILD on id collision (distinct instance)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fixedId: IdGenerator = () => "shared";
|
||||
const c1 = new FakeChild("c1", { idGenerator: fixedId });
|
||||
const c2 = new FakeChild("c2", { idGenerator: fixedId });
|
||||
const m = new ControllerMerger();
|
||||
m.addController(c1);
|
||||
expect(() => m.addController(c2)).toThrow(ControllerError);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("is a no-op on destroyed merger", { timeout: 5000 }, () => {
|
||||
const m = new ControllerMerger();
|
||||
m.destroy();
|
||||
const c = new FakeChild("c", { idGenerator: makeIdGen("c") });
|
||||
expect(() => m.addController(c)).not.toThrow();
|
||||
expect(m.childCount).toBe(0);
|
||||
c.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerMerger — removeController", () => {
|
||||
it("detaches a child and drops its snapshot", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const a = new FakeChild("a", { clock, idGenerator: makeIdGen("a") });
|
||||
const b = new FakeChild("b", { clock, idGenerator: makeIdGen("b") });
|
||||
a.setState({ up: 200 });
|
||||
b.setState({ up: 50 });
|
||||
const m = new ControllerMerger({ clock, children: [a, b] });
|
||||
m.update();
|
||||
expect(m.getValue("up")).toBe(200);
|
||||
m.removeController(a);
|
||||
advance(100);
|
||||
m.update();
|
||||
expect(m.getValue("up")).toBe(50);
|
||||
expect(m.childCount).toBe(1);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("ignores future events from a detached child", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ children: [a] });
|
||||
m.removeController(a);
|
||||
a.setState({ down: 200 });
|
||||
m.update();
|
||||
expect(m.getValue("down")).toBe(0);
|
||||
a.destroy();
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("throws UNKNOWN_CHILD when child is not attached", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const stray = new FakeChild("s", { idGenerator: makeIdGen("s") });
|
||||
const m = new ControllerMerger();
|
||||
try {
|
||||
m.removeController(stray);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ControllerError);
|
||||
if (err instanceof ControllerError) {
|
||||
expect(err.code).toBe(CONTROLLER_CODES.UNKNOWN_CHILD);
|
||||
}
|
||||
}
|
||||
stray.destroy();
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("is a no-op on destroyed merger", { timeout: 5000 }, () => {
|
||||
const c = new FakeChild("c", { idGenerator: makeIdGen("c") });
|
||||
const m = new ControllerMerger({ children: [c] });
|
||||
m.destroy();
|
||||
expect(() => m.removeController(c)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerMerger — merge reduction", () => {
|
||||
it("takes per-field max across children", { timeout: 5000 }, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const b = new FakeChild("b", { idGenerator: makeIdGen("b") });
|
||||
a.setState({ up: 80, left: 200, a: 0 });
|
||||
b.setState({ up: 150, left: 50, a: 255 });
|
||||
const m = new ControllerMerger({ children: [a, b] });
|
||||
m.update();
|
||||
expect(m.getValue("up")).toBe(150);
|
||||
expect(m.getValue("left")).toBe(200);
|
||||
expect(m.getValue("a")).toBe(255);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("iterates fields in CONTROLLER_INPUT_KEYS order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ children: [a] });
|
||||
const partial: Partial<ControllerInput> = {};
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
partial[key] = 10;
|
||||
}
|
||||
a.setState(partial);
|
||||
m.update();
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
expect(m.getValue(key)).toBe(10);
|
||||
}
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("recomputes when a child fires onAnyInput post-construction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const a = new FakeChild("a", { clock, idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ clock, children: [a] });
|
||||
m.update();
|
||||
expect(m.getValue("right")).toBe(0);
|
||||
advance(100);
|
||||
a.setState({ right: 222 });
|
||||
m.update();
|
||||
expect(m.getValue("right")).toBe(222);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("emits onAnyInput on the merger when merged state changes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ children: [a] });
|
||||
let calls = 0;
|
||||
m.onAnyInput(() => {
|
||||
calls += 1;
|
||||
});
|
||||
a.setState({ up: 100 });
|
||||
m.update();
|
||||
expect(calls).toBeGreaterThan(0);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("emits onInput per changed field", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const a = new FakeChild("a", { clock, idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ clock, children: [a] });
|
||||
let upCalls = 0;
|
||||
m.onInput("up", () => {
|
||||
upCalls += 1;
|
||||
});
|
||||
a.setState({ up: 100 });
|
||||
m.update();
|
||||
expect(upCalls).toBe(1);
|
||||
advance(100);
|
||||
a.setState({ up: 150 });
|
||||
m.update();
|
||||
expect(upCalls).toBe(2);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("update with no changes clears needsUpdate", { timeout: 5000 }, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const a = new FakeChild("a", { clock, idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ clock, children: [a] });
|
||||
m.update();
|
||||
advance(100);
|
||||
// Second update with no child change → exercises the !changed branch.
|
||||
(m as unknown as { needsUpdate: boolean }).needsUpdate = true;
|
||||
m.update();
|
||||
advance(100);
|
||||
m.update(); // already cleared; this re-exercises the !needsUpdate branch
|
||||
expect(m.getValue("up")).toBe(0);
|
||||
m.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerMerger — combo forwarding", () => {
|
||||
it("re-emits combos from any child", { timeout: 5000 }, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const b = new FakeChild("b", { idGenerator: makeIdGen("b") });
|
||||
const m = new ControllerMerger({ children: [a, b] });
|
||||
const seen: string[][] = [];
|
||||
m.onCombo((c) => seen.push(c));
|
||||
a.emitComboPublic(["up", "a"]);
|
||||
b.emitComboPublic(["down", "b"]);
|
||||
expect(seen).toEqual([
|
||||
["up", "a"],
|
||||
["down", "b"],
|
||||
]);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("ignores child combo after merger destroyed", { timeout: 5000 }, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ children: [a] });
|
||||
let calls = 0;
|
||||
m.onCombo(() => {
|
||||
calls += 1;
|
||||
});
|
||||
// Capture a is still alive so its onCombo subscribers still get called
|
||||
// — but the merger destroyed itself, so the forwarder must early-out.
|
||||
// We invoke the forwarder directly by re-attaching after destroy.
|
||||
m.destroy();
|
||||
// Listeners are cleared by destroy, so even if the underlying handler
|
||||
// ran, no consumer-visible event fires. This asserts the destroyed
|
||||
// guard path on the forwarder by ensuring no exception is thrown.
|
||||
expect(() => a.emitComboPublic(["x"])).not.toThrow();
|
||||
expect(calls).toBe(0);
|
||||
a.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerMerger — state change forwarding guard", () => {
|
||||
it("does not capture child state once merger is destroyed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ children: [a] });
|
||||
m.destroy();
|
||||
// Child still alive but merger guards inside the listener; a.setState
|
||||
// must not throw, and the merger snapshot must not exist anymore.
|
||||
expect(() => a.setState({ up: 100 })).not.toThrow();
|
||||
a.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerMerger — pollRate gating", () => {
|
||||
it("skips merge work inside the pollRate window", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { clock, advance } = makeClock();
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({
|
||||
clock,
|
||||
pollRate: 10, // 100 ms window
|
||||
children: [a],
|
||||
});
|
||||
a.setState({ up: 100 });
|
||||
m.update();
|
||||
expect(m.getValue("up")).toBe(100);
|
||||
|
||||
a.setState({ up: 200 });
|
||||
// No time advanced → gated; merger value frozen.
|
||||
m.update();
|
||||
expect(m.getValue("up")).toBe(100);
|
||||
|
||||
advance(150);
|
||||
m.update();
|
||||
expect(m.getValue("up")).toBe(200);
|
||||
m.destroy();
|
||||
});
|
||||
|
||||
it("update is a no-op on destroyed merger", { timeout: 5000 }, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ children: [a] });
|
||||
m.destroy();
|
||||
expect(() => m.update()).not.toThrow();
|
||||
});
|
||||
|
||||
it("ignores recursive update calls", { timeout: 5000 }, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const m = new ControllerMerger({ children: [a] });
|
||||
let calls = 0;
|
||||
m.onInput("up", () => {
|
||||
calls += 1;
|
||||
// Recursive update during emit must early-out via isMerging.
|
||||
m.update();
|
||||
});
|
||||
a.setState({ up: 100 });
|
||||
m.update();
|
||||
expect(calls).toBe(1);
|
||||
m.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerMerger — destroy", () => {
|
||||
it("destroy is idempotent", { timeout: 5000 }, () => {
|
||||
const m = new ControllerMerger();
|
||||
m.destroy();
|
||||
expect(() => m.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it("destroys every attached child", { timeout: 5000 }, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
const b = new FakeChild("b", { idGenerator: makeIdGen("b") });
|
||||
const m = new ControllerMerger({ children: [a, b] });
|
||||
m.destroy();
|
||||
// Children are destroyed → setState in non-strict mode is silent
|
||||
// and their state has been zeroed.
|
||||
expect(a.getValue("up")).toBe(0);
|
||||
expect(b.getValue("up")).toBe(0);
|
||||
// The child id-keyed maps were cleared (no internal leak).
|
||||
expect(m.childCount).toBe(0);
|
||||
});
|
||||
|
||||
it("clears lastMerged so post-destroy snapshot reads zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
|
||||
a.setState({ up: 200 });
|
||||
const m = new ControllerMerger({ children: [a] });
|
||||
m.update();
|
||||
m.destroy();
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
expect(m.getValue(key)).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
214
packages/gaming/games/controller/src/merger/merger.ts
Normal file
214
packages/gaming/games/controller/src/merger/merger.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { BaseController } from "../base/base.js";
|
||||
import {
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { ControllerInput } from "../types/controller.types.js";
|
||||
import { CONTROLLER_INPUT_KEYS } from "../types/keys.js";
|
||||
import type {
|
||||
ControllerMergerOptions,
|
||||
MergerChildren,
|
||||
} from "./merger.types.js";
|
||||
|
||||
/**
|
||||
* Multi-source controller that fuses N `BaseController` children into a
|
||||
* single `ControllerInput` via per-field max-value reduction.
|
||||
*
|
||||
* Children are walked in **stable insertion order** so the reduction is
|
||||
* deterministic for byte-equality assertions. Each child is observed
|
||||
* through its public `onAnyInput` (state snapshots) and `onCombo`
|
||||
* (combo forwarding) listeners; both disposers are tracked and torn down
|
||||
* on `removeController()` or `destroy()`.
|
||||
*
|
||||
* The merger extends `BaseController` so it inherits the typed event
|
||||
* emitter, threshold/deadzone pipeline, OTel telemetry, and lifecycle
|
||||
* guards. Children remain owned by their original creators — calling
|
||||
* `destroy()` on the merger destroys each child as well, matching the
|
||||
* "merger owns the wiring" lifecycle contract.
|
||||
*/
|
||||
export class ControllerMerger extends BaseController {
|
||||
private readonly children: BaseController[] = [];
|
||||
private readonly childDisposers = new Map<string, Array<() => void>>();
|
||||
private readonly childStates = new Map<string, ControllerInput>();
|
||||
private readonly lastMerged: ControllerInput;
|
||||
private isMerging = false;
|
||||
|
||||
public constructor(options: ControllerMergerOptions = {}) {
|
||||
const { children, ...rest } = options;
|
||||
super({ device: "merger", ...rest });
|
||||
this.lastMerged = this.getState();
|
||||
for (const child of children ?? []) {
|
||||
this.addController(child);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a child controller. Duplicates (same instance or colliding
|
||||
* `id`) throw `CONTROLLER.DUPLICATE_CHILD`. The child's current state
|
||||
* is captured immediately so the next `update()` reflects it.
|
||||
*/
|
||||
public addController(child: BaseController): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
if (this.childStates.has(child.id)) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.DUPLICATE_CHILD,
|
||||
`Child controller with id "${child.id}" already attached`,
|
||||
{ context: { id: child.id } }
|
||||
);
|
||||
}
|
||||
|
||||
const disposers: Array<() => void> = [];
|
||||
disposers.push(
|
||||
child.onAnyInput((state) => {
|
||||
this.childStates.set(child.id, { ...state });
|
||||
this.needsUpdate = true;
|
||||
})
|
||||
);
|
||||
disposers.push(
|
||||
child.onCombo((combo) => {
|
||||
this.eventEmitter.emitCombo([...combo]);
|
||||
})
|
||||
);
|
||||
|
||||
this.children.push(child);
|
||||
this.childDisposers.set(child.id, disposers);
|
||||
this.childStates.set(child.id, child.getState());
|
||||
this.needsUpdate = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a child. Disposes both subscriptions and forgets the child's
|
||||
* snapshot so subsequent merges treat it as absent. Throws
|
||||
* `CONTROLLER.UNKNOWN_CHILD` when the child was never attached.
|
||||
*/
|
||||
public removeController(child: BaseController): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const index = this.children.indexOf(child);
|
||||
if (index === -1) {
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.UNKNOWN_CHILD,
|
||||
"Child controller not attached to this merger",
|
||||
{ context: { id: child.id } }
|
||||
);
|
||||
}
|
||||
const disposers = this.childDisposers.get(child.id) as Array<
|
||||
() => void
|
||||
>;
|
||||
for (const dispose of disposers) {
|
||||
dispose();
|
||||
}
|
||||
this.childDisposers.delete(child.id);
|
||||
this.childStates.delete(child.id);
|
||||
this.children.splice(index, 1);
|
||||
this.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** Read-only snapshot of attached children, in insertion order. */
|
||||
public getChildren(): MergerChildren {
|
||||
return [...this.children];
|
||||
}
|
||||
|
||||
/** Number of attached children. */
|
||||
public get childCount(): number {
|
||||
return this.children.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the merger. Polls every child first (so their internal
|
||||
* pipelines flush pending input), then reduces each
|
||||
* `CONTROLLER_INPUT_KEYS` field via `Math.max` across the captured
|
||||
* child states and routes diffs through `updateInput` so threshold /
|
||||
* deadzone gating + event emission stay consistent with sibling
|
||||
* adapters. The override deliberately bypasses `BaseController.update()`
|
||||
* so children are polled even when the merger itself has no pending
|
||||
* state of its own.
|
||||
*/
|
||||
public override update(): void {
|
||||
if (this.isDestroyed || this.isMerging) {
|
||||
return;
|
||||
}
|
||||
this.isMerging = true;
|
||||
try {
|
||||
for (const child of this.children) {
|
||||
child.update();
|
||||
}
|
||||
if (!this.needsUpdate) {
|
||||
return;
|
||||
}
|
||||
const now = this.clock();
|
||||
const minInterval = 1000 / this.pollRate;
|
||||
if (now - this.lastUpdate < minInterval) {
|
||||
return;
|
||||
}
|
||||
this.lastUpdate = now;
|
||||
|
||||
const merged = this.calculateMerged();
|
||||
let changed = false;
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
const nextValue = merged[key];
|
||||
if (nextValue !== this.lastMerged[key]) {
|
||||
changed = true;
|
||||
this.lastMerged[key] = nextValue;
|
||||
this.updateInput(key, nextValue);
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
this.needsUpdate = false;
|
||||
}
|
||||
} finally {
|
||||
this.isMerging = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the merged state from the captured per-child snapshots.
|
||||
* Iterates `CONTROLLER_INPUT_KEYS` so the reduction order is the
|
||||
* canonical wire order; per-field reduction is `Math.max` so any
|
||||
* source dominates a zero from another (the desired "any source
|
||||
* wins" semantic for game input).
|
||||
*/
|
||||
private calculateMerged(): ControllerInput {
|
||||
const out = {} as ControllerInput;
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
let best = 0;
|
||||
for (const snap of this.childStates.values()) {
|
||||
const v = snap[key];
|
||||
if (v > best) {
|
||||
best = v;
|
||||
}
|
||||
}
|
||||
out[key] = best;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle: detach every child, dispose listeners, destroy each
|
||||
* child (it owns its own resources), then defer to the base destroy
|
||||
* which clears local state + the typed emitter.
|
||||
*/
|
||||
public override destroy(): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
for (const disposers of this.childDisposers.values()) {
|
||||
for (const dispose of disposers) {
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
this.childDisposers.clear();
|
||||
for (const child of this.children) {
|
||||
child.destroy();
|
||||
}
|
||||
this.children.length = 0;
|
||||
this.childStates.clear();
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
this.lastMerged[key] = 0;
|
||||
}
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
24
packages/gaming/games/controller/src/merger/merger.types.ts
Normal file
24
packages/gaming/games/controller/src/merger/merger.types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { BaseController } from "../base/base.js";
|
||||
import type { BaseControllerOptions } from "../base/base.types.js";
|
||||
|
||||
/**
|
||||
* Construction options for `ControllerMerger`.
|
||||
*
|
||||
* Extends every `BaseControllerOptions` field (clock, idGenerator,
|
||||
* pollRate, device, strict) and adds an optional `children` seed so a
|
||||
* fully populated merger can be created in a single statement.
|
||||
*/
|
||||
export interface ControllerMergerOptions extends BaseControllerOptions {
|
||||
/**
|
||||
* Initial child controllers attached eagerly during construction.
|
||||
* Each child must have a unique `id`; duplicates throw
|
||||
* `ControllerError(CONTROLLER.DUPLICATE_CHILD)`.
|
||||
*/
|
||||
children?: readonly BaseController[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public read-only view of the merger's child set, returned by
|
||||
* `getChildren()` for inspection without exposing the internal storage.
|
||||
*/
|
||||
export type MergerChildren = readonly BaseController[];
|
||||
8
packages/gaming/games/controller/src/telemetry/index.ts
Normal file
8
packages/gaming/games/controller/src/telemetry/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
inputsCounter,
|
||||
patternMatchDurationHistogram,
|
||||
recordInput,
|
||||
recordPatternMatchDuration,
|
||||
recordStateChange,
|
||||
stateChangesCounter,
|
||||
} from "./telemetry.js";
|
||||
88
packages/gaming/games/controller/src/telemetry/telemetry.md
Normal file
88
packages/gaming/games/controller/src/telemetry/telemetry.md
Normal 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.
|
||||
130
packages/gaming/games/controller/src/telemetry/telemetry.test.ts
Normal file
130
packages/gaming/games/controller/src/telemetry/telemetry.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
afterAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
flushTestMetrics,
|
||||
testInMemoryMetricExporter,
|
||||
testMeterProvider,
|
||||
} from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
inputsCounter,
|
||||
patternMatchDurationHistogram,
|
||||
recordInput,
|
||||
recordPatternMatchDuration,
|
||||
recordStateChange,
|
||||
stateChangesCounter,
|
||||
} from "./telemetry.js";
|
||||
|
||||
describe("telemetry instruments", () => {
|
||||
beforeEach(() => {
|
||||
testInMemoryMetricExporter.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testMeterProvider.shutdown();
|
||||
metrics.disable();
|
||||
});
|
||||
|
||||
it("exposes the three named instruments", { timeout: 5000 }, () => {
|
||||
expect(inputsCounter).toBeDefined();
|
||||
expect(stateChangesCounter).toBeDefined();
|
||||
expect(patternMatchDurationHistogram).toBeDefined();
|
||||
});
|
||||
|
||||
it("recordInput increments controller_inputs_total with device+input attrs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
recordInput({ device: "keyboard", input: "up" });
|
||||
recordInput({ device: "keyboard", input: "up" });
|
||||
recordInput({ device: "gamepad", input: "a" });
|
||||
|
||||
const snapshots = await flushTestMetrics();
|
||||
const points: { attributes: Record<string, unknown>; value: number }[] =
|
||||
[];
|
||||
for (const rm of snapshots) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name === "controller_inputs_total") {
|
||||
for (const dp of m.dataPoints) {
|
||||
points.push({
|
||||
attributes: dp.attributes,
|
||||
value: dp.value as number,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(points.length).toBe(2);
|
||||
const kbUp = points.find(
|
||||
(p) =>
|
||||
p.attributes.device === "keyboard" &&
|
||||
p.attributes.input === "up"
|
||||
);
|
||||
const gpA = points.find(
|
||||
(p) =>
|
||||
p.attributes.device === "gamepad" && p.attributes.input === "a"
|
||||
);
|
||||
expect(kbUp?.value).toBe(2);
|
||||
expect(gpA?.value).toBe(1);
|
||||
});
|
||||
|
||||
it("recordStateChange increments controller_state_changes_total", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
recordStateChange({ device: "keyboard" });
|
||||
recordStateChange({ device: "keyboard" });
|
||||
const snapshots = await flushTestMetrics();
|
||||
let value = 0;
|
||||
for (const rm of snapshots) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (
|
||||
m.descriptor.name === "controller_state_changes_total"
|
||||
) {
|
||||
for (const dp of m.dataPoints) {
|
||||
value += dp.value as number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(value).toBe(2);
|
||||
});
|
||||
|
||||
it("recordPatternMatchDuration writes into the histogram", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
recordPatternMatchDuration(1.5);
|
||||
recordPatternMatchDuration(2.5);
|
||||
const snapshots = await flushTestMetrics();
|
||||
let count = 0;
|
||||
let sum = 0;
|
||||
for (const rm of snapshots) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (
|
||||
m.descriptor.name ===
|
||||
"controller_pattern_match_duration_ms"
|
||||
) {
|
||||
for (const dp of m.dataPoints) {
|
||||
const v = dp.value as {
|
||||
count: number;
|
||||
sum: number;
|
||||
};
|
||||
count += v.count;
|
||||
sum += v.sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(count).toBe(2);
|
||||
expect(sum).toBe(4);
|
||||
});
|
||||
});
|
||||
67
packages/gaming/games/controller/src/telemetry/telemetry.ts
Normal file
67
packages/gaming/games/controller/src/telemetry/telemetry.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
import type {
|
||||
InputCounterAttributes,
|
||||
StateCounterAttributes,
|
||||
} from "../types/controller.types.js";
|
||||
|
||||
const METER_NAME = "@suaveplan/controller";
|
||||
|
||||
/**
|
||||
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
|
||||
* first access. When no provider is registered the OTel API returns a
|
||||
* no-op meter, so instruments are safe to import in production code that
|
||||
* does not opt into telemetry.
|
||||
*/
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/**
|
||||
* Counter for input change events.
|
||||
*
|
||||
* Attributes: `device` (string, set by adapter — `keyboard`, `gamepad`,
|
||||
* etc.) and `input` (the `ControllerInput` key whose value changed).
|
||||
*/
|
||||
export const inputsCounter: Counter = meter.createCounter(
|
||||
"controller_inputs_total",
|
||||
{
|
||||
description:
|
||||
"Total input-change events emitted by a controller, tagged with the device and input key",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for full-state change events; attribute `device`. */
|
||||
export const stateChangesCounter: Counter = meter.createCounter(
|
||||
"controller_state_changes_total",
|
||||
{
|
||||
description:
|
||||
"Total state-change events emitted by a controller, tagged with the device",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Histogram recording the duration of a pattern-match attempt in
|
||||
* milliseconds. The instrument is owned by Chunk A so the pattern engine
|
||||
* landing in a later chunk plugs in by name without redeclaring it.
|
||||
*/
|
||||
export const patternMatchDurationHistogram: Histogram = meter.createHistogram(
|
||||
"controller_pattern_match_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration of an input-pattern match attempt in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Record a single input-change event with `device` + `input` tags. */
|
||||
export function recordInput(attrs: InputCounterAttributes): void {
|
||||
inputsCounter.add(1, { device: attrs.device, input: attrs.input });
|
||||
}
|
||||
|
||||
/** Record a single state-change event with the `device` tag. */
|
||||
export function recordStateChange(attrs: StateCounterAttributes): void {
|
||||
stateChangesCounter.add(1, { device: attrs.device });
|
||||
}
|
||||
|
||||
/** Record a pattern-match duration measurement. */
|
||||
export function recordPatternMatchDuration(durationMs: number): void {
|
||||
patternMatchDurationHistogram.record(durationMs);
|
||||
}
|
||||
@@ -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.
|
||||
128
packages/gaming/games/controller/src/types/controller.types.ts
Normal file
128
packages/gaming/games/controller/src/types/controller.types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Canonical 37-field controller input shape.
|
||||
*
|
||||
* Every device adapter (keyboard, gamepad, touch, mouse) normalises into
|
||||
* this exact shape. Field values are integers in `[0, 255]` after
|
||||
* `BaseController` normalisation (clamp → round → deadzone → threshold).
|
||||
*
|
||||
* The 37 keys are stable wire identifiers; reordering is a breaking change.
|
||||
*/
|
||||
export interface ControllerInput {
|
||||
// Directional inputs
|
||||
up: number;
|
||||
down: number;
|
||||
left: number;
|
||||
right: number;
|
||||
|
||||
// Face buttons
|
||||
a: number;
|
||||
b: number;
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
// Triggers / bumpers
|
||||
t1: number;
|
||||
t2: number;
|
||||
t3: number;
|
||||
t4: number;
|
||||
|
||||
// Function buttons
|
||||
f1: number;
|
||||
f2: number;
|
||||
f3: number;
|
||||
f4: number;
|
||||
|
||||
// Analog sticks
|
||||
lx: number;
|
||||
ly: number;
|
||||
rx: number;
|
||||
ry: number;
|
||||
|
||||
// Stick buttons (l3 / r3)
|
||||
l3: number;
|
||||
r3: number;
|
||||
|
||||
// Analog triggers (lt / rt)
|
||||
lt: number;
|
||||
rt: number;
|
||||
|
||||
// Motion (gyro + accelerometer)
|
||||
gx: number;
|
||||
gy: number;
|
||||
gz: number;
|
||||
mx: number;
|
||||
my: number;
|
||||
mz: number;
|
||||
|
||||
// Touch (tx/ty position, tp pressure)
|
||||
tx: number;
|
||||
ty: number;
|
||||
tp: number;
|
||||
|
||||
// Special (vibration low/high, battery, player index)
|
||||
vl: number;
|
||||
vh: number;
|
||||
bt: number;
|
||||
pi: number;
|
||||
}
|
||||
|
||||
/** Backward-compatible alias for callers that imported the old name. */
|
||||
export type ControllerState = ControllerInput;
|
||||
|
||||
/** 2D vector used by `BaseController.getDirectionalVector()`. */
|
||||
export interface Vector2D {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-input gating configuration.
|
||||
*
|
||||
* - `deadzone` — values with `|v| < deadzone` are snapped to 0.
|
||||
* - `threshold` — values `0 < v < threshold` are snapped to 0.
|
||||
*
|
||||
* Both default to 0 (no gating). Configured via
|
||||
* `BaseController.setThreshold(input, options)`.
|
||||
*/
|
||||
export interface InputThresholds {
|
||||
threshold?: number;
|
||||
deadzone?: number;
|
||||
}
|
||||
|
||||
/** Listener fired when a single input value crosses normalisation. */
|
||||
export type InputChangeCallback = (
|
||||
input: keyof ControllerInput,
|
||||
value: number,
|
||||
prevValue: number
|
||||
) => void;
|
||||
|
||||
/** Listener fired when any field of the controller state changes. */
|
||||
export type StateChangeCallback = (
|
||||
state: ControllerInput,
|
||||
oldState?: Partial<ControllerInput>
|
||||
) => void;
|
||||
|
||||
/** Listener fired when a press-state change emits a combo sequence. */
|
||||
export type ComboCallback = (combo: string[]) => void;
|
||||
|
||||
/**
|
||||
* Snapshot of an input value at a point in time, captured via the injected
|
||||
* `Clock` (`@suaveplan/types`). Used by the buffer + pattern engine that
|
||||
* lands in Chunks B+C.
|
||||
*/
|
||||
export interface ControllerSnapshot {
|
||||
input: keyof ControllerInput;
|
||||
value: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Telemetry attribute set for the `controller_inputs_total` counter. */
|
||||
export interface InputCounterAttributes {
|
||||
device: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
/** Telemetry attribute set for the `controller_state_changes_total` counter. */
|
||||
export interface StateCounterAttributes {
|
||||
device: string;
|
||||
}
|
||||
16
packages/gaming/games/controller/src/types/index.ts
Normal file
16
packages/gaming/games/controller/src/types/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
ComboCallback,
|
||||
ControllerInput,
|
||||
ControllerSnapshot,
|
||||
ControllerState,
|
||||
InputChangeCallback,
|
||||
InputCounterAttributes,
|
||||
InputThresholds,
|
||||
StateChangeCallback,
|
||||
StateCounterAttributes,
|
||||
Vector2D,
|
||||
} from "./controller.types.js";
|
||||
export {
|
||||
CONTROLLER_INPUT_KEYS,
|
||||
createDefaultControllerInput,
|
||||
} from "./keys.js";
|
||||
80
packages/gaming/games/controller/src/types/keys.md
Normal file
80
packages/gaming/games/controller/src/types/keys.md
Normal 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.
|
||||
35
packages/gaming/games/controller/src/types/keys.test.ts
Normal file
35
packages/gaming/games/controller/src/types/keys.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CONTROLLER_INPUT_KEYS, createDefaultControllerInput } from "./keys.js";
|
||||
|
||||
describe("CONTROLLER_INPUT_KEYS", () => {
|
||||
it("contains exactly 37 unique keys in stable order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(CONTROLLER_INPUT_KEYS.length).toBe(37);
|
||||
const set = new Set<string>(CONTROLLER_INPUT_KEYS);
|
||||
expect(set.size).toBe(CONTROLLER_INPUT_KEYS.length);
|
||||
// First-group sanity: directional keys come first
|
||||
expect(CONTROLLER_INPUT_KEYS[0]).toBe("up");
|
||||
expect(CONTROLLER_INPUT_KEYS[CONTROLLER_INPUT_KEYS.length - 1]).toBe(
|
||||
"pi"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDefaultControllerInput", () => {
|
||||
it("returns a fresh zero-initialised object", { timeout: 5000 }, () => {
|
||||
const state = createDefaultControllerInput();
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
expect(state[key]).toBe(0);
|
||||
}
|
||||
expect(Object.keys(state).length).toBe(CONTROLLER_INPUT_KEYS.length);
|
||||
});
|
||||
|
||||
it("returns a new instance on every call", { timeout: 5000 }, () => {
|
||||
const a = createDefaultControllerInput();
|
||||
const b = createDefaultControllerInput();
|
||||
expect(a).not.toBe(b);
|
||||
a.up = 200;
|
||||
expect(b.up).toBe(0);
|
||||
});
|
||||
});
|
||||
56
packages/gaming/games/controller/src/types/keys.ts
Normal file
56
packages/gaming/games/controller/src/types/keys.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ControllerInput } from "./controller.types.js";
|
||||
|
||||
/**
|
||||
* Canonical iteration order over `ControllerInput` keys.
|
||||
*
|
||||
* Order matters for `ControllerMerger`'s deterministic per-field max
|
||||
* reduction and for any serialiser that walks the shape.
|
||||
*/
|
||||
export const CONTROLLER_INPUT_KEYS: readonly (keyof ControllerInput)[] = [
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"a",
|
||||
"b",
|
||||
"x",
|
||||
"y",
|
||||
"t1",
|
||||
"t2",
|
||||
"t3",
|
||||
"t4",
|
||||
"f1",
|
||||
"f2",
|
||||
"f3",
|
||||
"f4",
|
||||
"lx",
|
||||
"ly",
|
||||
"rx",
|
||||
"ry",
|
||||
"l3",
|
||||
"r3",
|
||||
"lt",
|
||||
"rt",
|
||||
"gx",
|
||||
"gy",
|
||||
"gz",
|
||||
"mx",
|
||||
"my",
|
||||
"mz",
|
||||
"tx",
|
||||
"ty",
|
||||
"tp",
|
||||
"vl",
|
||||
"vh",
|
||||
"bt",
|
||||
"pi",
|
||||
] as const;
|
||||
|
||||
/** Build a fresh zero-initialised `ControllerInput`. */
|
||||
export function createDefaultControllerInput(): ControllerInput {
|
||||
const out = {} as ControllerInput;
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
out[key] = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
40
packages/gaming/games/controller/tests/setup/polyfills.ts
Normal file
40
packages/gaming/games/controller/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/controller.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it. Also
|
||||
* provides a `webcrypto`-backed `globalThis.crypto` for bare-Node runs.
|
||||
*/
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/gaming/games/controller/tsconfig.lib.json
Normal file
12
packages/gaming/games/controller/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/gaming/games/controller/vite.config.ts
Normal file
39
packages/gaming/games/controller/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/games-controller",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/gaming/games/controller/vitest.config.ts
Normal file
32
packages/gaming/games/controller/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
hookTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user