Initial Commit
This commit is contained in:
15
packages/gaming/games/README.md
Normal file
15
packages/gaming/games/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `games`
|
||||
|
||||
Game controllers, entity-component systems, and game-server logic.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `games`
|
||||
|
||||
3 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/controller`](./controller/README.md) | Universal input controller foundation. Ships the unified `ControllerInput` shape (37 normalised fields in `[0, 255]`), `BaseController` abstract base with… |
|
||||
| [`@suaveplan/ecs`](./ecs/README.md) | Archetype-based Entity Component System with `Uint32`-backed bitset queries, phase-ordered system scheduling, entity-ID recycling, Zod-validated component sche… |
|
||||
| [`@suaveplan/game-server`](./game-server/README.md) | Universal game-server framework. Migrated from `@suaveplan/game-server` on the reference branch `claude/review-game-engine-arch-Kaykr` (main repo `packages/uni… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/gaming/games/_intro.md
Normal file
1
packages/gaming/games/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Game controllers, entity-component systems, and game-server logic.
|
||||
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,
|
||||
},
|
||||
});
|
||||
91
packages/gaming/games/ecs/README.md
Normal file
91
packages/gaming/games/ecs/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# @suaveplan/ecs - Archetype-based Entity Component System with Uint32 bitset queries, phase-ordered system scheduler, entity-ID recycling, Zod-validated component schemas, structured errors, and OpenTelemetry instrumentation
|
||||
|
||||
Archetype-based Entity Component System with `Uint32`-backed bitset queries, phase-ordered system scheduling, entity-ID recycling, Zod-validated component schemas, structured errors, and OpenTelemetry instrumentation. Universal — runs identically in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [archetype](./src/archetype/archetype.md) — An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with th…
|
||||
- [archetype](./src/archetype/archetype.md) — An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with th…
|
||||
- [bitset](./src/bitset/bitset.md) — `BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/…
|
||||
- [bitset](./src/bitset/bitset.md) — `BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/…
|
||||
- [component-registry](./src/component-registry/component-registry.md) — `ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit position…
|
||||
- [component-registry](./src/component-registry/component-registry.md) — `ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit position…
|
||||
- [entity-pool](./src/entity-pool/entity-pool.md) — `EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free…
|
||||
- [entity-pool](./src/entity-pool/entity-pool.md) — `EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free…
|
||||
- [errors](./src/errors/ecs-error.md) — `EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from…
|
||||
- [ecs-error](./src/errors/ecs-error.md) — `EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from…
|
||||
- [observability](./src/observability/otel.md) — This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_…
|
||||
- [otel](./src/observability/otel.md) — This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_…
|
||||
- [query](./src/query/query.md) — `Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluat…
|
||||
- [query](./src/query/query.md) — `Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluat…
|
||||
- [schema](./src/schema/schema.md) — `defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing…
|
||||
- [schema](./src/schema/schema.md) — `defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing…
|
||||
- [system](./src/system/system.md) — A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates com…
|
||||
- [system](./src/system/system.md) — A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates com…
|
||||
- [world](./src/world/world.md) — `World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, que…
|
||||
- [world](./src/world/world.md) — `World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, que…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/ecs
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const p = world.getComponent(entity, Position)!;
|
||||
const v = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: p.x + v.dx * dt,
|
||||
y: p.y + v.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const player = world.spawn();
|
||||
world.addComponent(player, Position);
|
||||
world.addComponent(player, Velocity);
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`bitset/`](./src/bitset/bitset.md) — `Uint32Array`-backed bitset primitive.
|
||||
- [`component-registry/`](./src/component-registry/component-registry.md) — stable-ID registry for component schemas.
|
||||
- [`entity-pool/`](./src/entity-pool/entity-pool.md) — entity-ID allocator with recycling and generations.
|
||||
- [`archetype/`](./src/archetype/archetype.md) — column-oriented storage with O(1) swap-remove.
|
||||
- [`query/`](./src/query/query.md) — bitset-driven query compiler.
|
||||
- [`system/`](./src/system/system.md) — phase-priority system scheduler.
|
||||
- [`schema/`](./src/schema/schema.md) — `defineComponent` factory with optional Zod validator.
|
||||
- [`world/`](./src/world/world.md) — central container; spawn/despawn, components, queries, systems, snapshot/restore.
|
||||
- [`errors/`](./src/errors/ecs-error.md) — `EcsError` and the `ECS_CODES` registry.
|
||||
- [`observability/`](./src/observability/otel.md) — OTel counters and histograms.
|
||||
- [`types/`](./src/types/ecs.types.md) — every public type alias and interface.
|
||||
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/ecs
|
||||
bunx turbo test:coverage --filter=@suaveplan/ecs # 100% statement+branch+function+line
|
||||
bun run docs:check --filter=@suaveplan/ecs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Suaveplan Team
|
||||
|
||||
62
packages/gaming/games/ecs/package.json
Normal file
62
packages/gaming/games/ecs/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@suaveplan/ecs",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Archetype-based Entity Component System with Uint32 bitset queries, phase-ordered system scheduler, entity-ID recycling, Zod-validated component schemas, structured errors, and OpenTelemetry instrumentation",
|
||||
"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",
|
||||
"fast-check": "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"]
|
||||
}
|
||||
80
packages/gaming/games/ecs/src/archetype/archetype.md
Normal file
80
packages/gaming/games/ecs/src/archetype/archetype.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Archetype
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with the same component set live in a single archetype, whose storage is column-oriented (Struct of Arrays). When an entity's component set changes, the world swap-removes it from its current archetype and inserts it into the destination archetype, keeping iteration over any single component cache-friendly and O(1).
|
||||
|
||||
This class is internal-but-exported so advanced consumers (debug overlays, profilers, custom system runners) can inspect or iterate dense entity arrays without going through the world's higher-level API.
|
||||
|
||||
## Features
|
||||
|
||||
- Mask-keyed identity (`mask.toKey()`) plus a sorted component-ID list.
|
||||
- One dense `ComponentData[]` per component, addressed by entity slot index.
|
||||
- O(1) `removeEntity` via swap-with-tail; dense packing is invariant across mutations.
|
||||
- Returns the removed entity's full column map for cheap migration to the destination archetype.
|
||||
- `setComponent` merges partial updates without re-allocating the slot.
|
||||
- `hasComponent`, `hasEntity`, `getEntities`, `getComponentIds` accessors for read-only inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Archetype, BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(32);
|
||||
mask.set(0);
|
||||
mask.set(1);
|
||||
const arch = new Archetype(mask, [0, 1]);
|
||||
|
||||
arch.addEntity(7, new Map([
|
||||
[0, { x: 0, y: 0 }],
|
||||
[1, { dx: 1, dy: 0 }],
|
||||
]));
|
||||
|
||||
console.log(arch.count); // 1
|
||||
console.log(arch.getComponent(7, 0)); // { x: 0, y: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Archetype, BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(32);
|
||||
mask.set(0);
|
||||
const arch = new Archetype(mask, [0]);
|
||||
|
||||
for (const id of [1, 2, 3]) {
|
||||
arch.addEntity(id, new Map([[0, { v: id }]]));
|
||||
}
|
||||
|
||||
// Migration to a new archetype: pull the removed entity's data and forward it.
|
||||
const removed = arch.removeEntity(2);
|
||||
const targetArch = new Archetype(mask, [0]);
|
||||
if (removed) targetArch.addEntity(2, removed);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new Archetype(mask, componentIds)` | Build an archetype with the given component-ID set. |
|
||||
| `mask` | The bitset of owned component IDs. |
|
||||
| `id` | `mask.toKey()` — stable archetype identity. |
|
||||
| `count` | Number of entities currently in this archetype. |
|
||||
| `addEntity(entity, data)` | Append an entity with its initial column values. |
|
||||
| `removeEntity(entity)` | Swap-remove and return the entity's full column map (or `undefined`). |
|
||||
| `getComponent(entity, componentId)` | Read a single column value. |
|
||||
| `setComponent(entity, componentId, data)` | Merge a partial update; returns `false` on missing entity/column. |
|
||||
| `hasEntity(entity)` / `hasComponent(componentId)` | Membership checks. |
|
||||
| `getEntities()` / `getComponentIds()` | Read-only snapshots of the storage order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Component data is stored in parallel arrays — one array per component-ID. Entity slot `n` maps to position `n` in every array, so a system iterating one archetype reads dense memory.
|
||||
- Swap-remove is the classic dense-vector trick: copy the tail value into the removed slot then pop. This keeps the entities array contiguous, but means the entity order is **not stable** across removals — callers needing stable order must impose it externally.
|
||||
- `setComponent` performs an object spread merge rather than assigning by reference. This costs an allocation per mutation but keeps the stored value immutable from the caller's perspective.
|
||||
- `removeEntity` collects every column value into a `Map` before destroying the slot. The world uses this map to seed the destination archetype during component-add / component-remove transitions.
|
||||
127
packages/gaming/games/ecs/src/archetype/archetype.test.ts
Normal file
127
packages/gaming/games/ecs/src/archetype/archetype.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BitSet } from "../bitset/bitset.js";
|
||||
import { Archetype } from "./archetype.js";
|
||||
|
||||
function archetypeWith(componentIds: number[], maskSize = 32): Archetype {
|
||||
const mask = new BitSet(maskSize);
|
||||
for (const cid of componentIds) mask.set(cid);
|
||||
return new Archetype(mask, componentIds);
|
||||
}
|
||||
|
||||
describe("Archetype", () => {
|
||||
it("id matches the mask key", { timeout: 5000 }, () => {
|
||||
const arch = archetypeWith([1, 3]);
|
||||
expect(arch.id).toBe("1,3");
|
||||
});
|
||||
|
||||
it("addEntity fills every column with provided data or empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0, 1]);
|
||||
const data = new Map<number, Record<string, unknown>>([
|
||||
[0, { x: 5 }],
|
||||
[1, { dx: 1 }],
|
||||
]);
|
||||
arch.addEntity(7, data);
|
||||
expect(arch.count).toBe(1);
|
||||
expect(arch.getComponent(7, 0)).toEqual({ x: 5 });
|
||||
expect(arch.getComponent(7, 1)).toEqual({ dx: 1 });
|
||||
});
|
||||
|
||||
it("addEntity defaults missing column values to empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0, 1]);
|
||||
arch.addEntity(1, new Map([[0, { x: 1 }]]));
|
||||
expect(arch.getComponent(1, 1)).toEqual({});
|
||||
});
|
||||
|
||||
it("removeEntity swap-removes and preserves dense packing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
for (const id of [1, 2, 3]) {
|
||||
arch.addEntity(id, new Map([[0, { v: id }]]));
|
||||
}
|
||||
arch.removeEntity(2);
|
||||
expect(arch.count).toBe(2);
|
||||
expect(arch.getEntities()).toEqual([1, 3]);
|
||||
expect(arch.getComponent(3, 0)).toEqual({ v: 3 });
|
||||
expect(arch.getComponent(1, 0)).toEqual({ v: 1 });
|
||||
});
|
||||
|
||||
it("removeEntity removing the tail does not move other entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
for (const id of [1, 2, 3]) {
|
||||
arch.addEntity(id, new Map([[0, { v: id }]]));
|
||||
}
|
||||
arch.removeEntity(3);
|
||||
expect(arch.getEntities()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("removeEntity returns a map of every column value at the removed slot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0, 1]);
|
||||
arch.addEntity(
|
||||
1,
|
||||
new Map([
|
||||
[0, { x: 1 }],
|
||||
[1, { dx: 2 }],
|
||||
])
|
||||
);
|
||||
const removed = arch.removeEntity(1);
|
||||
expect(removed?.get(0)).toEqual({ x: 1 });
|
||||
expect(removed?.get(1)).toEqual({ dx: 2 });
|
||||
});
|
||||
|
||||
it("removeEntity returns undefined for an unknown entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
expect(arch.removeEntity(123)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setComponent merges a partial update into the existing column value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
arch.addEntity(1, new Map([[0, { x: 0, y: 0 }]]));
|
||||
expect(arch.setComponent(1, 0, { x: 5 })).toBe(true);
|
||||
expect(arch.getComponent(1, 0)).toEqual({ x: 5, y: 0 });
|
||||
});
|
||||
|
||||
it("setComponent returns false for unknown entity or column", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
arch.addEntity(1, new Map([[0, { x: 0 }]]));
|
||||
expect(arch.setComponent(99, 0, { x: 1 })).toBe(false);
|
||||
expect(arch.setComponent(1, 99, { x: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("hasEntity and hasComponent reflect membership", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
arch.addEntity(1, new Map([[0, {}]]));
|
||||
expect(arch.hasEntity(1)).toBe(true);
|
||||
expect(arch.hasEntity(2)).toBe(false);
|
||||
expect(arch.hasComponent(0)).toBe(true);
|
||||
expect(arch.hasComponent(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("getComponent on an unknown entity returns undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
expect(arch.getComponent(42, 0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getComponentIds returns the registered ids", { timeout: 5000 }, () => {
|
||||
const arch = archetypeWith([2, 5]);
|
||||
expect([...arch.getComponentIds()]).toEqual([2, 5]);
|
||||
});
|
||||
});
|
||||
131
packages/gaming/games/ecs/src/archetype/archetype.ts
Normal file
131
packages/gaming/games/ecs/src/archetype/archetype.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { BitSet } from "../bitset/bitset.js";
|
||||
import type { ComponentData, ComponentId, Entity } from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* An archetype is a unique combination of component types. All entities with
|
||||
* the same component set share a single archetype, whose storage is laid out
|
||||
* column-first (Struct-of-Arrays): one dense array per component.
|
||||
*
|
||||
* Removal is O(1) via swap-remove with the trailing entity, and the dense
|
||||
* packing invariant is preserved across mutations.
|
||||
*/
|
||||
export class Archetype {
|
||||
/** Mask of component IDs that this archetype owns. */
|
||||
public readonly mask: BitSet;
|
||||
|
||||
/** Sorted-component-ID string used to identify and dedupe archetypes. */
|
||||
public readonly id: string;
|
||||
|
||||
private readonly entities: Entity[] = [];
|
||||
private readonly entityIndex = new Map<Entity, number>();
|
||||
private readonly columns = new Map<ComponentId, ComponentData[]>();
|
||||
private readonly componentIds: ReadonlyArray<ComponentId>;
|
||||
|
||||
public constructor(mask: BitSet, componentIds: ReadonlyArray<ComponentId>) {
|
||||
this.mask = mask;
|
||||
this.id = mask.toKey();
|
||||
this.componentIds = componentIds;
|
||||
for (const cid of componentIds) {
|
||||
this.columns.set(cid, []);
|
||||
}
|
||||
}
|
||||
|
||||
/** Append an entity with its column values. */
|
||||
public addEntity(
|
||||
entity: Entity,
|
||||
data: ReadonlyMap<ComponentId, ComponentData>
|
||||
): void {
|
||||
const index = this.entities.length;
|
||||
this.entities.push(entity);
|
||||
this.entityIndex.set(entity, index);
|
||||
for (const cid of this.componentIds) {
|
||||
const column = this.columns.get(cid) as ComponentData[];
|
||||
column.push(data.get(cid) ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `entity` via swap-remove and return its column values.
|
||||
* Returns `undefined` if the entity is not present.
|
||||
*/
|
||||
public removeEntity(
|
||||
entity: Entity
|
||||
): Map<ComponentId, ComponentData> | undefined {
|
||||
const index = this.entityIndex.get(entity);
|
||||
if (index === undefined) return undefined;
|
||||
|
||||
const removed = new Map<ComponentId, ComponentData>();
|
||||
const lastIndex = this.entities.length - 1;
|
||||
|
||||
for (const cid of this.componentIds) {
|
||||
const column = this.columns.get(cid) as ComponentData[];
|
||||
removed.set(cid, column[index] as ComponentData);
|
||||
if (index !== lastIndex) {
|
||||
column[index] = column[lastIndex] as ComponentData;
|
||||
}
|
||||
column.pop();
|
||||
}
|
||||
|
||||
if (index !== lastIndex) {
|
||||
const lastEntity = this.entities[lastIndex] as Entity;
|
||||
this.entities[index] = lastEntity;
|
||||
this.entityIndex.set(lastEntity, index);
|
||||
}
|
||||
this.entities.pop();
|
||||
this.entityIndex.delete(entity);
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Look up a single component value for `entity`. */
|
||||
public getComponent(
|
||||
entity: Entity,
|
||||
componentId: ComponentId
|
||||
): ComponentData | undefined {
|
||||
const index = this.entityIndex.get(entity);
|
||||
if (index === undefined) return undefined;
|
||||
return this.columns.get(componentId)?.[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `data` into the existing column value for `entity` / `componentId`.
|
||||
* Returns `false` if the entity or column is absent.
|
||||
*/
|
||||
public setComponent(
|
||||
entity: Entity,
|
||||
componentId: ComponentId,
|
||||
data: ComponentData
|
||||
): boolean {
|
||||
const index = this.entityIndex.get(entity);
|
||||
if (index === undefined) return false;
|
||||
const column = this.columns.get(componentId);
|
||||
if (column === undefined) return false;
|
||||
column[index] = { ...(column[index] as ComponentData), ...data };
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when this archetype currently holds `entity`. */
|
||||
public hasEntity(entity: Entity): boolean {
|
||||
return this.entityIndex.has(entity);
|
||||
}
|
||||
|
||||
/** Read-only snapshot of the archetype's entity list, in storage order. */
|
||||
public getEntities(): ReadonlyArray<Entity> {
|
||||
return this.entities;
|
||||
}
|
||||
|
||||
/** Number of entities currently in this archetype. */
|
||||
public get count(): number {
|
||||
return this.entities.length;
|
||||
}
|
||||
|
||||
/** True when this archetype's mask has bit `componentId`. */
|
||||
public hasComponent(componentId: ComponentId): boolean {
|
||||
return this.mask.has(componentId);
|
||||
}
|
||||
|
||||
/** Read-only snapshot of the archetype's component-ID list. */
|
||||
public getComponentIds(): ReadonlyArray<ComponentId> {
|
||||
return this.componentIds;
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/archetype/index.ts
Normal file
1
packages/gaming/games/ecs/src/archetype/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Archetype } from "./archetype.js";
|
||||
82
packages/gaming/games/ecs/src/bitset/bitset.md
Normal file
82
packages/gaming/games/ecs/src/bitset/bitset.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# BitSet
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/ecs`. Storing component membership as bits packed into `Uint32Array` words gives the engine cache-friendly storage and O(words) algebra for subset, superset, and intersection tests — the hot paths driving every archetype-graph transition.
|
||||
|
||||
The class is intentionally narrow: it offers only the operators needed by the engine itself (`set`, `unset`, `has`, `containsAll`, `intersects`, `equals`, `count`, `clone`, `clear`, `toKey`, iterator). It is universal — no `node:*`, browser, or React dependency — so it runs identically in Bun, Node, and the browser.
|
||||
|
||||
## Features
|
||||
|
||||
- `Uint32Array`-backed storage; one word per 32 bits.
|
||||
- O(words) `containsAll` and `intersects`, safe across mismatched sizes.
|
||||
- Population count (`count`) via the SWAR Hamming-weight trick.
|
||||
- Sorted-ascending iteration through `Symbol.iterator`.
|
||||
- Stable serialised key from `toKey()` used as the archetype-graph map key.
|
||||
- Defensive `set` / `unset` guards: out-of-range bit indices throw `RangeError`; the constructor rejects non-positive sizes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(64);
|
||||
mask.set(0);
|
||||
mask.set(5);
|
||||
mask.set(32);
|
||||
|
||||
console.log(mask.has(5)); // true
|
||||
console.log([...mask]); // [0, 5, 32]
|
||||
console.log(mask.toKey()); // "0,5,32"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const include = new BitSet(128);
|
||||
const archetype = new BitSet(128);
|
||||
include.set(3);
|
||||
include.set(7);
|
||||
for (const cid of [3, 7, 11, 18]) archetype.set(cid);
|
||||
|
||||
if (archetype.containsAll(include)) {
|
||||
// Archetype has every required component — yield its entities.
|
||||
}
|
||||
|
||||
const exclude = new BitSet(128);
|
||||
exclude.set(11);
|
||||
if (archetype.intersects(exclude)) {
|
||||
// Skip — archetype contains a banned component.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new BitSet(size)` | Allocate a bitset of `size` bits. Throws `RangeError` for non-positive integer sizes. |
|
||||
| `size` | Read-only declared bit-width. |
|
||||
| `set(bit)` / `unset(bit)` | Flip a single bit. `RangeError` on out-of-range index. |
|
||||
| `has(bit)` | Test a bit. Returns `false` for out-of-range indices. |
|
||||
| `containsAll(other)` | `true` when every bit set in `other` is also set here. Safe across mismatched word counts. |
|
||||
| `intersects(other)` | `true` when at least one bit is set in both bitsets. |
|
||||
| `equals(other)` | Structural word-by-word equality. |
|
||||
| `count()` | Population count via SWAR. |
|
||||
| `clone()` | Deep copy with the same size. |
|
||||
| `clear()` | Reset every bit to zero. |
|
||||
| `toKey()` | Comma-separated string of set indices, ascending. |
|
||||
| `[Symbol.iterator]()` | Yield set indices in ascending order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Words are accessed with `>>> 5` (divide by 32) and `& 31` (mod 32). Using bitwise math keeps everything in the 32-bit-integer fast path.
|
||||
- `containsAll` walks each word in lock-step and treats trailing words of `other` as zero when `this` is shorter — this lets the world build mask-only diffs between archetypes without forcing both sides to the same allocation size.
|
||||
- `count` is the classic 5-step parallel bit-population trick, fast and branch-free.
|
||||
- `toKey` produces a stable, comparable string used as the archetype-graph map key. It is O(size) but only paid once per archetype-create event.
|
||||
- The iterator is a generator; `for (const bit of set)` consumes it lazily.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { BitSet } from "./bitset.js";
|
||||
|
||||
describe("BitSet — spec conformance", () => {
|
||||
it("iterator yields number indices", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(8);
|
||||
set.set(3);
|
||||
const iter = set[Symbol.iterator]();
|
||||
expectTypeOf(iter).toEqualTypeOf<Generator<number>>();
|
||||
expect(iter.next().value).toBe(3);
|
||||
});
|
||||
|
||||
it("size is publicly readable", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(128);
|
||||
expectTypeOf(set.size).toEqualTypeOf<number>();
|
||||
expect(set.size).toBe(128);
|
||||
});
|
||||
});
|
||||
129
packages/gaming/games/ecs/src/bitset/bitset.test.ts
Normal file
129
packages/gaming/games/ecs/src/bitset/bitset.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BitSet } from "./bitset.js";
|
||||
|
||||
describe("BitSet", () => {
|
||||
it("set, has, and unset propagate across multiple Uint32 words", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(96);
|
||||
for (const bit of [2, 31, 32, 64, 95]) set.set(bit);
|
||||
for (const bit of [2, 31, 32, 64, 95]) expect(set.has(bit)).toBe(true);
|
||||
expect(set.has(33)).toBe(false);
|
||||
set.unset(32);
|
||||
expect(set.has(32)).toBe(false);
|
||||
expect(set.has(31)).toBe(true);
|
||||
expect(set.has(64)).toBe(true);
|
||||
});
|
||||
|
||||
it("out-of-range has returns false rather than throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(8);
|
||||
expect(set.has(-1)).toBe(false);
|
||||
expect(set.has(99)).toBe(false);
|
||||
});
|
||||
|
||||
it("out-of-range set and unset throw RangeError", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(8);
|
||||
expect(() => set.set(8)).toThrow(RangeError);
|
||||
expect(() => set.unset(-1)).toThrow(RangeError);
|
||||
expect(() => set.set(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("constructor rejects non-positive sizes", { timeout: 5000 }, () => {
|
||||
expect(() => new BitSet(0)).toThrow(RangeError);
|
||||
expect(() => new BitSet(-1)).toThrow(RangeError);
|
||||
expect(() => new BitSet(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("containsAll returns false when other has bits in trailing words this lacks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const small = new BitSet(32);
|
||||
const big = new BitSet(96);
|
||||
big.set(64);
|
||||
expect(small.containsAll(big)).toBe(false);
|
||||
big.unset(64);
|
||||
expect(small.containsAll(big)).toBe(true);
|
||||
});
|
||||
|
||||
it("containsAll matches the brute-force scan", { timeout: 5000 }, () => {
|
||||
const a = new BitSet(64);
|
||||
const b = new BitSet(64);
|
||||
for (const bit of [1, 5, 10, 31, 32, 63]) a.set(bit);
|
||||
for (const bit of [5, 10, 31]) b.set(bit);
|
||||
expect(a.containsAll(b)).toBe(true);
|
||||
b.set(2);
|
||||
expect(a.containsAll(b)).toBe(false);
|
||||
});
|
||||
|
||||
it("intersects is symmetric and detects shared bits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new BitSet(64);
|
||||
const b = new BitSet(64);
|
||||
a.set(7);
|
||||
b.set(7);
|
||||
expect(a.intersects(b)).toBe(true);
|
||||
expect(b.intersects(a)).toBe(true);
|
||||
b.unset(7);
|
||||
expect(a.intersects(b)).toBe(false);
|
||||
});
|
||||
|
||||
it("the iterator yields set bit indices in ascending order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(96);
|
||||
for (const bit of [64, 2, 32, 31]) set.set(bit);
|
||||
expect([...set]).toEqual([2, 31, 32, 64]);
|
||||
});
|
||||
|
||||
it("toKey produces a stable comma-separated string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(32);
|
||||
set.set(3);
|
||||
set.set(1);
|
||||
expect(set.toKey()).toBe("1,3");
|
||||
expect(new BitSet(32).toKey()).toBe("");
|
||||
});
|
||||
|
||||
it("clone produces an independent copy", { timeout: 5000 }, () => {
|
||||
const a = new BitSet(32);
|
||||
a.set(5);
|
||||
const b = a.clone();
|
||||
b.set(10);
|
||||
expect(a.has(10)).toBe(false);
|
||||
expect(b.has(5)).toBe(true);
|
||||
expect(b.has(10)).toBe(true);
|
||||
});
|
||||
|
||||
it("equals returns true for structurally identical sets including different word lengths", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new BitSet(32);
|
||||
const b = new BitSet(64);
|
||||
a.set(5);
|
||||
b.set(5);
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(b.equals(a)).toBe(true);
|
||||
b.set(33);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
expect(b.equals(a)).toBe(false);
|
||||
});
|
||||
|
||||
it("count returns the population of set bits", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(96);
|
||||
expect(set.count()).toBe(0);
|
||||
for (const bit of [0, 31, 32, 63, 64, 95]) set.set(bit);
|
||||
expect(set.count()).toBe(6);
|
||||
});
|
||||
|
||||
it("clear resets every bit", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(64);
|
||||
set.set(5);
|
||||
set.set(40);
|
||||
set.clear();
|
||||
expect(set.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
127
packages/gaming/games/ecs/src/bitset/bitset.ts
Normal file
127
packages/gaming/games/ecs/src/bitset/bitset.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Fixed-size bitset backed by `Uint32Array` storage for fast component-mask
|
||||
* arithmetic. Used by archetypes and queries for O(words) subset/superset
|
||||
* checks.
|
||||
*/
|
||||
export class BitSet {
|
||||
private readonly data: Uint32Array;
|
||||
public readonly size: number;
|
||||
|
||||
public constructor(size: number) {
|
||||
if (!Number.isInteger(size) || size <= 0) {
|
||||
throw new RangeError(
|
||||
`BitSet size must be a positive integer, got ${size}`
|
||||
);
|
||||
}
|
||||
this.size = size;
|
||||
this.data = new Uint32Array(Math.ceil(size / 32));
|
||||
}
|
||||
|
||||
/** Set the bit at `bit` to 1. */
|
||||
public set(bit: number): void {
|
||||
this.checkBit(bit);
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
this.data[index] = (this.data[index] as number) | (1 << offset);
|
||||
}
|
||||
|
||||
/** Clear the bit at `bit`. */
|
||||
public unset(bit: number): void {
|
||||
this.checkBit(bit);
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
this.data[index] = (this.data[index] as number) & ~(1 << offset);
|
||||
}
|
||||
|
||||
/** Test whether `bit` is set. */
|
||||
public has(bit: number): boolean {
|
||||
if (bit < 0 || bit >= this.size) return false;
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
return ((this.data[index] as number) & (1 << offset)) !== 0;
|
||||
}
|
||||
|
||||
/** True when this bitset has every bit set in `other`. */
|
||||
public containsAll(other: BitSet): boolean {
|
||||
const shared = Math.min(this.data.length, other.data.length);
|
||||
for (let i = 0; i < shared; i++) {
|
||||
const a = this.data[i] as number;
|
||||
const b = other.data[i] as number;
|
||||
if ((a & b) >>> 0 !== b) return false;
|
||||
}
|
||||
for (let i = shared; i < other.data.length; i++) {
|
||||
if ((other.data[i] as number) !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when this bitset shares at least one set bit with `other`. */
|
||||
public intersects(other: BitSet): boolean {
|
||||
const shared = Math.min(this.data.length, other.data.length);
|
||||
for (let i = 0; i < shared; i++) {
|
||||
if (((this.data[i] as number) & (other.data[i] as number)) !== 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Comma-separated string of set bit indices, ascending. Stable across runs. */
|
||||
public toKey(): string {
|
||||
const bits: number[] = [];
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
if (this.has(i)) bits.push(i);
|
||||
}
|
||||
return bits.join(",");
|
||||
}
|
||||
|
||||
/** Return a new BitSet with the same size and bits. */
|
||||
public clone(): BitSet {
|
||||
const copy = new BitSet(this.size);
|
||||
copy.data.set(this.data);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Structural equality — true when every word matches. */
|
||||
public equals(other: BitSet): boolean {
|
||||
const len = Math.max(this.data.length, other.data.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const a = i < this.data.length ? (this.data[i] as number) : 0;
|
||||
const b = i < other.data.length ? (other.data[i] as number) : 0;
|
||||
if (a !== b) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Population count — total number of set bits. */
|
||||
public count(): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
let v = this.data[i] as number;
|
||||
v = v - ((v >>> 1) & 0x55555555);
|
||||
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333);
|
||||
total += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Clear every bit. */
|
||||
public clear(): void {
|
||||
this.data.fill(0);
|
||||
}
|
||||
|
||||
/** Iterate set bit indices in ascending order. */
|
||||
public *[Symbol.iterator](): Generator<number> {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
if (this.has(i)) yield i;
|
||||
}
|
||||
}
|
||||
|
||||
private checkBit(bit: number): void {
|
||||
if (!Number.isInteger(bit) || bit < 0 || bit >= this.size) {
|
||||
throw new RangeError(
|
||||
`bit index ${bit} out of range [0, ${this.size})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/bitset/index.ts
Normal file
1
packages/gaming/games/ecs/src/bitset/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BitSet } from "./bitset.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# ComponentRegistry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit positions in archetype bitsets, and registering a schema is the first step before it can be added to any entity. The registry enforces three contracts: identity-stable registration (same schema → same ID), name-uniqueness (no two schemas may share a `name`), and a hard cap on how many distinct types can be registered (`maxComponents`, default 256, matching the bitset's typical 8-word allocation).
|
||||
|
||||
The registry is owned by `World` but exposed publicly so editors, profilers, and serialisation layers can iterate every registered type.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent `register` — passing the same schema reference returns the same ID.
|
||||
- Throws structured `EcsError` with stable codes for every failure mode (`COMPONENT_NAME_CONFLICT`, `COMPONENT_LIMIT_REACHED`, `COMPONENT_NOT_REGISTERED`).
|
||||
- Bi-directional lookup: schema → ID, ID → schema, name → schema.
|
||||
- `maxComponents` cap configured at construction; validates positive integer.
|
||||
- `count`, `has`, and `schemas()` accessors for inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ComponentRegistry, defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const registry = new ComponentRegistry();
|
||||
const positionId = registry.register(Position);
|
||||
|
||||
console.log(positionId); // 0
|
||||
console.log(registry.register(Position) === positionId); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ComponentRegistry,
|
||||
defineComponent,
|
||||
ECS_CODES,
|
||||
EcsError,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
const registry = new ComponentRegistry(4);
|
||||
registry.register(defineComponent("A", {}));
|
||||
registry.register(defineComponent("B", {}));
|
||||
registry.register(defineComponent("C", {}));
|
||||
registry.register(defineComponent("D", {}));
|
||||
|
||||
try {
|
||||
registry.register(defineComponent("E", {}));
|
||||
} catch (err) {
|
||||
if (err instanceof EcsError && err.code === ECS_CODES.COMPONENT_LIMIT_REACHED) {
|
||||
console.warn("raise maxComponents in WorldConfig");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new ComponentRegistry(maxComponents?)` | Default `maxComponents` is 256. Rejects non-positive integers. |
|
||||
| `register(schema)` | Return stable ID. Throws `ECS.COMPONENT_NAME_CONFLICT` / `ECS.COMPONENT_LIMIT_REACHED`. |
|
||||
| `getId(schema)` | Return ID, or throw `ECS.COMPONENT_NOT_REGISTERED`. |
|
||||
| `getSchema(id)` / `getSchemaByName(name)` | Lookups; return `undefined` when missing. |
|
||||
| `has(schema)` | Boolean membership. |
|
||||
| `count` | Number of registered schemas. |
|
||||
| `schemas()` | Read-only snapshot in registration order. |
|
||||
| `maxComponents` | Hard cap configured at construction. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Three maps back the registry — schema → id, id → schema, name → schema. The slight memory overhead pays off in O(1) lookups across all three directions.
|
||||
- IDs are dense and monotonically increasing starting at `0`. They are intentionally not stable across processes: the same schema registered second in process A and first in process B yields different IDs. Persistence layers use `schema.name`, never the ID.
|
||||
- Name uniqueness is enforced via the dedicated `nameToSchema` map; this catches the most common bug — two `defineComponent("Position", …)` calls in different modules — at registration time rather than at the first archetype-create.
|
||||
- The `maxComponents` cap is also the bitset size for archetype masks. Exceeding it would mean an archetype's mask runs off the end of its `Uint32Array`, so the limit is hard, not advisory.
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { ComponentRegistry } from "./component-registry.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0, dy: 0 });
|
||||
|
||||
describe("ComponentRegistry", () => {
|
||||
it("register returns the same id for the same schema reference", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
const id1 = reg.register(Position);
|
||||
const id2 = reg.register(Position);
|
||||
expect(id1).toBe(id2);
|
||||
expect(reg.count).toBe(1);
|
||||
});
|
||||
|
||||
it("register assigns dense ascending ids across schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
expect(reg.register(Position)).toBe(0);
|
||||
expect(reg.register(Velocity)).toBe(1);
|
||||
expect(reg.count).toBe(2);
|
||||
});
|
||||
|
||||
it("register throws ECS.COMPONENT_NAME_CONFLICT for a duplicate name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
const dup = defineComponent("Position", { x: 1, y: 1 });
|
||||
try {
|
||||
reg.register(dup);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_NAME_CONFLICT
|
||||
);
|
||||
expect((err as EcsError).context?.name).toBe("Position");
|
||||
}
|
||||
});
|
||||
|
||||
it("register throws ECS.COMPONENT_LIMIT_REACHED past maxComponents", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry(2);
|
||||
reg.register(Position);
|
||||
reg.register(Velocity);
|
||||
const Extra = defineComponent("Extra", { v: 0 });
|
||||
try {
|
||||
reg.register(Extra);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_LIMIT_REACHED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getId throws ECS.COMPONENT_NOT_REGISTERED for unknown schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
try {
|
||||
reg.getId(Position);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_NOT_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getSchema and getSchemaByName resolve registered schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
const id = reg.register(Position);
|
||||
expect(reg.getSchema(id)).toBe(Position);
|
||||
expect(reg.getSchemaByName("Position")).toBe(Position);
|
||||
expect(reg.getSchema(999)).toBeUndefined();
|
||||
expect(reg.getSchemaByName("Nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has returns true for registered, false for unregistered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
expect(reg.has(Position)).toBe(false);
|
||||
reg.register(Position);
|
||||
expect(reg.has(Position)).toBe(true);
|
||||
});
|
||||
|
||||
it("schemas returns a snapshot in registration order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
reg.register(Velocity);
|
||||
expect(reg.schemas()).toEqual([Position, Velocity]);
|
||||
});
|
||||
|
||||
it("constructor rejects non-positive maxComponents", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new ComponentRegistry(0)).toThrow(RangeError);
|
||||
expect(() => new ComponentRegistry(-1)).toThrow(RangeError);
|
||||
expect(() => new ComponentRegistry(1.5)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
||||
import type {
|
||||
ComponentData,
|
||||
ComponentId,
|
||||
ComponentSchema,
|
||||
} from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* Stable-ID registry for component schemas. IDs are dense, monotonically
|
||||
* increasing integers used as bit positions inside archetype bitsets.
|
||||
*/
|
||||
export class ComponentRegistry {
|
||||
private nextId = 0;
|
||||
private readonly schemaToId = new Map<ComponentSchema, ComponentId>();
|
||||
private readonly idToSchema = new Map<ComponentId, ComponentSchema>();
|
||||
private readonly nameToSchema = new Map<string, ComponentSchema>();
|
||||
|
||||
/** Hard cap on the number of distinct component types. Default 256. */
|
||||
public readonly maxComponents: number;
|
||||
|
||||
public constructor(maxComponents = 256) {
|
||||
if (!Number.isInteger(maxComponents) || maxComponents <= 0) {
|
||||
throw new RangeError(
|
||||
`maxComponents must be a positive integer, got ${maxComponents}`
|
||||
);
|
||||
}
|
||||
this.maxComponents = maxComponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a schema and return its ID.
|
||||
*
|
||||
* Idempotent by schema identity — passing the same schema reference twice
|
||||
* yields the same ID. Throws `ECS.COMPONENT_NAME_CONFLICT` when a different
|
||||
* schema is already registered under the same `name`, and
|
||||
* `ECS.COMPONENT_LIMIT_REACHED` once `maxComponents` distinct schemas
|
||||
* have been registered.
|
||||
*/
|
||||
public register<T extends ComponentData>(
|
||||
schema: ComponentSchema<T>
|
||||
): ComponentId {
|
||||
const existing = this.schemaToId.get(schema);
|
||||
if (existing !== undefined) return existing;
|
||||
|
||||
if (this.nextId >= this.maxComponents) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.COMPONENT_LIMIT_REACHED,
|
||||
"error.ecs.component_limit_reached",
|
||||
{ context: { name: schema.name, max: this.maxComponents } }
|
||||
);
|
||||
}
|
||||
|
||||
if (this.nameToSchema.has(schema.name)) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.COMPONENT_NAME_CONFLICT,
|
||||
"error.ecs.component_name_conflict",
|
||||
{ context: { name: schema.name } }
|
||||
);
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
this.schemaToId.set(schema, id);
|
||||
this.idToSchema.set(id, schema);
|
||||
this.nameToSchema.set(schema.name, schema);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Return the ID for a registered schema, or throw `ECS.COMPONENT_NOT_REGISTERED`. */
|
||||
public getId(schema: ComponentSchema): ComponentId {
|
||||
const id = this.schemaToId.get(schema);
|
||||
if (id === undefined) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.COMPONENT_NOT_REGISTERED,
|
||||
"error.ecs.component_not_registered",
|
||||
{ context: { name: schema.name } }
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Lookup schema by ID. Returns `undefined` for unknown IDs. */
|
||||
public getSchema(id: ComponentId): ComponentSchema | undefined {
|
||||
return this.idToSchema.get(id);
|
||||
}
|
||||
|
||||
/** Lookup schema by name. Returns `undefined` when no schema is registered. */
|
||||
public getSchemaByName(name: string): ComponentSchema | undefined {
|
||||
return this.nameToSchema.get(name);
|
||||
}
|
||||
|
||||
/** True when the schema reference has been registered. */
|
||||
public has(schema: ComponentSchema): boolean {
|
||||
return this.schemaToId.has(schema);
|
||||
}
|
||||
|
||||
/** Number of registered schemas. */
|
||||
public get count(): number {
|
||||
return this.nextId;
|
||||
}
|
||||
|
||||
/** Snapshot of every registered schema, in registration order. */
|
||||
public schemas(): ReadonlyArray<ComponentSchema> {
|
||||
return Array.from(this.schemaToId.keys());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ComponentRegistry } from "./component-registry.js";
|
||||
71
packages/gaming/games/ecs/src/entity-pool/entity-pool.md
Normal file
71
packages/gaming/games/ecs/src/entity-pool/entity-pool.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# EntityPool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free()`, and tracks a per-ID generation counter so callers can detect stale references to a recycled slot. The pool keeps the live-ID range dense, which is important because every alive entity occupies a bitset slot and dense IDs limit the working-set footprint.
|
||||
|
||||
The pool is owned by `World`; the class is exported only so debug tooling can read its state.
|
||||
|
||||
## Features
|
||||
|
||||
- Recycle-first allocation: freed IDs are returned ahead of fresh ones.
|
||||
- Per-ID generation counter — increments on every allocation, including reuse.
|
||||
- O(1) `allocate`, `free`, `isAlive`, and `generation`.
|
||||
- `peekNextId` / `setNextId` enable snapshot/restore of the underlying counter.
|
||||
- `clear()` resets every counter and free-list back to the initial state.
|
||||
- Defensive `setNextId` — rejects non-positive integers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EntityPool } from "@suaveplan/ecs";
|
||||
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate(); // 1
|
||||
const b = pool.allocate(); // 2
|
||||
pool.free(a);
|
||||
const recycled = pool.allocate(); // 1 — freed id comes back first
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { EntityPool } from "@suaveplan/ecs";
|
||||
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
const generationAtSpawn = pool.generation(a); // 1
|
||||
|
||||
pool.free(a);
|
||||
pool.allocate(); // recycles `a`
|
||||
const generationNow = pool.generation(a); // 2
|
||||
|
||||
// External handle compares its captured generation against the current one
|
||||
// to detect stale references to a slot that has since been recycled.
|
||||
const handleIsStale = generationAtSpawn !== generationNow;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `allocate()` | Return a fresh or recycled positive integer. Increments the entity's generation. |
|
||||
| `free(entity)` | Move `entity` to the recycled list. Returns `false` for non-alive IDs. |
|
||||
| `isAlive(entity)` | True when `entity` is currently allocated. |
|
||||
| `generation(entity)` | Generation counter. Zero for never-allocated IDs. |
|
||||
| `aliveCount` / `recycledCount` | Inspectable counts. |
|
||||
| `peekNextId` | Next fresh ID that would be assigned. |
|
||||
| `setNextId(id)` | Restore the counter from a snapshot. Rejects non-positive integers. |
|
||||
| `getAlive()` | Read-only snapshot of the live set. |
|
||||
| `clear()` | Reset every counter. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The free-list is a plain `Entity[]` used as a LIFO stack. LIFO maximises cache reuse: the most recently freed slot is the first one re-handed, so it is still likely to be hot.
|
||||
- The generation map is keyed by entity ID, not slot index, because IDs are themselves dense — using the ID as the key keeps the data structure simple.
|
||||
- `peekNextId` returns the counter even after `clear()` (which resets it to `1`). The restore path uses this to rebuild the world's counter before re-spawning entities.
|
||||
- The pool never reuses an ID that was returned by a fresh allocation but never freed — so an `aliveCount` of zero plus `recycledCount` of zero is enough to prove no stale handles exist.
|
||||
109
packages/gaming/games/ecs/src/entity-pool/entity-pool.test.ts
Normal file
109
packages/gaming/games/ecs/src/entity-pool/entity-pool.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EntityPool } from "./entity-pool.js";
|
||||
|
||||
describe("EntityPool", () => {
|
||||
it("allocate returns positive fresh ids in ascending order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
expect(pool.allocate()).toBe(1);
|
||||
expect(pool.allocate()).toBe(2);
|
||||
expect(pool.allocate()).toBe(3);
|
||||
});
|
||||
|
||||
it("allocate prefers a recycled id over a fresh id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
const b = pool.allocate();
|
||||
pool.free(a);
|
||||
const recycled = pool.allocate();
|
||||
expect(recycled).toBe(a);
|
||||
expect(b).toBe(2);
|
||||
});
|
||||
|
||||
it("generation increments on every allocation of the same id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
expect(pool.generation(a)).toBe(1);
|
||||
pool.free(a);
|
||||
pool.allocate();
|
||||
expect(pool.generation(a)).toBe(2);
|
||||
});
|
||||
|
||||
it("free returns false for a non-alive entity", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
expect(pool.free(999)).toBe(false);
|
||||
const a = pool.allocate();
|
||||
expect(pool.free(a)).toBe(true);
|
||||
expect(pool.free(a)).toBe(false);
|
||||
});
|
||||
|
||||
it("isAlive reflects the alive set", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
expect(pool.isAlive(a)).toBe(true);
|
||||
pool.free(a);
|
||||
expect(pool.isAlive(a)).toBe(false);
|
||||
});
|
||||
|
||||
it("generation returns 0 for never-allocated entities", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
expect(pool.generation(42)).toBe(0);
|
||||
});
|
||||
|
||||
it("aliveCount and recycledCount track the pool state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
pool.allocate();
|
||||
const b = pool.allocate();
|
||||
expect(pool.aliveCount).toBe(2);
|
||||
expect(pool.recycledCount).toBe(0);
|
||||
pool.free(b);
|
||||
expect(pool.aliveCount).toBe(1);
|
||||
expect(pool.recycledCount).toBe(1);
|
||||
});
|
||||
|
||||
it("peekNextId and setNextId round-trip", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
pool.allocate();
|
||||
pool.allocate();
|
||||
expect(pool.peekNextId).toBe(3);
|
||||
pool.setNextId(100);
|
||||
expect(pool.allocate()).toBe(100);
|
||||
});
|
||||
|
||||
it("setNextId rejects non-positive integers", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
expect(() => pool.setNextId(0)).toThrow(RangeError);
|
||||
expect(() => pool.setNextId(-1)).toThrow(RangeError);
|
||||
expect(() => pool.setNextId(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("getAlive returns a read-only snapshot of the live set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
const b = pool.allocate();
|
||||
expect(pool.getAlive().has(a)).toBe(true);
|
||||
expect(pool.getAlive().has(b)).toBe(true);
|
||||
});
|
||||
|
||||
it("clear resets every internal counter", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
pool.allocate();
|
||||
pool.allocate();
|
||||
pool.clear();
|
||||
expect(pool.aliveCount).toBe(0);
|
||||
expect(pool.recycledCount).toBe(0);
|
||||
expect(pool.peekNextId).toBe(1);
|
||||
expect(pool.allocate()).toBe(1);
|
||||
});
|
||||
});
|
||||
85
packages/gaming/games/ecs/src/entity-pool/entity-pool.ts
Normal file
85
packages/gaming/games/ecs/src/entity-pool/entity-pool.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Entity } from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* Entity-ID allocator with recycling and per-ID generation counters.
|
||||
*
|
||||
* Despawned IDs go onto a free-list and are preferred for the next
|
||||
* allocation, keeping the live-id range dense. Every allocation bumps a
|
||||
* generation counter so callers can detect references to a recycled slot.
|
||||
*/
|
||||
export class EntityPool {
|
||||
private nextId = 1;
|
||||
private readonly recycled: Entity[] = [];
|
||||
private readonly alive = new Set<Entity>();
|
||||
private readonly generations = new Map<Entity, number>();
|
||||
|
||||
/** Allocate a fresh entity ID, preferring recycled IDs. */
|
||||
public allocate(): Entity {
|
||||
let id: Entity;
|
||||
const recycled = this.recycled.pop();
|
||||
if (recycled !== undefined) {
|
||||
id = recycled;
|
||||
} else {
|
||||
id = this.nextId++;
|
||||
}
|
||||
this.alive.add(id);
|
||||
this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Return a live entity to the pool. Returns `false` for non-alive IDs. */
|
||||
public free(entity: Entity): boolean {
|
||||
if (!this.alive.has(entity)) return false;
|
||||
this.alive.delete(entity);
|
||||
this.recycled.push(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when `entity` is currently alive. */
|
||||
public isAlive(entity: Entity): boolean {
|
||||
return this.alive.has(entity);
|
||||
}
|
||||
|
||||
/** Generation counter for `entity`. Zero if never allocated. */
|
||||
public generation(entity: Entity): number {
|
||||
return this.generations.get(entity) ?? 0;
|
||||
}
|
||||
|
||||
/** Number of alive entities. */
|
||||
public get aliveCount(): number {
|
||||
return this.alive.size;
|
||||
}
|
||||
|
||||
/** Number of IDs on the recycled free-list. */
|
||||
public get recycledCount(): number {
|
||||
return this.recycled.length;
|
||||
}
|
||||
|
||||
/** Next fresh ID that will be assigned. Used by `World.snapshot`. */
|
||||
public get peekNextId(): number {
|
||||
return this.nextId;
|
||||
}
|
||||
|
||||
/** Restore the `nextId` counter from a snapshot. Generations reset. */
|
||||
public setNextId(id: number): void {
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
throw new RangeError(
|
||||
`nextId must be a positive integer, got ${id}`
|
||||
);
|
||||
}
|
||||
this.nextId = id;
|
||||
}
|
||||
|
||||
/** Snapshot of currently alive entities. Read-only view. */
|
||||
public getAlive(): ReadonlySet<Entity> {
|
||||
return this.alive;
|
||||
}
|
||||
|
||||
/** Clear all state — alive set, free-list, generations, and counter. */
|
||||
public clear(): void {
|
||||
this.alive.clear();
|
||||
this.recycled.length = 0;
|
||||
this.generations.clear();
|
||||
this.nextId = 1;
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/entity-pool/index.ts
Normal file
1
packages/gaming/games/ecs/src/entity-pool/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EntityPool } from "./entity-pool.js";
|
||||
83
packages/gaming/games/ecs/src/errors/ecs-error.md
Normal file
83
packages/gaming/games/ecs/src/errors/ecs-error.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# EcsError & ECS_CODES
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from `@suaveplan/error`, which means every thrown error carries a stable, fully-qualified `code` string and a structured `context` object documenting the inputs at the failure site. Callers can discriminate failures from this package either by `instanceof EcsError` or by inspecting `error.code`.
|
||||
|
||||
`ECS_CODES` is the canonical registry of every code this package raises. The registry is generated through `defineCodes("ECS", [...])` from `@suaveplan/error`, which fully-qualifies the string literals (e.g., `"ECS.ENTITY_NOT_ALIVE"`) and exposes them as a frozen object.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable, fully-qualified codes — `ECS.ENTITY_NOT_ALIVE`, `ECS.COMPONENT_NOT_REGISTERED`, `ECS.COMPONENT_NAME_CONFLICT`, `ECS.COMPONENT_LIMIT_REACHED`, `ECS.COMPONENT_VALIDATION_FAILED`, `ECS.SYSTEM_NAME_CONFLICT`, `ECS.SNAPSHOT_INVALID`.
|
||||
- Every code is exercised by `src/errors/ecs-error.test.ts` from a real production code path — registry-to-throw-site coverage is a test invariant.
|
||||
- Structured `context` field on every error (`{ entity }`, `{ name }`, `{ issues }`, etc.) so the wire-redacted error envelope remains useful for debugging.
|
||||
- Cause chain preserved through `SuaveplanError`'s base support.
|
||||
- Code type `EcsCode` is a literal union narrowable in `switch` statements.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, ECS_CODES, EcsError } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.despawn(entity);
|
||||
|
||||
try {
|
||||
world.addComponent(entity, Position);
|
||||
} catch (err) {
|
||||
if (err instanceof EcsError && err.code === ECS_CODES.ENTITY_NOT_ALIVE) {
|
||||
console.error("entity was already despawned");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ECS_CODES, EcsError } from "@suaveplan/ecs";
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (!(err instanceof EcsError)) return false;
|
||||
switch (err.code) {
|
||||
case ECS_CODES.ENTITY_NOT_ALIVE:
|
||||
case ECS_CODES.SNAPSHOT_INVALID:
|
||||
return false;
|
||||
case ECS_CODES.COMPONENT_VALIDATION_FAILED:
|
||||
return false;
|
||||
case ECS_CODES.COMPONENT_LIMIT_REACHED:
|
||||
case ECS_CODES.COMPONENT_NAME_CONFLICT:
|
||||
case ECS_CODES.COMPONENT_NOT_REGISTERED:
|
||||
case ECS_CODES.SYSTEM_NAME_CONFLICT:
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Description |
|
||||
|---|---|
|
||||
| `EcsError` | `SuaveplanError`-extending class. Constructor: `new EcsError(code, messageKey, options?)`. |
|
||||
| `EcsCode` | Type union over every registered code string. |
|
||||
| `ECS_CODES` | Frozen registry returned by `defineCodes`. |
|
||||
| `ECS_CODES.ENTITY_NOT_ALIVE` | Thrown from `addComponent` / `removeComponent` / `setComponent` for despawned entities. |
|
||||
| `ECS_CODES.COMPONENT_NOT_REGISTERED` | `registry.getId(schema)` on an unknown schema. |
|
||||
| `ECS_CODES.COMPONENT_NAME_CONFLICT` | Two distinct schemas sharing a `name`. |
|
||||
| `ECS_CODES.COMPONENT_LIMIT_REACHED` | `register` past `maxComponents`. |
|
||||
| `ECS_CODES.COMPONENT_VALIDATION_FAILED` | Zod validator rejected merged component data. |
|
||||
| `ECS_CODES.SYSTEM_NAME_CONFLICT` | `addSystem` with a name already registered. |
|
||||
| `ECS_CODES.SNAPSHOT_INVALID` | `restore` payload failed `WorldSnapshotSchema`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Codes are namespaced under `ECS.` so cross-package error log aggregation can filter by the `ECS.*` prefix.
|
||||
- `EcsError` does not override `Error.prototype.toJSON` — `SuaveplanError` already exposes `toWire` / `toSpanEvent` for serialisation, and we don't extend the contract.
|
||||
- The test suite enumerates `Object.values(ECS_CODES)` and triggers every code through a production call. If a new code is added without a real throw site, the test will fail — this is the registered-but-unthrown drift guard required by the spec-conformance checklist (`AGENTS.md` §13).
|
||||
100
packages/gaming/games/ecs/src/errors/ecs-error.test.ts
Normal file
100
packages/gaming/games/ecs/src/errors/ecs-error.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { defineSystem } from "../system/system.js";
|
||||
import { createWorld } from "../world/world.js";
|
||||
import { ECS_CODES, type EcsCode, EcsError } from "./ecs-error.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0 });
|
||||
const Health = defineComponent(
|
||||
"Health",
|
||||
{ hp: 100 },
|
||||
z.object({ hp: z.number().min(0) })
|
||||
);
|
||||
|
||||
/**
|
||||
* Force-trigger every registered `ECS.*` code from a production path so the
|
||||
* "every code is reachable" guarantee is enforced by the test suite.
|
||||
*/
|
||||
const triggers: Record<EcsCode, () => void> = {
|
||||
[ECS_CODES.ENTITY_NOT_ALIVE]: () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.despawn(entity);
|
||||
world.addComponent(entity, Position);
|
||||
},
|
||||
[ECS_CODES.COMPONENT_NOT_REGISTERED]: () => {
|
||||
const world = createWorld();
|
||||
world.hasComponent(world.spawn(), Position);
|
||||
},
|
||||
[ECS_CODES.COMPONENT_NAME_CONFLICT]: () => {
|
||||
const world = createWorld();
|
||||
const PositionA = defineComponent("Position", { x: 0 });
|
||||
const PositionB = defineComponent("Position", { x: 1 });
|
||||
world.registerComponent(PositionA);
|
||||
world.registerComponent(PositionB);
|
||||
},
|
||||
[ECS_CODES.COMPONENT_LIMIT_REACHED]: () => {
|
||||
const world = createWorld({ maxComponents: 1 });
|
||||
world.registerComponent(Position);
|
||||
world.registerComponent(Velocity);
|
||||
},
|
||||
[ECS_CODES.COMPONENT_VALIDATION_FAILED]: () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Health, { hp: -10 });
|
||||
},
|
||||
[ECS_CODES.SYSTEM_NAME_CONFLICT]: () => {
|
||||
const world = createWorld();
|
||||
const sys = defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
});
|
||||
world.addSystem(sys);
|
||||
world.addSystem(sys);
|
||||
},
|
||||
[ECS_CODES.SNAPSHOT_INVALID]: () => {
|
||||
const world = createWorld();
|
||||
world.restore({ entities: [], nextEntityId: -1 });
|
||||
},
|
||||
};
|
||||
|
||||
describe("EcsError", () => {
|
||||
it("every ECS_CODES entry is thrown by at least one production code path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(ECS_CODES)) {
|
||||
let caught: unknown;
|
||||
try {
|
||||
triggers[code]();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(EcsError);
|
||||
expect((caught as EcsError).code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("EcsError carries the supplied context", { timeout: 5000 }, () => {
|
||||
const err = new EcsError(
|
||||
ECS_CODES.ENTITY_NOT_ALIVE,
|
||||
"error.ecs.entity_not_alive",
|
||||
{ context: { entity: 7 } }
|
||||
);
|
||||
expect(err.code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
|
||||
expect(err.context?.entity).toBe(7);
|
||||
expect(err.name).toBe("EcsError");
|
||||
});
|
||||
|
||||
it("EcsError preserves the cause chain", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new EcsError(
|
||||
ECS_CODES.SNAPSHOT_INVALID,
|
||||
"error.ecs.snapshot_invalid",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
51
packages/gaming/games/ecs/src/errors/ecs-error.ts
Normal file
51
packages/gaming/games/ecs/src/errors/ecs-error.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/ecs`.
|
||||
*
|
||||
* Codes follow the pattern `ECS.<DOMAIN>` and are resolved via
|
||||
* `@suaveplan/error`'s `defineCodes` registry. Use the fully-qualified
|
||||
* strings (e.g. `"ECS.ENTITY_NOT_ALIVE"`) for stable `error.code`
|
||||
* discrimination without relying on `instanceof`.
|
||||
*/
|
||||
export const ECS_CODES = defineCodes("ECS", [
|
||||
"ENTITY_NOT_ALIVE",
|
||||
"COMPONENT_NOT_REGISTERED",
|
||||
"COMPONENT_NAME_CONFLICT",
|
||||
"COMPONENT_LIMIT_REACHED",
|
||||
"COMPONENT_VALIDATION_FAILED",
|
||||
"SYSTEM_NAME_CONFLICT",
|
||||
"SNAPSHOT_INVALID",
|
||||
] as const);
|
||||
|
||||
export type EcsCode = (typeof ECS_CODES)[keyof typeof ECS_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/ecs` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so callers can discriminate failures from this
|
||||
* package via the stable `error.code` property or `error instanceof EcsError`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { ECS_CODES, EcsError } from "@suaveplan/ecs";
|
||||
*
|
||||
* try {
|
||||
* world.addComponent(deadEntity, Position);
|
||||
* } catch (err) {
|
||||
* if (err instanceof EcsError && err.code === ECS_CODES.ENTITY_NOT_ALIVE) {
|
||||
* console.error("entity was already despawned");
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class EcsError extends SuaveplanError<EcsCode> {
|
||||
public constructor(
|
||||
code: EcsCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "EcsError";
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/errors/index.ts
Normal file
1
packages/gaming/games/ecs/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ECS_CODES, type EcsCode, EcsError } from "./ecs-error.js";
|
||||
42
packages/gaming/games/ecs/src/index.ts
Normal file
42
packages/gaming/games/ecs/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export { Archetype } from "./archetype/archetype.js";
|
||||
export { BitSet } from "./bitset/bitset.js";
|
||||
export { ComponentRegistry } from "./component-registry/component-registry.js";
|
||||
export { EntityPool } from "./entity-pool/entity-pool.js";
|
||||
export { ECS_CODES, type EcsCode, EcsError } from "./errors/ecs-error.js";
|
||||
export {
|
||||
entityLifecycleCounter,
|
||||
executeDurationHistogram,
|
||||
recordExecuteDuration,
|
||||
recordLifecycle,
|
||||
} from "./observability/otel.js";
|
||||
export { defineQuery, Query } from "./query/query.js";
|
||||
export { defineComponent } from "./schema/schema.js";
|
||||
export {
|
||||
EntityComponentSnapshotSchema,
|
||||
EntitySnapshotSchema,
|
||||
SystemPhaseSchema,
|
||||
WorldConfigSchema,
|
||||
WorldSnapshotSchema,
|
||||
} from "./schemas/world.schemas.js";
|
||||
export {
|
||||
type DefineSystemOptions,
|
||||
defineSystem,
|
||||
SystemScheduler,
|
||||
} from "./system/system.js";
|
||||
export type {
|
||||
ArchetypeId,
|
||||
ComponentData,
|
||||
ComponentId,
|
||||
ComponentSchema,
|
||||
Entity,
|
||||
EntitySnapshot,
|
||||
QueryDescriptor,
|
||||
SystemContext,
|
||||
SystemDefinition,
|
||||
SystemExecuteFn,
|
||||
SystemPhase,
|
||||
WorldApi,
|
||||
WorldEventMap,
|
||||
WorldSnapshot,
|
||||
} from "./types/ecs.types.js";
|
||||
export { createWorld, World, type WorldConfig } from "./world/world.js";
|
||||
6
packages/gaming/games/ecs/src/observability/index.ts
Normal file
6
packages/gaming/games/ecs/src/observability/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
entityLifecycleCounter,
|
||||
executeDurationHistogram,
|
||||
recordExecuteDuration,
|
||||
recordLifecycle,
|
||||
} from "./otel.js";
|
||||
65
packages/gaming/games/ecs/src/observability/otel.md
Normal file
65
packages/gaming/games/ecs/src/observability/otel.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# OpenTelemetry Instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_total` (counter) and `ecs.world.execute_duration_ms` (histogram) — plus the helper functions `recordLifecycle` and `recordExecuteDuration` that the world calls from its mutation paths. Instruments are bound to a single meter (`@suaveplan/ecs`) resolved through the global `MeterProvider` at module load.
|
||||
|
||||
When no SDK is registered (e.g. plain production deploys without an OTel collector), the OTel API returns a no-op meter and recording is free. When tests register an in-memory `MeterProvider` (`tests/setup/polyfills.ts`), the same instruments emit real data points readable through the test exporter.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `Counter` (`ecs.entity.lifecycle_total`) keyed by `op ∈ {spawn, despawn}`.
|
||||
- Single `Histogram` (`ecs.world.execute_duration_ms`) in milliseconds.
|
||||
- Stable meter name `@suaveplan/ecs` for filtering in dashboards.
|
||||
- Helper functions wrap `.add` / `.record` so call sites stay terse.
|
||||
- No SDK dependency at runtime — `@opentelemetry/api` is the only import.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordLifecycle, recordExecuteDuration } from "@suaveplan/ecs";
|
||||
|
||||
recordLifecycle("spawn");
|
||||
recordExecuteDuration(0.42);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider, PeriodicExportingMetricReader, InMemoryMetricExporter, AggregationTemporality } from "@opentelemetry/sdk-metrics";
|
||||
import { createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
|
||||
|
||||
const world = createWorld();
|
||||
world.spawn();
|
||||
world.execute(0.016);
|
||||
|
||||
// later
|
||||
const snapshots = await /* meterProvider */;
|
||||
// Inspect the spawn / despawn counter and the execute duration histogram.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `entityLifecycleCounter` | Raw `Counter` instrument. Attribute `op ∈ {spawn, despawn}`. |
|
||||
| `executeDurationHistogram` | Raw `Histogram` instrument; unit `"ms"`. |
|
||||
| `recordLifecycle(op)` | Increment the counter for a spawn or despawn. |
|
||||
| `recordExecuteDuration(durationMs)` | Record a single execute-tick duration. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The meter is resolved at module load via `metrics.getMeter("@suaveplan/ecs")`. The OTel API guarantees a stable meter reference: when a real SDK is later registered, *previously created* instruments continue to emit through the new provider. This is the documented "no-op-then-bind" pattern.
|
||||
- The histogram unit is `"ms"`, matching `performance.now()`. The world records the wall-clock difference between the start and end of `execute(dt)`.
|
||||
- Counter attributes are keyed by `op`, not by code-path, so dashboards can sum spawn + despawn as a single series or split per-op trivially.
|
||||
- Tests should reset the in-memory exporter between cases (`testInMemoryMetricExporter.reset()`) to avoid cross-test bleed; the package's `world.spec-conformance.test.ts` does this in an `afterEach` hook.
|
||||
- No spans are created here — the histogram is sufficient for measuring execute duration, and span-creation overhead would dominate the metric it's meant to observe. If consumers want per-system spans, they can wrap `execute` in their own tracer.
|
||||
38
packages/gaming/games/ecs/src/observability/otel.ts
Normal file
38
packages/gaming/games/ecs/src/observability/otel.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/ecs";
|
||||
|
||||
/**
|
||||
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
|
||||
* first access. When no provider is registered (i.e. production without an
|
||||
* SDK or tests without the polyfill), the OTel API returns a no-op meter.
|
||||
*/
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/** Counter for entity-lifecycle events; attribute `op` ∈ `{spawn, despawn}`. */
|
||||
export const entityLifecycleCounter: Counter = meter.createCounter(
|
||||
"ecs.entity.lifecycle_total",
|
||||
{
|
||||
description:
|
||||
"Total spawn/despawn events emitted by a World, attribute op ∈ {spawn, despawn}",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram recording `World.execute` duration in milliseconds. */
|
||||
export const executeDurationHistogram: Histogram = meter.createHistogram(
|
||||
"ecs.world.execute_duration_ms",
|
||||
{
|
||||
description: "Duration of a World.execute tick in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Record a spawn or despawn lifecycle event. */
|
||||
export function recordLifecycle(op: "spawn" | "despawn"): void {
|
||||
entityLifecycleCounter.add(1, { op });
|
||||
}
|
||||
|
||||
/** Record an execute-tick duration. */
|
||||
export function recordExecuteDuration(durationMs: number): void {
|
||||
executeDurationHistogram.record(durationMs);
|
||||
}
|
||||
4
packages/gaming/games/ecs/src/query/index.ts
Normal file
4
packages/gaming/games/ecs/src/query/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
defineQuery,
|
||||
Query,
|
||||
} from "./query.js";
|
||||
84
packages/gaming/games/ecs/src/query/query.md
Normal file
84
packages/gaming/games/ecs/src/query/query.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Query
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluates it against the archetype graph. Because archetypes group entities by their exact component set, a query only needs to test each archetype's mask once — every entity inside a matching archetype is automatically a match. The result is O(archetypes) filtering plus O(entities-in-matching-archetypes) collection.
|
||||
|
||||
`defineQuery` is the canonical factory; raw `Query` instances are exposed for advanced consumers building custom system runners.
|
||||
|
||||
## Features
|
||||
|
||||
- Bitset-only match predicate (`containsAll` + `intersects`).
|
||||
- Cached `matchedArchetypes` list, invalidated by `World` whenever archetypes change.
|
||||
- Handles include-only, include + exclude, and empty-exclude descriptors cleanly.
|
||||
- Result is a flat `ReadonlyArray<Entity>` in archetype-discovery order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineComponent, defineQuery, createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
|
||||
const result = world.query(defineQuery([Position]));
|
||||
console.log([...result]); // [1]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineComponent,
|
||||
defineQuery,
|
||||
Query,
|
||||
createWorld,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", {});
|
||||
const Disabled = defineComponent("Disabled", {});
|
||||
|
||||
const world = createWorld();
|
||||
world.registerComponent(Position);
|
||||
world.registerComponent(Disabled);
|
||||
|
||||
// Build a long-lived Query and re-evaluate on every tick.
|
||||
const activeMovers = new Query(
|
||||
defineQuery([Position], [Disabled]),
|
||||
world.registry,
|
||||
);
|
||||
|
||||
function tick() {
|
||||
const entities = activeMovers.evaluate(
|
||||
// archetype graph is exposed indirectly via world.query; advanced
|
||||
// consumers run their own archetype scans through reflection.
|
||||
[],
|
||||
);
|
||||
// ... iterate entities ...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new Query(descriptor, registry)` | Compile masks from a descriptor. Throws if descriptor references unregistered schemas. |
|
||||
| `defineQuery(include, exclude?)` | Build a descriptor with optional `exclude`. |
|
||||
| `matches(archetype)` | Predicate: does this archetype's mask satisfy the query? |
|
||||
| `evaluate(archetypes)` | Collect every entity from every matching archetype. Result is cached until `invalidate()`. |
|
||||
| `invalidate()` | Drop the cached match list; the next `evaluate` rebuilds it. |
|
||||
| `includeMask` / `excludeMask` | Read-only masks for debug / inspection. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `excludeMask` is `null` (not an empty `BitSet`) when no exclude is declared, so the predicate skips the `intersects` test entirely in the common case.
|
||||
- Empty `exclude: []` is treated as "no exclude" — the descriptor normalises the difference.
|
||||
- The world owns its `Query` objects; one-off `world.query(descriptor)` calls build an ad-hoc `Query` per call and discard it. Long-lived systems should pin a `Query` instance and call `evaluate` repeatedly.
|
||||
- The cached `matchedArchetypes` list is invalidated whenever the archetype graph mutates (component add/remove that creates a new archetype, or `clear`). Mutations within an existing archetype do not invalidate — entity lists are read fresh on every evaluate.
|
||||
- Iteration order is "every entity in archetype 0, then every entity in archetype 1, …". Within an archetype the order matches insertion order (subject to swap-remove side-effects).
|
||||
103
packages/gaming/games/ecs/src/query/query.test.ts
Normal file
103
packages/gaming/games/ecs/src/query/query.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Archetype } from "../archetype/archetype.js";
|
||||
import { BitSet } from "../bitset/bitset.js";
|
||||
import { ComponentRegistry } from "../component-registry/component-registry.js";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { defineQuery, Query } from "./query.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0 });
|
||||
const Disabled = defineComponent("Disabled", { d: true });
|
||||
|
||||
function makeArch(
|
||||
registry: ComponentRegistry,
|
||||
schemas: (typeof Position)[]
|
||||
): Archetype {
|
||||
const mask = new BitSet(registry.maxComponents);
|
||||
const ids: number[] = [];
|
||||
for (const s of schemas) {
|
||||
const id = registry.getId(s);
|
||||
mask.set(id);
|
||||
ids.push(id);
|
||||
}
|
||||
ids.sort((a, b) => a - b);
|
||||
return new Archetype(mask, ids);
|
||||
}
|
||||
|
||||
describe("Query", () => {
|
||||
it("include-only matches every superset archetype", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
reg.register(Velocity);
|
||||
const arch1 = makeArch(reg, [Position]);
|
||||
const arch2 = makeArch(reg, [Position, Velocity]);
|
||||
const arch3 = makeArch(reg, [Velocity]);
|
||||
for (const id of [10, 11]) arch1.addEntity(id, new Map());
|
||||
arch2.addEntity(20, new Map());
|
||||
arch3.addEntity(30, new Map());
|
||||
|
||||
const q = new Query(defineQuery([Position]), reg);
|
||||
const result = q.evaluate([arch1, arch2, arch3]);
|
||||
expect([...result].sort((a, b) => a - b)).toEqual([10, 11, 20]);
|
||||
});
|
||||
|
||||
it("exclude filters out archetypes whose mask intersects the exclude mask", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
reg.register(Disabled);
|
||||
const archEnabled = makeArch(reg, [Position]);
|
||||
const archDisabled = makeArch(reg, [Position, Disabled]);
|
||||
archEnabled.addEntity(1, new Map());
|
||||
archDisabled.addEntity(2, new Map());
|
||||
|
||||
const q = new Query(defineQuery([Position], [Disabled]), reg);
|
||||
const result = q.evaluate([archEnabled, archDisabled]);
|
||||
expect([...result]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("invalidate clears the cached match list and forces a re-scan", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
reg.register(Velocity);
|
||||
const arch1 = makeArch(reg, [Position]);
|
||||
arch1.addEntity(1, new Map());
|
||||
|
||||
const archetypes: Archetype[] = [arch1];
|
||||
const q = new Query(defineQuery([Position]), reg);
|
||||
expect(q.evaluate(archetypes)).toEqual([1]);
|
||||
|
||||
const arch2 = makeArch(reg, [Position, Velocity]);
|
||||
arch2.addEntity(2, new Map());
|
||||
archetypes.push(arch2);
|
||||
|
||||
// Without invalidate, the new archetype is not reconsidered.
|
||||
expect(q.evaluate(archetypes)).toEqual([1]);
|
||||
q.invalidate();
|
||||
expect([...q.evaluate(archetypes)].sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("defineQuery omits exclude when not supplied", { timeout: 5000 }, () => {
|
||||
const descriptor = defineQuery([Position]);
|
||||
expect(descriptor.exclude).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defineQuery includes exclude when provided", { timeout: 5000 }, () => {
|
||||
const descriptor = defineQuery([Position], [Disabled]);
|
||||
expect(descriptor.exclude).toEqual([Disabled]);
|
||||
});
|
||||
|
||||
it("empty exclude array is treated as no exclude", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
const q = new Query({ include: [Position], exclude: [] }, reg);
|
||||
expect(q.excludeMask).toBeNull();
|
||||
});
|
||||
});
|
||||
87
packages/gaming/games/ecs/src/query/query.ts
Normal file
87
packages/gaming/games/ecs/src/query/query.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Archetype } from "../archetype/archetype.js";
|
||||
import { BitSet } from "../bitset/bitset.js";
|
||||
import type { ComponentRegistry } from "../component-registry/component-registry.js";
|
||||
import type { Entity, QueryDescriptor } from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* Compiled query matching archetypes by include/exclude masks.
|
||||
*
|
||||
* Queries cache the list of matched archetypes between mutations. The world
|
||||
* calls `invalidate()` whenever the archetype graph changes so the next
|
||||
* `evaluate` re-scans.
|
||||
*/
|
||||
export class Query {
|
||||
public readonly includeMask: BitSet;
|
||||
public readonly excludeMask: BitSet | null;
|
||||
|
||||
private matchedArchetypes: Archetype[] = [];
|
||||
private dirty = true;
|
||||
|
||||
public constructor(
|
||||
descriptor: QueryDescriptor,
|
||||
registry: ComponentRegistry
|
||||
) {
|
||||
this.includeMask = new BitSet(registry.maxComponents);
|
||||
for (const schema of descriptor.include) {
|
||||
this.includeMask.set(registry.getId(schema));
|
||||
}
|
||||
|
||||
if (descriptor.exclude !== undefined && descriptor.exclude.length > 0) {
|
||||
this.excludeMask = new BitSet(registry.maxComponents);
|
||||
for (const schema of descriptor.exclude) {
|
||||
this.excludeMask.set(registry.getId(schema));
|
||||
}
|
||||
} else {
|
||||
this.excludeMask = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when an archetype matches this query's include/exclude masks. */
|
||||
public matches(archetype: Archetype): boolean {
|
||||
if (!archetype.mask.containsAll(this.includeMask)) return false;
|
||||
if (
|
||||
this.excludeMask !== null &&
|
||||
archetype.mask.intersects(this.excludeMask)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Mark the cached match list dirty. */
|
||||
public invalidate(): void {
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
/** Collect every matching entity across all archetypes. */
|
||||
public evaluate(
|
||||
archetypes: ReadonlyArray<Archetype>
|
||||
): ReadonlyArray<Entity> {
|
||||
if (this.dirty) {
|
||||
this.matchedArchetypes = [];
|
||||
for (const arch of archetypes) {
|
||||
if (this.matches(arch)) {
|
||||
this.matchedArchetypes.push(arch);
|
||||
}
|
||||
}
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
const result: Entity[] = [];
|
||||
for (const arch of this.matchedArchetypes) {
|
||||
const entities = arch.getEntities();
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
result.push(entities[i] as Entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a {@link QueryDescriptor} from component schemas. */
|
||||
export function defineQuery(
|
||||
include: QueryDescriptor["include"],
|
||||
exclude?: QueryDescriptor["exclude"]
|
||||
): QueryDescriptor {
|
||||
return exclude !== undefined ? { include, exclude } : { include };
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/schema/index.ts
Normal file
1
packages/gaming/games/ecs/src/schema/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { defineComponent } from "./schema.js";
|
||||
65
packages/gaming/games/ecs/src/schema/schema.md
Normal file
65
packages/gaming/games/ecs/src/schema/schema.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# defineComponent
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing a stable `name`, a frozen `defaults` value, and an optional Zod validator. The descriptor is the unit the registry, world, and systems treat as a component's public identity — passing the same descriptor reference through the API is what proves two call sites mean the same component.
|
||||
|
||||
The optional Zod validator opts the descriptor into runtime validation: `World.addComponent` and `World.setComponent` will reject component data that fails the schema with `ECS.COMPONENT_VALIDATION_FAILED`, capturing the Zod issues in `error.context`.
|
||||
|
||||
## Features
|
||||
|
||||
- Deep-freeze on the descriptor and its `defaults` — strict-mode mutation throws.
|
||||
- Defaults are *copied* on construction, so caller mutations after `defineComponent` don't leak into the descriptor.
|
||||
- Optional `schema?: z.ZodType<T>` validator threaded through to the world's validation step.
|
||||
- specific addition over upstream's `defineComponent` — upstream lacked the Zod hook.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0, z: 0 });
|
||||
console.log(Position.name); // "Position"
|
||||
console.log(Position.defaults); // { x: 0, y: 0, z: 0 } (frozen)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { defineComponent, createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const Health = defineComponent(
|
||||
"Health",
|
||||
{ current: 100, max: 100 },
|
||||
z.object({
|
||||
current: z.number().nonnegative(),
|
||||
max: z.number().positive(),
|
||||
}),
|
||||
);
|
||||
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
// Throws EcsError(code = ECS.COMPONENT_VALIDATION_FAILED)
|
||||
world.addComponent(entity, Health, { current: -1, max: 100 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Signature | Description |
|
||||
|---|---|
|
||||
| `defineComponent<T>(name, defaults, schema?)` | Build a frozen `ComponentSchema<T>` with optional Zod validator. |
|
||||
| `ComponentSchema.name` | Stable string identity. |
|
||||
| `ComponentSchema.defaults` | Frozen default value, deep-copied. |
|
||||
| `ComponentSchema.schema?` | Optional Zod validator for runtime checks inside `World.addComponent` / `World.setComponent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The defaults clone is shallow (`{ ...defaults }`). For nested mutable references, callers should freeze deeply themselves before passing in. The component schema is identity-stable, but interior mutability of a passed reference is out of scope.
|
||||
- The schema property is added conditionally (omitted from the object when not supplied) so JSON serialisation of a descriptor produces a clean two-key shape when no validator is supplied.
|
||||
- Validation is *not* applied at registration — only at component-add / component-set boundaries. This keeps the registry decoupled from Zod and means a schema can be retrofitted onto an existing component without rewiring registration.
|
||||
- The factory does not generate IDs; the registry assigns those on first registration via `World.registerComponent` or implicitly during `addComponent`.
|
||||
42
packages/gaming/games/ecs/src/schema/schema.test.ts
Normal file
42
packages/gaming/games/ecs/src/schema/schema.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { defineComponent } from "./schema.js";
|
||||
|
||||
describe("defineComponent", () => {
|
||||
it("returns a frozen descriptor with frozen defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
expect(Object.isFrozen(Position)).toBe(true);
|
||||
expect(Object.isFrozen(Position.defaults)).toBe(true);
|
||||
expect(() => {
|
||||
(Position.defaults as { x: number }).x = 5;
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("exposes the name and defaults", { timeout: 5000 }, () => {
|
||||
const Health = defineComponent("Health", { hp: 100, max: 100 });
|
||||
expect(Health.name).toBe("Health");
|
||||
expect(Health.defaults).toEqual({ hp: 100, max: 100 });
|
||||
});
|
||||
|
||||
it("omits schema when not supplied", { timeout: 5000 }, () => {
|
||||
const Position = defineComponent("Position", { x: 0 });
|
||||
expect(Position.schema).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes a supplied Zod schema", { timeout: 5000 }, () => {
|
||||
const schema = z.object({ hp: z.number().min(0) });
|
||||
const Health = defineComponent("Health", { hp: 100 }, schema);
|
||||
expect(Health.schema).toBe(schema);
|
||||
});
|
||||
|
||||
it("defaults are copied so caller mutations cannot leak in", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const draft = { x: 0, y: 0 };
|
||||
const Position = defineComponent("Position", draft);
|
||||
draft.x = 5;
|
||||
expect(Position.defaults).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
33
packages/gaming/games/ecs/src/schema/schema.ts
Normal file
33
packages/gaming/games/ecs/src/schema/schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type z from "zod";
|
||||
import type { ComponentData, ComponentSchema } from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* Build a frozen {@link ComponentSchema}.
|
||||
*
|
||||
* The returned object and its `defaults` are deep-frozen so accidental
|
||||
* mutation is rejected in strict mode. Supplying a Zod `schema` opts the
|
||||
* descriptor into runtime validation inside `World.addComponent` and
|
||||
* `World.setComponent`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const Position = defineComponent("Position", { x: 0, y: 0, z: 0 });
|
||||
* const Health = defineComponent(
|
||||
* "Health",
|
||||
* { current: 100, max: 100 },
|
||||
* z.object({ current: z.number().nonnegative(), max: z.number().positive() }),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function defineComponent<T extends ComponentData>(
|
||||
name: string,
|
||||
defaults: T,
|
||||
schema?: z.ZodType<T>
|
||||
): ComponentSchema<T> {
|
||||
const frozen: ComponentSchema<T> = {
|
||||
name,
|
||||
defaults: Object.freeze({ ...defaults }),
|
||||
...(schema !== undefined ? { schema } : {}),
|
||||
};
|
||||
return Object.freeze(frozen);
|
||||
}
|
||||
7
packages/gaming/games/ecs/src/schemas/index.ts
Normal file
7
packages/gaming/games/ecs/src/schemas/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
EntityComponentSnapshotSchema,
|
||||
EntitySnapshotSchema,
|
||||
SystemPhaseSchema,
|
||||
WorldConfigSchema,
|
||||
WorldSnapshotSchema,
|
||||
} from "./world.schemas.js";
|
||||
34
packages/gaming/games/ecs/src/schemas/world.schemas.ts
Normal file
34
packages/gaming/games/ecs/src/schemas/world.schemas.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Zod schema for a single snapshotted component entry. */
|
||||
export const EntityComponentSnapshotSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
/** Zod schema for a single snapshotted entity. */
|
||||
export const EntitySnapshotSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
components: z.array(EntityComponentSnapshotSchema),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link WorldSnapshot}. */
|
||||
export const WorldSnapshotSchema = z.object({
|
||||
entities: z.array(EntitySnapshotSchema),
|
||||
nextEntityId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link WorldConfig}. */
|
||||
export const WorldConfigSchema = z
|
||||
.object({
|
||||
maxComponents: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod schema for {@link SystemPhase}. */
|
||||
export const SystemPhaseSchema = z.enum([
|
||||
"preUpdate",
|
||||
"update",
|
||||
"postUpdate",
|
||||
"render",
|
||||
]);
|
||||
5
packages/gaming/games/ecs/src/system/index.ts
Normal file
5
packages/gaming/games/ecs/src/system/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { DefineSystemOptions } from "./system.js";
|
||||
export {
|
||||
defineSystem,
|
||||
SystemScheduler,
|
||||
} from "./system.js";
|
||||
97
packages/gaming/games/ecs/src/system/system.md
Normal file
97
packages/gaming/games/ecs/src/system/system.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# System & SystemScheduler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates component state. `defineSystem` is the factory that fills in sensible defaults (`phase: "update"`, `priority: 0`, `enabled: true`) and produces a frozen `SystemDefinition`. `SystemScheduler` is the world's internal sorted registry: it orders systems by phase then priority, lazily resorting only when the set changes.
|
||||
|
||||
Phase ordering is fixed at `preUpdate → update → postUpdate → render`, mirroring the canonical game-loop pipeline. Priority breaks ties within a phase, lowest first.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen-by-default `SystemDefinition`s with sensible defaults.
|
||||
- `enabled: false` lets systems be added but skipped on `execute` — useful for runtime toggling.
|
||||
- Stable phase order: `preUpdate < update < postUpdate < render`.
|
||||
- Insertion order is the implicit tiebreaker when priority is equal (Array#sort is stable in modern engines).
|
||||
- `has(name)` membership check used by the world to enforce unique system names.
|
||||
- `count` / `getAll` accessors for inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const pos = world.getComponent(entity, Position)!;
|
||||
const vel = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: pos.x + vel.dx * dt,
|
||||
y: pos.y + vel.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defineSystem, SystemScheduler } from "@suaveplan/ecs";
|
||||
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(defineSystem({
|
||||
name: "input",
|
||||
phase: "preUpdate",
|
||||
priority: 0,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
scheduler.add(defineSystem({
|
||||
name: "physics",
|
||||
priority: 10,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
scheduler.add(defineSystem({
|
||||
name: "gameplay",
|
||||
priority: 5,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
|
||||
for (const sys of scheduler.getOrdered()) {
|
||||
// order: input, gameplay, physics
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `defineSystem(options)` | Build a `SystemDefinition` with phase / priority / enabled defaults. |
|
||||
| `SystemScheduler#add(system)` | Register a system. Marks scheduler dirty. |
|
||||
| `SystemScheduler#remove(name)` | Remove by name. Returns `false` for unknowns. |
|
||||
| `SystemScheduler#has(name)` | Membership check. |
|
||||
| `SystemScheduler#getOrdered()` | Read-only execution-ordered snapshot. |
|
||||
| `SystemScheduler#getAll()` | Read-only unordered snapshot. |
|
||||
| `SystemScheduler#count` | Number of registered systems. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `getOrdered` sorts lazily — the `sorted` flag clears on add/remove and is re-set after the first sort. The world is therefore free to call it once per tick without paying repeated sort cost when no mutation occurred.
|
||||
- `defineSystem` returns a plain object, not a frozen one — performance-sensitive call sites can mutate fields like `enabled` to gate execution at runtime without rebuilding the definition. The world contract still treats it as immutable.
|
||||
- The `query` field references `ComponentSchema` objects, not IDs. The world resolves them through its registry on `addSystem`, registering any not-yet-known schemas at that point.
|
||||
- The phase order is encoded as a numeric lookup table (`PHASE_ORDER`), which keeps the sort key access branch-free.
|
||||
128
packages/gaming/games/ecs/src/system/system.test.ts
Normal file
128
packages/gaming/games/ecs/src/system/system.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { defineSystem, SystemScheduler } from "./system.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0 });
|
||||
|
||||
describe("defineSystem + SystemScheduler", () => {
|
||||
it("defineSystem applies phase / priority / enabled defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const sys = defineSystem({
|
||||
name: "render",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
});
|
||||
expect(sys.phase).toBe("update");
|
||||
expect(sys.priority).toBe(0);
|
||||
expect(sys.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("defineSystem honors explicit values", { timeout: 5000 }, () => {
|
||||
const sys = defineSystem({
|
||||
name: "render",
|
||||
phase: "render",
|
||||
priority: 5,
|
||||
enabled: false,
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
});
|
||||
expect(sys.phase).toBe("render");
|
||||
expect(sys.priority).toBe(5);
|
||||
expect(sys.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("phases run in fixed order preUpdate update postUpdate render", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
for (const phase of ["render", "preUpdate", "update"] as const) {
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: phase,
|
||||
phase,
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
const names = scheduler.getOrdered().map((s) => s.name);
|
||||
expect(names).toEqual(["preUpdate", "update", "render"]);
|
||||
});
|
||||
|
||||
it("ties within a phase break by ascending priority", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "B",
|
||||
priority: 5,
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "A",
|
||||
priority: 2,
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
const names = scheduler.getOrdered().map((s) => s.name);
|
||||
expect(names).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("remove returns false for unknown system name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
expect(scheduler.remove("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("remove returns true and shrinks the registered list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "A",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(scheduler.remove("A")).toBe(true);
|
||||
expect(scheduler.count).toBe(0);
|
||||
});
|
||||
|
||||
it("has reports membership by name", { timeout: 5000 }, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "A",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(scheduler.has("A")).toBe(true);
|
||||
expect(scheduler.has("B")).toBe(false);
|
||||
});
|
||||
|
||||
it("getAll returns a live read-only view, getOrdered returns a sorted view", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "A",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(scheduler.getAll().length).toBe(1);
|
||||
expect(scheduler.getOrdered().length).toBe(1);
|
||||
// calling again re-uses the sorted cache
|
||||
expect(scheduler.getOrdered().length).toBe(1);
|
||||
});
|
||||
});
|
||||
90
packages/gaming/games/ecs/src/system/system.ts
Normal file
90
packages/gaming/games/ecs/src/system/system.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
QueryDescriptor,
|
||||
SystemDefinition,
|
||||
SystemExecuteFn,
|
||||
SystemPhase,
|
||||
} from "../types/ecs.types.js";
|
||||
|
||||
/** Options accepted by {@link defineSystem}. */
|
||||
export interface DefineSystemOptions {
|
||||
name: string;
|
||||
phase?: SystemPhase;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
query: QueryDescriptor;
|
||||
execute: SystemExecuteFn;
|
||||
}
|
||||
|
||||
/** Build a {@link SystemDefinition} with phase/priority/enabled defaults. */
|
||||
export function defineSystem(options: DefineSystemOptions): SystemDefinition {
|
||||
return {
|
||||
name: options.name,
|
||||
phase: options.phase ?? "update",
|
||||
priority: options.priority ?? 0,
|
||||
enabled: options.enabled ?? true,
|
||||
query: options.query,
|
||||
execute: options.execute,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase- and priority-ordered system scheduler.
|
||||
*
|
||||
* Phases run in fixed order — `preUpdate → update → postUpdate → render`.
|
||||
* Within a phase, lower-priority numbers run first. Insertion order is the
|
||||
* stable tiebreaker for systems with equal priority.
|
||||
*/
|
||||
export class SystemScheduler {
|
||||
private readonly systems: SystemDefinition[] = [];
|
||||
private sorted = false;
|
||||
|
||||
private static readonly PHASE_ORDER: Record<SystemPhase, number> = {
|
||||
preUpdate: 0,
|
||||
update: 1,
|
||||
postUpdate: 2,
|
||||
render: 3,
|
||||
};
|
||||
|
||||
/** Register a system. Caller is responsible for unique names. */
|
||||
public add(system: SystemDefinition): void {
|
||||
this.systems.push(system);
|
||||
this.sorted = false;
|
||||
}
|
||||
|
||||
/** Remove a system by name. Returns `false` if no match. */
|
||||
public remove(name: string): boolean {
|
||||
const index = this.systems.findIndex((s) => s.name === name);
|
||||
if (index === -1) return false;
|
||||
this.systems.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Return the registered systems in execution order. */
|
||||
public getOrdered(): ReadonlyArray<SystemDefinition> {
|
||||
if (!this.sorted) {
|
||||
this.systems.sort((a, b) => {
|
||||
const phaseA = SystemScheduler.PHASE_ORDER[a.phase];
|
||||
const phaseB = SystemScheduler.PHASE_ORDER[b.phase];
|
||||
if (phaseA !== phaseB) return phaseA - phaseB;
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
this.sorted = true;
|
||||
}
|
||||
return this.systems;
|
||||
}
|
||||
|
||||
/** Read-only snapshot of every registered system. */
|
||||
public getAll(): ReadonlyArray<SystemDefinition> {
|
||||
return this.systems;
|
||||
}
|
||||
|
||||
/** True when a system with `name` is registered. */
|
||||
public has(name: string): boolean {
|
||||
return this.systems.some((s) => s.name === name);
|
||||
}
|
||||
|
||||
/** Number of registered systems. */
|
||||
public get count(): number {
|
||||
return this.systems.length;
|
||||
}
|
||||
}
|
||||
84
packages/gaming/games/ecs/src/types/ecs.types.md
Normal file
84
packages/gaming/games/ecs/src/types/ecs.types.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# ECS Types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the canonical home for every type alias and interface in `@suaveplan/ecs`. Keeping the type vocabulary in a single file makes the surface easy to scan and lets consumers import every public shape from `@suaveplan/ecs/types` (or the package root) without learning the internal folder layout. The file is types-only — no runtime exports, no side effects — which keeps the build output empty and tree-shake-safe.
|
||||
|
||||
The types fall into three groups: identity primitives (`Entity`, `ComponentId`, `ComponentData`, `ArchetypeId`), descriptor shapes (`ComponentSchema`, `QueryDescriptor`, `SystemDefinition`), and persistence/event shapes (`EntitySnapshot`, `WorldSnapshot`, `WorldEventMap`, `WorldApi`).
|
||||
|
||||
## Features
|
||||
|
||||
- Branded-style integer aliases (`Entity`, `ComponentId`) for documentation without runtime cost.
|
||||
- `ComponentSchema<T>` is generic over the component data shape so `World.getComponent` infers the right return type from a descriptor reference.
|
||||
- `WorldEventMap` extends `Record<string, unknown>` so the `@suaveplan/events` `EventBus<TMap>` type accepts it directly while still typing each known event payload.
|
||||
- `WorldApi` is the contract `World` implements — systems consume it through `SystemContext.world` to avoid coupling system code to the full `World` class surface.
|
||||
- `SystemPhase` is a string-literal union mirroring the scheduler's fixed phase order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Entity,
|
||||
ComponentSchema,
|
||||
QueryDescriptor,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
interface Position { x: number; y: number }
|
||||
|
||||
const Position: ComponentSchema<Position> = {
|
||||
name: "Position",
|
||||
defaults: Object.freeze({ x: 0, y: 0 }),
|
||||
};
|
||||
|
||||
const query: QueryDescriptor = { include: [Position] };
|
||||
const entity: Entity = 1;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SystemDefinition, SystemContext, WorldEventMap } from "@suaveplan/ecs";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const bus = new EventBus<WorldEventMap>();
|
||||
bus.on("componentAdded", (payload) => {
|
||||
// payload is fully typed: { entity: number; component: string }
|
||||
console.log(payload.entity, payload.component);
|
||||
});
|
||||
|
||||
function makeSystem(execute: SystemDefinition["execute"]): SystemDefinition {
|
||||
return {
|
||||
name: "custom",
|
||||
phase: "update",
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
query: { include: [] },
|
||||
execute,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `Entity` | Positive integer ID. |
|
||||
| `ComponentId` | Stable integer assigned by `ComponentRegistry`. |
|
||||
| `ComponentData` | `Record<string, unknown>` — generic component payload. |
|
||||
| `ArchetypeId` | Sorted-comma-separated component-ID string. |
|
||||
| `ComponentSchema<T>` | `{ name, defaults, schema? }` descriptor. |
|
||||
| `QueryDescriptor` | `{ include, exclude? }` declarative query. |
|
||||
| `SystemPhase` | `"preUpdate" \| "update" \| "postUpdate" \| "render"`. |
|
||||
| `SystemDefinition` | Frozen system contract used by the scheduler. |
|
||||
| `SystemContext` | Argument passed to `execute({ entities, world, dt })`. |
|
||||
| `SystemExecuteFn` | Type alias for the system execute callback. |
|
||||
| `EntitySnapshot` / `WorldSnapshot` | Persistence wire shapes. |
|
||||
| `WorldApi` | World surface exposed to systems. |
|
||||
| `WorldEventMap` | Single-object event payload map for `world.events`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All types use TypeScript-level branding (`type Entity = number`) rather than full nominal branding (`{ __brand: ... }`). The full-brand approach adds friction (`entity as Entity` everywhere) without much payoff for an integer that originates exclusively inside the package.
|
||||
- `ComponentSchema.schema?` is a `z.ZodType<T>`, optional at the type level so descriptors without a validator skip a property entirely. The world's validation step only fires when the property is defined.
|
||||
- `WorldEventMap`'s `[key: string]: unknown` index signature is required by `@suaveplan/events`' `EventMap = Record<string, unknown>` constraint. Known events keep their precise object shape; extra keys can be defined by consumers without leaking through.
|
||||
- The file deliberately avoids importing from any other file in the package — it is a pure leaf at the bottom of the dep graph. The single external import is `zod` for `z.ZodType<T>` in `ComponentSchema.schema`.
|
||||
105
packages/gaming/games/ecs/src/types/ecs.types.ts
Normal file
105
packages/gaming/games/ecs/src/types/ecs.types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
/** Unique entity identifier — opaque positive integer assigned by the world. */
|
||||
export type Entity = number;
|
||||
|
||||
/** Component data — any plain object indexed by string keys. */
|
||||
export type ComponentData = Record<string, unknown>;
|
||||
|
||||
/** Stable integer ID assigned to a {@link ComponentSchema} by the registry. */
|
||||
export type ComponentId = number;
|
||||
|
||||
/** Sorted, comma-separated component-ID string used as an archetype map key. */
|
||||
export type ArchetypeId = string;
|
||||
|
||||
/**
|
||||
* Schema definition for a component — provides a stable name, default values,
|
||||
* and an optional Zod validator. Schemas are deep-frozen and identity-stable.
|
||||
*/
|
||||
export interface ComponentSchema<T extends ComponentData = ComponentData> {
|
||||
readonly name: string;
|
||||
readonly defaults: Readonly<T>;
|
||||
readonly schema?: z.ZodType<T>;
|
||||
}
|
||||
|
||||
/** Execution phase. Phases run in the order declared by `PHASE_ORDER`. */
|
||||
export type SystemPhase = "preUpdate" | "update" | "postUpdate" | "render";
|
||||
|
||||
/** Arguments passed to a system's `execute` callback. */
|
||||
export interface SystemContext {
|
||||
readonly entities: ReadonlyArray<Entity>;
|
||||
readonly world: WorldApi;
|
||||
readonly dt: number;
|
||||
}
|
||||
|
||||
/** System execute function signature. */
|
||||
export type SystemExecuteFn = (ctx: SystemContext) => void;
|
||||
|
||||
/** System definition registered with the world. */
|
||||
export interface SystemDefinition {
|
||||
readonly name: string;
|
||||
readonly phase: SystemPhase;
|
||||
readonly priority: number;
|
||||
readonly query: QueryDescriptor;
|
||||
readonly execute: SystemExecuteFn;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
/** Declarative query — which components to include and/or exclude. */
|
||||
export interface QueryDescriptor {
|
||||
readonly include: ReadonlyArray<ComponentSchema>;
|
||||
readonly exclude?: ReadonlyArray<ComponentSchema>;
|
||||
}
|
||||
|
||||
/** Serialized form of a single entity for snapshot transport. */
|
||||
export interface EntitySnapshot {
|
||||
readonly id: Entity;
|
||||
readonly components: ReadonlyArray<{
|
||||
readonly name: string;
|
||||
readonly data: ComponentData;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Serialized form of an entire world. */
|
||||
export interface WorldSnapshot {
|
||||
readonly entities: ReadonlyArray<EntitySnapshot>;
|
||||
readonly nextEntityId: number;
|
||||
}
|
||||
|
||||
/** Public surface exposed to systems via {@link SystemContext.world}. */
|
||||
export interface WorldApi {
|
||||
spawn(): Entity;
|
||||
despawn(entity: Entity): void;
|
||||
isAlive(entity: Entity): boolean;
|
||||
addComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>,
|
||||
data?: Partial<T>
|
||||
): void;
|
||||
removeComponent(entity: Entity, schema: ComponentSchema): void;
|
||||
getComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>
|
||||
): T | undefined;
|
||||
setComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>,
|
||||
data: Partial<T>
|
||||
): void;
|
||||
hasComponent(entity: Entity, schema: ComponentSchema): boolean;
|
||||
query(descriptor: QueryDescriptor): ReadonlyArray<Entity>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventBus payload map emitted by `world.events`.
|
||||
*
|
||||
* Genesis events use single-object payloads (not tuples) per the
|
||||
* `@suaveplan/events` `EventMap = Record<string, unknown>` contract.
|
||||
*/
|
||||
export type WorldEventMap = {
|
||||
entitySpawned: { entity: Entity };
|
||||
entityDespawned: { entity: Entity };
|
||||
componentAdded: { entity: Entity; component: string };
|
||||
componentRemoved: { entity: Entity; component: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
16
packages/gaming/games/ecs/src/types/index.ts
Normal file
16
packages/gaming/games/ecs/src/types/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
ArchetypeId,
|
||||
ComponentData,
|
||||
ComponentId,
|
||||
ComponentSchema,
|
||||
Entity,
|
||||
EntitySnapshot,
|
||||
QueryDescriptor,
|
||||
SystemContext,
|
||||
SystemDefinition,
|
||||
SystemExecuteFn,
|
||||
SystemPhase,
|
||||
WorldApi,
|
||||
WorldEventMap,
|
||||
WorldSnapshot,
|
||||
} from "./ecs.types.js";
|
||||
5
packages/gaming/games/ecs/src/world/index.ts
Normal file
5
packages/gaming/games/ecs/src/world/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { WorldConfig } from "./world.js";
|
||||
export {
|
||||
createWorld,
|
||||
World,
|
||||
} from "./world.js";
|
||||
108
packages/gaming/games/ecs/src/world/world.md
Normal file
108
packages/gaming/games/ecs/src/world/world.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# World
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, query cache, system scheduler, and the typed `EventBus` that fires entity-lifecycle events. Every public mutator is instrumented with OpenTelemetry — `spawn` / `despawn` increment a counter, `execute` records into a histogram — so production behaviour is observable through the same in-memory `MeterProvider` used in tests.
|
||||
|
||||
The `createWorld(config?)` factory is the canonical entry point. `World` itself is exported so consumers can subclass or interrogate the instance.
|
||||
|
||||
## Features
|
||||
|
||||
- Archetype-graph mutations: `addComponent` / `removeComponent` move entities between archetypes lazily.
|
||||
- Idempotent `addComponent` — no-op when the component already exists.
|
||||
- Component validation via descriptor-attached Zod schemas; throws `ECS.COMPONENT_VALIDATION_FAILED` on rejection.
|
||||
- System scheduling with phase + priority ordering through `SystemScheduler`.
|
||||
- `snapshot` / `restore` round-trip with Zod-validated payloads.
|
||||
- Typed `EventBus<WorldEventMap>` — single-object payloads matching the `@suaveplan/events` shape.
|
||||
- OpenTelemetry counter (`ecs.entity.lifecycle_total`) and histogram (`ecs.world.execute_duration_ms`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const p = world.getComponent(entity, Position)!;
|
||||
const v = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: p.x + v.dx * dt,
|
||||
y: p.y + v.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const player = world.spawn();
|
||||
world.addComponent(player, Position, { x: 0, y: 0 });
|
||||
world.addComponent(player, Velocity, { dx: 5, dy: 0 });
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld({ maxComponents: 128 });
|
||||
world.registerComponent(Position);
|
||||
|
||||
world.events.on("entitySpawned", ({ entity }) => {
|
||||
console.log("spawned", entity);
|
||||
});
|
||||
world.events.on("componentAdded", ({ entity, component }) => {
|
||||
console.log(component, "->", entity);
|
||||
});
|
||||
|
||||
const snapshot = world.snapshot();
|
||||
// ... ship snapshot over the wire ...
|
||||
|
||||
const replay = createWorld();
|
||||
replay.registerComponent(Position);
|
||||
replay.restore(snapshot);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `createWorld(config?)` / `new World(config)` | Build a world. `maxComponents` defaults to 256. |
|
||||
| `events` | `EventBus<WorldEventMap>` for entity lifecycle. |
|
||||
| `registry` | Underlying `ComponentRegistry`. |
|
||||
| `registerComponent(schema)` | Pre-register a schema (idempotent). |
|
||||
| `spawn()` | Allocate an entity and emit `entitySpawned`. |
|
||||
| `despawn(entity)` | Despawn and recycle the entity. No-op when unknown. |
|
||||
| `isAlive(entity)` | Boolean liveness check. |
|
||||
| `addComponent(entity, schema, data?)` | Attach a component; validates Zod-attached schemas. |
|
||||
| `removeComponent(entity, schema)` | Detach a component. |
|
||||
| `getComponent(entity, schema)` | Read a component (or `undefined`). |
|
||||
| `setComponent(entity, schema, data)` | Merge partial data; revalidates. |
|
||||
| `hasComponent(entity, schema)` | Membership check. |
|
||||
| `query(descriptor)` | One-off entity query. |
|
||||
| `addSystem(system)` / `removeSystem(name)` | System lifecycle. |
|
||||
| `execute(dt)` | Run every enabled system; records duration histogram. |
|
||||
| `snapshot()` / `restore(snap)` | Persistence pair. `restore` Zod-validates the input. |
|
||||
| `clear()` | Wipe entities + archetypes, keep systems. |
|
||||
| `entityCount` / `archetypeCount` / `getSystems()` | Inspection accessors. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Archetype identity is the bitset key (`mask.toKey()`); `getOrCreateArchetype` deduplicates archetypes through a `Map<string, Archetype>`.
|
||||
- `addComponent` and `removeComponent` go through a swap-remove from the old archetype, mutate the component map, then insert into the new archetype. The destination archetype is materialised lazily.
|
||||
- `setComponent` does **not** change archetype membership — it merges into the existing column slot and re-validates against the descriptor's Zod schema.
|
||||
- `restore` is the only persistence boundary that validates input with Zod (`WorldSnapshotSchema`). Failures throw `ECS.SNAPSHOT_INVALID` with the Zod issues in `context`. Unknown component names in the snapshot are silently skipped, so adding new component types to consumer code does not break older snapshots.
|
||||
- OTel instruments resolve through the global `MeterProvider`. In tests, the package's `tests/setup/polyfills.ts` registers an in-memory `MeterProvider` before any module imports so the instruments bind to it.
|
||||
- The world's `EventBus` uses single-object payloads (`{ entity }`, `{ entity, component }`) consistent with the `@suaveplan/events` contract, rather than upstream's tuple shape.
|
||||
@@ -0,0 +1,149 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { DataPoint, ResourceMetrics } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
expectTypeOf,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
flushTestMetrics,
|
||||
testInMemoryMetricExporter,
|
||||
testMeterProvider,
|
||||
} from "../../tests/setup/polyfills.js";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import type { WorldEventMap } from "../types/ecs.types.js";
|
||||
import { createWorld } from "./world.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
|
||||
function findMetric(
|
||||
snapshots: ResourceMetrics[],
|
||||
name: string
|
||||
): DataPoint<number> | DataPoint<unknown> | undefined {
|
||||
for (const rm of snapshots) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name === name) {
|
||||
return m.dataPoints[0] as DataPoint<number>;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("World — spec conformance", () => {
|
||||
afterEach(() => {
|
||||
testInMemoryMetricExporter.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testMeterProvider.shutdown();
|
||||
metrics.disable();
|
||||
});
|
||||
|
||||
it("execute records into the ecs.world.execute_duration_ms histogram via an in-memory MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const world = createWorld();
|
||||
for (let i = 0; i < 5; i++) world.execute(0.016);
|
||||
const snapshots = await flushTestMetrics();
|
||||
const hist = findMetric(snapshots, "ecs.world.execute_duration_ms");
|
||||
expect(hist).toBeDefined();
|
||||
const point = hist as DataPoint<{ count: number; sum: number }>;
|
||||
expect(point.value.count).toBe(5);
|
||||
expect(point.value.sum).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("spawn and despawn increment the ecs.entity.lifecycle_total counter via an in-memory MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const world = createWorld();
|
||||
const a = world.spawn();
|
||||
world.spawn();
|
||||
world.despawn(a);
|
||||
const snapshots = await flushTestMetrics();
|
||||
const counter = findMetric(snapshots, "ecs.entity.lifecycle_total");
|
||||
let spawnTotal = 0;
|
||||
let despawnTotal = 0;
|
||||
for (const rm of snapshots) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== "ecs.entity.lifecycle_total")
|
||||
continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
const op = (dp.attributes as { op?: string }).op;
|
||||
if (op === "spawn") spawnTotal += dp.value as number;
|
||||
if (op === "despawn")
|
||||
despawnTotal += dp.value as number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(counter).toBeDefined();
|
||||
expect(spawnTotal).toBe(2);
|
||||
expect(despawnTotal).toBe(1);
|
||||
});
|
||||
|
||||
it("spawn emits entitySpawned with a single-object payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
let received: { entity: number } | undefined;
|
||||
world.events.on("entitySpawned", (payload) => {
|
||||
received = payload;
|
||||
});
|
||||
const entity = world.spawn();
|
||||
expect(received).toEqual({ entity });
|
||||
expectTypeOf<WorldEventMap["entitySpawned"]>().toEqualTypeOf<{
|
||||
entity: number;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("componentAdded fires after the component is queryable on the entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
let visible = false;
|
||||
world.events.on("componentAdded", (payload) => {
|
||||
const { entity, component } = payload as {
|
||||
entity: number;
|
||||
component: string;
|
||||
};
|
||||
if (component === "Position") {
|
||||
visible = world.hasComponent(entity, Position);
|
||||
}
|
||||
});
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
expect(visible).toBe(true);
|
||||
});
|
||||
|
||||
it("componentRemoved fires with entity and component name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const events: Array<{ entity: number; component: string }> = [];
|
||||
world.events.on("componentRemoved", (payload) => {
|
||||
events.push(payload as { entity: number; component: string });
|
||||
});
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.removeComponent(entity, Position);
|
||||
expect(events).toEqual([{ entity, component: "Position" }]);
|
||||
});
|
||||
|
||||
it("entityDespawned fires for every despawn", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
const seen: number[] = [];
|
||||
world.events.on("entityDespawned", (payload) => {
|
||||
seen.push((payload as { entity: number }).entity);
|
||||
});
|
||||
const a = world.spawn();
|
||||
world.despawn(a);
|
||||
expect(seen).toEqual([a]);
|
||||
});
|
||||
});
|
||||
455
packages/gaming/games/ecs/src/world/world.test.ts
Normal file
455
packages/gaming/games/ecs/src/world/world.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
||||
import { defineQuery } from "../query/query.js";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { defineSystem } from "../system/system.js";
|
||||
import { createWorld, World } from "./world.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0, dy: 0 });
|
||||
const Disabled = defineComponent("Disabled", { d: true });
|
||||
const Health = defineComponent(
|
||||
"Health",
|
||||
{ hp: 100 },
|
||||
z.object({ hp: z.number().min(0) })
|
||||
);
|
||||
|
||||
describe("World", () => {
|
||||
it("createWorld returns a World instance", { timeout: 5000 }, () => {
|
||||
expect(createWorld()).toBeInstanceOf(World);
|
||||
});
|
||||
|
||||
it("spawn and addComponent place the entity in the matching archetype", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position, { x: 5 });
|
||||
expect(world.hasComponent(entity, Position)).toBe(true);
|
||||
expect(world.getComponent(entity, Position)).toEqual({
|
||||
x: 5,
|
||||
y: 0,
|
||||
});
|
||||
const matches = world.query(defineQuery([Position]));
|
||||
expect([...matches]).toEqual([entity]);
|
||||
});
|
||||
|
||||
it("spawn placement defaults to the empty archetype", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.spawn();
|
||||
expect(world.archetypeCount).toBe(1);
|
||||
expect(world.entityCount).toBe(1);
|
||||
});
|
||||
|
||||
it("addComponent is idempotent when the component already exists", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position, { x: 5 });
|
||||
world.addComponent(entity, Position, { x: 9 });
|
||||
expect(world.getComponent(entity, Position)).toEqual({
|
||||
x: 5,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("addComponent throws ECS.ENTITY_NOT_ALIVE for a despawned entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.despawn(entity);
|
||||
try {
|
||||
world.addComponent(entity, Position);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
|
||||
}
|
||||
});
|
||||
|
||||
it("addComponent throws ECS.COMPONENT_VALIDATION_FAILED for data that fails the descriptor's Zod schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
try {
|
||||
world.addComponent(entity, Health, { hp: -5 });
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_VALIDATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("removeComponent moves the entity to a smaller archetype", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.addComponent(entity, Velocity);
|
||||
world.removeComponent(entity, Velocity);
|
||||
expect(world.hasComponent(entity, Velocity)).toBe(false);
|
||||
expect(world.hasComponent(entity, Position)).toBe(true);
|
||||
});
|
||||
|
||||
it("removeComponent is a no-op when the component is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.registerComponent(Velocity);
|
||||
const before = world.archetypeCount;
|
||||
world.removeComponent(entity, Velocity);
|
||||
expect(world.archetypeCount).toBe(before);
|
||||
expect(world.hasComponent(entity, Position)).toBe(true);
|
||||
});
|
||||
|
||||
it("removeComponent throws ECS.ENTITY_NOT_ALIVE for a despawned entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.despawn(entity);
|
||||
try {
|
||||
world.removeComponent(entity, Position);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
|
||||
}
|
||||
});
|
||||
|
||||
it("setComponent merges partial data and re-validates", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position, { x: 1, y: 2 });
|
||||
world.setComponent(entity, Position, { x: 9 });
|
||||
expect(world.getComponent(entity, Position)).toEqual({
|
||||
x: 9,
|
||||
y: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("setComponent delegates to addComponent when the component is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.setComponent(entity, Position, { x: 4 });
|
||||
expect(world.hasComponent(entity, Position)).toBe(true);
|
||||
expect(world.getComponent(entity, Position)).toEqual({ x: 4, y: 0 });
|
||||
});
|
||||
|
||||
it("setComponent throws ECS.ENTITY_NOT_ALIVE when the entity is despawned", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.despawn(entity);
|
||||
try {
|
||||
world.setComponent(entity, Position, { x: 1 });
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
|
||||
}
|
||||
});
|
||||
|
||||
it("setComponent rejects updates that fail the descriptor schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Health);
|
||||
try {
|
||||
world.setComponent(entity, Health, { hp: -1 });
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_VALIDATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getComponent returns undefined for absent components and despawned entities", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.registerComponent(Position);
|
||||
expect(world.getComponent(entity, Position)).toBeUndefined();
|
||||
world.despawn(entity);
|
||||
expect(world.getComponent(entity, Position)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("despawn is a no-op for unknown entities", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
world.despawn(9999);
|
||||
expect(world.entityCount).toBe(0);
|
||||
});
|
||||
|
||||
it("isAlive reflects the entity pool", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
expect(world.isAlive(entity)).toBe(true);
|
||||
world.despawn(entity);
|
||||
expect(world.isAlive(entity)).toBe(false);
|
||||
});
|
||||
|
||||
it("hasComponent returns false for a despawned entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.despawn(entity);
|
||||
expect(world.hasComponent(entity, Position)).toBe(false);
|
||||
});
|
||||
|
||||
it("query results respect include and exclude", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
const a = world.spawn();
|
||||
const b = world.spawn();
|
||||
world.addComponent(a, Position);
|
||||
world.addComponent(b, Position);
|
||||
world.addComponent(b, Disabled);
|
||||
|
||||
const enabled = world.query(defineQuery([Position], [Disabled]));
|
||||
expect([...enabled]).toEqual([a]);
|
||||
});
|
||||
|
||||
it("addSystem throws ECS.SYSTEM_NAME_CONFLICT on duplicate name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const sys = defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
});
|
||||
world.addSystem(sys);
|
||||
try {
|
||||
world.addSystem(sys);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.SYSTEM_NAME_CONFLICT);
|
||||
}
|
||||
});
|
||||
|
||||
it("addSystem registers schemas from include and exclude clauses", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position], exclude: [Disabled] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(world.registry.has(Position)).toBe(true);
|
||||
expect(world.registry.has(Disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it("addSystem is a no-op for schemas already registered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.registerComponent(Position);
|
||||
world.registerComponent(Disabled);
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position], exclude: [Disabled] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(world.registry.count).toBe(2);
|
||||
});
|
||||
|
||||
it("addSystem with no exclude clause registers only include schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(world.registry.has(Position)).toBe(true);
|
||||
expect(world.registry.count).toBe(1);
|
||||
});
|
||||
|
||||
it("removeSystem returns false for unknown names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
expect(world.removeSystem("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("execute runs registered systems in scheduler order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const order: string[] = [];
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "render",
|
||||
phase: "render",
|
||||
query: { include: [Position] },
|
||||
execute: () => order.push("render"),
|
||||
})
|
||||
);
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "pre",
|
||||
phase: "preUpdate",
|
||||
query: { include: [Position] },
|
||||
execute: () => order.push("pre"),
|
||||
})
|
||||
);
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "update",
|
||||
query: { include: [Position] },
|
||||
execute: ({ dt }) => order.push(`update:${dt}`),
|
||||
})
|
||||
);
|
||||
world.execute(0.016);
|
||||
expect(order).toEqual(["pre", "update:0.016", "render"]);
|
||||
});
|
||||
|
||||
it("execute skips disabled systems", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
let fired = false;
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "off",
|
||||
enabled: false,
|
||||
query: { include: [Position] },
|
||||
execute: () => {
|
||||
fired = true;
|
||||
},
|
||||
})
|
||||
);
|
||||
world.execute(0.016);
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it("snapshot and restore round-trip preserves entities and components", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = createWorld();
|
||||
a.registerComponent(Position);
|
||||
const e1 = a.spawn();
|
||||
const e2 = a.spawn();
|
||||
a.addComponent(e1, Position, { x: 1, y: 2 });
|
||||
a.addComponent(e2, Position, { x: 3, y: 4 });
|
||||
a.despawn(e1);
|
||||
|
||||
const snap = a.snapshot();
|
||||
|
||||
const b = createWorld();
|
||||
b.registerComponent(Position);
|
||||
b.restore(snap);
|
||||
expect(b.entityCount).toBe(1);
|
||||
const restored = b.query(defineQuery([Position]));
|
||||
expect(restored.length).toBe(1);
|
||||
expect(b.getComponent(restored[0] ?? 0, Position)).toEqual({
|
||||
x: 3,
|
||||
y: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("restore silently skips snapshot components with unknown names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = createWorld();
|
||||
const entity = a.spawn();
|
||||
a.addComponent(entity, Position, { x: 7, y: 8 });
|
||||
const snap = a.snapshot();
|
||||
|
||||
const b = createWorld();
|
||||
// Only register Position, not Velocity — snapshot only has Position so OK.
|
||||
// Inject a phantom component name to exercise the skip branch.
|
||||
const tampered = {
|
||||
...snap,
|
||||
entities: snap.entities.map((e) => ({
|
||||
...e,
|
||||
components: [
|
||||
...e.components,
|
||||
{ name: "Ghost", data: { ghost: true } },
|
||||
],
|
||||
})),
|
||||
};
|
||||
b.registerComponent(Position);
|
||||
b.restore(tampered);
|
||||
expect(b.entityCount).toBe(1);
|
||||
});
|
||||
|
||||
it("restore throws ECS.SNAPSHOT_INVALID for a malformed snapshot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
try {
|
||||
world.restore({
|
||||
entities: [],
|
||||
nextEntityId: -1,
|
||||
});
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.SNAPSHOT_INVALID);
|
||||
}
|
||||
});
|
||||
|
||||
it("clear removes entities and archetypes but keeps systems", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.clear();
|
||||
expect(world.entityCount).toBe(0);
|
||||
expect(world.archetypeCount).toBe(0);
|
||||
expect(world.getSystems().length).toBe(1);
|
||||
});
|
||||
|
||||
it("getSystems returns the registered systems", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(world.getSystems().length).toBe(1);
|
||||
});
|
||||
|
||||
it("WorldConfig.maxComponents threads through to the registry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld({ maxComponents: 4 });
|
||||
expect(world.registry.maxComponents).toBe(4);
|
||||
});
|
||||
});
|
||||
391
packages/gaming/games/ecs/src/world/world.ts
Normal file
391
packages/gaming/games/ecs/src/world/world.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { Archetype } from "../archetype/archetype.js";
|
||||
import { BitSet } from "../bitset/bitset.js";
|
||||
import { ComponentRegistry } from "../component-registry/component-registry.js";
|
||||
import { EntityPool } from "../entity-pool/entity-pool.js";
|
||||
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
||||
import {
|
||||
recordExecuteDuration,
|
||||
recordLifecycle,
|
||||
} from "../observability/otel.js";
|
||||
import { Query } from "../query/query.js";
|
||||
import { WorldSnapshotSchema } from "../schemas/world.schemas.js";
|
||||
import { SystemScheduler } from "../system/system.js";
|
||||
import type {
|
||||
ComponentData,
|
||||
ComponentId,
|
||||
ComponentSchema,
|
||||
Entity,
|
||||
EntitySnapshot,
|
||||
QueryDescriptor,
|
||||
SystemDefinition,
|
||||
WorldApi,
|
||||
WorldEventMap,
|
||||
WorldSnapshot,
|
||||
} from "../types/ecs.types.js";
|
||||
|
||||
/** Configuration accepted by {@link createWorld} / {@link World}. */
|
||||
export interface WorldConfig {
|
||||
/** Hard cap on the number of distinct component types. Default 256. */
|
||||
readonly maxComponents?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The World is the central container for an ECS instance. It owns the
|
||||
* component registry, entity pool, archetype graph, query cache, and system
|
||||
* scheduler, and emits typed lifecycle events through `world.events`.
|
||||
*
|
||||
* Public mutators ({@link spawn}, {@link despawn}, {@link addComponent},
|
||||
* {@link removeComponent}, {@link execute}, {@link snapshot},
|
||||
* {@link restore}) are instrumented with OpenTelemetry counters / histograms.
|
||||
*/
|
||||
export class World implements WorldApi {
|
||||
public readonly events = new EventBus<WorldEventMap>();
|
||||
public readonly registry: ComponentRegistry;
|
||||
|
||||
private readonly pool: EntityPool;
|
||||
private readonly scheduler: SystemScheduler;
|
||||
private readonly archetypes: Archetype[] = [];
|
||||
private readonly archetypeMap = new Map<string, Archetype>();
|
||||
private readonly entityArchetype = new Map<Entity, Archetype>();
|
||||
|
||||
public constructor(config?: WorldConfig) {
|
||||
this.registry = new ComponentRegistry(config?.maxComponents);
|
||||
this.pool = new EntityPool();
|
||||
this.scheduler = new SystemScheduler();
|
||||
}
|
||||
|
||||
/** Register a component schema. Idempotent by schema identity. */
|
||||
public registerComponent<T extends ComponentData>(
|
||||
schema: ComponentSchema<T>
|
||||
): void {
|
||||
this.registry.register(schema);
|
||||
}
|
||||
|
||||
/** Spawn a new entity in the empty archetype and emit `entitySpawned`. */
|
||||
public spawn(): Entity {
|
||||
const entity = this.pool.allocate();
|
||||
const emptyArch = this.getOrCreateArchetype(
|
||||
new BitSet(this.registry.maxComponents),
|
||||
[]
|
||||
);
|
||||
emptyArch.addEntity(entity, new Map());
|
||||
this.entityArchetype.set(entity, emptyArch);
|
||||
recordLifecycle("spawn");
|
||||
this.events.emit("entitySpawned", { entity });
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Despawn `entity`, freeing its ID and emitting `entityDespawned`. */
|
||||
public despawn(entity: Entity): void {
|
||||
const arch = this.entityArchetype.get(entity);
|
||||
if (arch === undefined) return;
|
||||
arch.removeEntity(entity);
|
||||
this.entityArchetype.delete(entity);
|
||||
this.pool.free(entity);
|
||||
|
||||
recordLifecycle("despawn");
|
||||
this.events.emit("entityDespawned", { entity });
|
||||
}
|
||||
|
||||
/** True when `entity` is currently alive in this world. */
|
||||
public isAlive(entity: Entity): boolean {
|
||||
return this.pool.isAlive(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a component to `entity`, moving it to a new archetype as needed.
|
||||
* Throws `ECS.ENTITY_NOT_ALIVE` if the entity has been despawned.
|
||||
* No-op when the entity already has the component.
|
||||
*/
|
||||
public addComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>,
|
||||
data?: Partial<T>
|
||||
): void {
|
||||
if (!this.registry.has(schema)) this.registry.register(schema);
|
||||
const componentId = this.registry.getId(schema);
|
||||
const oldArch = this.entityArchetype.get(entity);
|
||||
if (oldArch === undefined) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.ENTITY_NOT_ALIVE,
|
||||
"error.ecs.entity_not_alive",
|
||||
{ context: { entity } }
|
||||
);
|
||||
}
|
||||
|
||||
if (oldArch.hasComponent(componentId)) return;
|
||||
|
||||
const merged = { ...schema.defaults, ...data } as T;
|
||||
this.validateComponent(schema, merged);
|
||||
|
||||
const oldData = oldArch.removeEntity(entity) as Map<
|
||||
ComponentId,
|
||||
ComponentData
|
||||
>;
|
||||
oldData.set(componentId, merged);
|
||||
|
||||
const newMask = oldArch.mask.clone();
|
||||
newMask.set(componentId);
|
||||
const newIds = [...oldArch.getComponentIds(), componentId].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const newArch = this.getOrCreateArchetype(newMask, newIds);
|
||||
newArch.addEntity(entity, oldData);
|
||||
this.entityArchetype.set(entity, newArch);
|
||||
|
||||
this.events.emit("componentAdded", {
|
||||
entity,
|
||||
component: schema.name,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a component from `entity`, moving it to a new archetype. */
|
||||
public removeComponent(entity: Entity, schema: ComponentSchema): void {
|
||||
const componentId = this.registry.getId(schema);
|
||||
const oldArch = this.entityArchetype.get(entity);
|
||||
if (oldArch === undefined) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.ENTITY_NOT_ALIVE,
|
||||
"error.ecs.entity_not_alive",
|
||||
{ context: { entity } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!oldArch.hasComponent(componentId)) return;
|
||||
|
||||
const oldData = oldArch.removeEntity(entity) as Map<
|
||||
ComponentId,
|
||||
ComponentData
|
||||
>;
|
||||
oldData.delete(componentId);
|
||||
const newMask = oldArch.mask.clone();
|
||||
newMask.unset(componentId);
|
||||
const newIds = oldArch
|
||||
.getComponentIds()
|
||||
.filter((id) => id !== componentId);
|
||||
|
||||
const newArch = this.getOrCreateArchetype(newMask, newIds);
|
||||
newArch.addEntity(entity, oldData);
|
||||
this.entityArchetype.set(entity, newArch);
|
||||
|
||||
this.events.emit("componentRemoved", {
|
||||
entity,
|
||||
component: schema.name,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a component value, or `undefined` if absent. */
|
||||
public getComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>
|
||||
): T | undefined {
|
||||
const componentId = this.registry.getId(schema);
|
||||
const arch = this.entityArchetype.get(entity);
|
||||
return arch?.getComponent(entity, componentId) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially merge new data into an existing component value.
|
||||
* Throws `ECS.ENTITY_NOT_ALIVE` if the entity is absent and
|
||||
* `ECS.COMPONENT_VALIDATION_FAILED` when the descriptor's Zod schema rejects
|
||||
* the merged result.
|
||||
*/
|
||||
public setComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>,
|
||||
data: Partial<T>
|
||||
): void {
|
||||
if (!this.registry.has(schema)) this.registry.register(schema);
|
||||
const componentId = this.registry.getId(schema);
|
||||
const arch = this.entityArchetype.get(entity);
|
||||
if (arch === undefined) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.ENTITY_NOT_ALIVE,
|
||||
"error.ecs.entity_not_alive",
|
||||
{ context: { entity } }
|
||||
);
|
||||
}
|
||||
const current = arch.getComponent(entity, componentId) as T | undefined;
|
||||
if (current === undefined) {
|
||||
this.addComponent(entity, schema, data);
|
||||
return;
|
||||
}
|
||||
const merged = { ...current, ...data } as T;
|
||||
this.validateComponent(schema, merged);
|
||||
arch.setComponent(entity, componentId, merged);
|
||||
}
|
||||
|
||||
/** True when `entity` has the component. */
|
||||
public hasComponent(entity: Entity, schema: ComponentSchema): boolean {
|
||||
const componentId = this.registry.getId(schema);
|
||||
const arch = this.entityArchetype.get(entity);
|
||||
return arch?.hasComponent(componentId) ?? false;
|
||||
}
|
||||
|
||||
/** Evaluate a one-off query — for repeated queries register a system. */
|
||||
public query(descriptor: QueryDescriptor): ReadonlyArray<Entity> {
|
||||
const q = new Query(descriptor, this.registry);
|
||||
return q.evaluate(this.archetypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a system. Throws `ECS.SYSTEM_NAME_CONFLICT` if another system
|
||||
* shares the same name.
|
||||
*/
|
||||
public addSystem(system: SystemDefinition): void {
|
||||
if (this.scheduler.has(system.name)) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.SYSTEM_NAME_CONFLICT,
|
||||
"error.ecs.system_name_conflict",
|
||||
{ context: { name: system.name } }
|
||||
);
|
||||
}
|
||||
const schemas: ComponentSchema[] = [
|
||||
...system.query.include,
|
||||
...(system.query.exclude ?? []),
|
||||
];
|
||||
for (const schema of schemas) {
|
||||
if (!this.registry.has(schema)) this.registry.register(schema);
|
||||
}
|
||||
this.scheduler.add(system);
|
||||
}
|
||||
|
||||
/** Remove a system by name. Returns `false` if no match. */
|
||||
public removeSystem(name: string): boolean {
|
||||
return this.scheduler.remove(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every enabled system in scheduler order. Records the wall-clock
|
||||
* duration in the `ecs.world.execute_duration_ms` histogram.
|
||||
*/
|
||||
public execute(dt: number): void {
|
||||
const start = performance.now();
|
||||
const ordered = this.scheduler.getOrdered();
|
||||
for (const system of ordered) {
|
||||
if (!system.enabled) continue;
|
||||
const q = new Query(system.query, this.registry);
|
||||
const entities = q.evaluate(this.archetypes);
|
||||
system.execute({ entities, world: this, dt });
|
||||
}
|
||||
recordExecuteDuration(performance.now() - start);
|
||||
}
|
||||
|
||||
/** Capture a structured snapshot of every alive entity. */
|
||||
public snapshot(): WorldSnapshot {
|
||||
const entities: EntitySnapshot[] = [];
|
||||
for (const [entity, arch] of this.entityArchetype) {
|
||||
const components: {
|
||||
readonly name: string;
|
||||
readonly data: ComponentData;
|
||||
}[] = [];
|
||||
for (const cid of arch.getComponentIds()) {
|
||||
const schema = this.registry.getSchema(cid) as ComponentSchema;
|
||||
const data = arch.getComponent(entity, cid) as ComponentData;
|
||||
components.push({
|
||||
name: schema.name,
|
||||
data: structuredClone(data),
|
||||
});
|
||||
}
|
||||
entities.push({ id: entity, components });
|
||||
}
|
||||
return { entities, nextEntityId: this.pool.peekNextId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore world state from a snapshot. The snapshot is Zod-validated;
|
||||
* `ECS.SNAPSHOT_INVALID` is thrown on failure. Component schemas
|
||||
* referenced by name must already be registered — unknown names are
|
||||
* silently skipped.
|
||||
*/
|
||||
public restore(snap: WorldSnapshot): void {
|
||||
const parsed = WorldSnapshotSchema.safeParse(snap);
|
||||
if (!parsed.success) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.SNAPSHOT_INVALID,
|
||||
"error.ecs.snapshot_invalid",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.clear();
|
||||
this.pool.setNextId(parsed.data.nextEntityId);
|
||||
for (const es of parsed.data.entities) {
|
||||
const entity = this.pool.allocate();
|
||||
const emptyArch = this.getOrCreateArchetype(
|
||||
new BitSet(this.registry.maxComponents),
|
||||
[]
|
||||
);
|
||||
emptyArch.addEntity(entity, new Map());
|
||||
this.entityArchetype.set(entity, emptyArch);
|
||||
for (const comp of es.components) {
|
||||
const schema = this.registry.getSchemaByName(comp.name);
|
||||
if (schema !== undefined) {
|
||||
this.addComponent(entity, schema, comp.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove every entity and archetype. Systems and registrations are kept. */
|
||||
public clear(): void {
|
||||
this.entityArchetype.clear();
|
||||
this.archetypes.length = 0;
|
||||
this.archetypeMap.clear();
|
||||
this.pool.clear();
|
||||
}
|
||||
|
||||
/** Number of alive entities. */
|
||||
public get entityCount(): number {
|
||||
return this.pool.aliveCount;
|
||||
}
|
||||
|
||||
/** Number of distinct archetypes currently held. */
|
||||
public get archetypeCount(): number {
|
||||
return this.archetypes.length;
|
||||
}
|
||||
|
||||
/** Read-only snapshot of every registered system. */
|
||||
public getSystems(): ReadonlyArray<SystemDefinition> {
|
||||
return this.scheduler.getAll();
|
||||
}
|
||||
|
||||
private getOrCreateArchetype(
|
||||
mask: BitSet,
|
||||
componentIds: ReadonlyArray<ComponentId>
|
||||
): Archetype {
|
||||
const key = mask.toKey();
|
||||
let arch = this.archetypeMap.get(key);
|
||||
if (arch === undefined) {
|
||||
arch = new Archetype(mask, componentIds);
|
||||
this.archetypes.push(arch);
|
||||
this.archetypeMap.set(key, arch);
|
||||
}
|
||||
return arch;
|
||||
}
|
||||
|
||||
private validateComponent<T extends ComponentData>(
|
||||
schema: ComponentSchema<T>,
|
||||
value: T
|
||||
): void {
|
||||
if (schema.schema === undefined) return;
|
||||
const result = schema.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.COMPONENT_VALIDATION_FAILED,
|
||||
"error.ecs.component_validation_failed",
|
||||
{
|
||||
context: {
|
||||
name: schema.name,
|
||||
issues: result.error.issues,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory wrapper around `new World(config)`. */
|
||||
export function createWorld(config?: WorldConfig): World {
|
||||
return new World(config);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user