Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
plugins/document-navigator/docs/gaming/games/README.md
Normal file
15
plugins/document-navigator/docs/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 inje… |
|
||||
| [`@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
plugins/document-navigator/docs/gaming/games/_intro.md
Normal file
1
plugins/document-navigator/docs/gaming/games/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Game controllers, entity-component systems, and game-server logic.
|
||||
@@ -0,0 +1,66 @@
|
||||
# @suaveplan/controller - Universal input controller foundation — unified 32-field ControllerInput shape, BaseController abstract base with Clock-injected pollRate gating and threshold/deadzone normalisation, typed InputEventEmitter, stable CONTROLLER.* error code registry, and OpenTelemetry instrumentation. Chunk A of the @suaveplan/controller migration; device adapters and merger ship in follow-up chunks.
|
||||
|
||||
Universal input controller foundation. Ships the unified
|
||||
`ControllerInput` shape (37 normalised fields in `[0, 255]`),
|
||||
`BaseController` abstract base with injectable `Clock`/`IdGenerator`,
|
||||
`pollRate` gating and per-input threshold/deadzone normalisation, the
|
||||
typed `InputEventEmitter`, the stable `CONTROLLER.*` error-code
|
||||
registry, and the package's OpenTelemetry instruments.
|
||||
|
||||
Device adapters (`KeyboardController`, `GamepadController`,
|
||||
`TouchController`, `MouseController`) and `ControllerMerger` ship in
|
||||
Chunks B and C.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [base](./src/base/base.md) — Abstract base for every device-specific controller adapter shipped by this package. Owns the 37-field `ControllerInput`…
|
||||
- [base](./src/base/base.md) — Abstract base for every device-specific controller adapter shipped by this package. Owns the 37-field `ControllerInput`…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CONTROLLER.*` error-code registry and the typed `ControllerError` class used by every public surfac…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CONTROLLER.*` error-code registry and the typed `ControllerError` class used by every public surfac…
|
||||
- [events](./src/events/events.md) — Typed event dispatcher used by `BaseController` to fan input, state and combo events out to listeners. Centralising the…
|
||||
- [events](./src/events/events.md) — Typed event dispatcher used by `BaseController` to fan input, state and combo events out to listeners. Centralising the…
|
||||
- [gamepad](./src/gamepad/gamepad.md) — Browser-tier device adapter that polls the Web Gamepad API and normalises button + axis state into the canonical 37-fie…
|
||||
- [gamepad.internal](./src/gamepad/gamepad.internal.md) — Module-private helpers used by `GamepadController` to keep the main class file under the repository's 300-line hard cap…
|
||||
- [gamepad](./src/gamepad/gamepad.md) — Browser-tier device adapter that polls the Web Gamepad API and normalises button + axis state into the canonical 37-fie…
|
||||
- [keyboard](./src/keyboard/keyboard.md) — Browser-tier device adapter that normalises DOM `keydown`/`keyup` events into the canonical 37-field `ControllerInput`…
|
||||
- [keyboard](./src/keyboard/keyboard.md) — Browser-tier device adapter that normalises DOM `keydown`/`keyup` events into the canonical 37-field `ControllerInput`…
|
||||
- [merger](./src/merger/merger.md) — `ControllerMerger` fuses an ordered list of `BaseController` children into a single virtual controller whose `Controlle…
|
||||
- [merger](./src/merger/merger.md) — `ControllerMerger` fuses an ordered list of `BaseController` children into a single virtual controller whose `Controlle…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`: two counters tracking input and state-change thr…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`: two counters tracking input and state-change thr…
|
||||
- [types](./src/types/keys.md) — Provides the canonical iteration order over the 37 `ControllerInput` keys and a `createDefaultControllerInput()` factor…
|
||||
- [keys](./src/types/keys.md) — Provides the canonical iteration order over the 37 `ControllerInput` keys and a `createDefaultControllerInput()` factor…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- [`base/base.md`](src/base/base.md) — `BaseController` lifecycle,
|
||||
state, normalisation, pollRate gating.
|
||||
- [`events/events.md`](src/events/events.md) — `InputEventEmitter`
|
||||
typed dispatcher with disposable listeners.
|
||||
- [`error-codes.md`](src/error-codes.md) — `CONTROLLER_CODES` registry
|
||||
and `ControllerError` class.
|
||||
- [`telemetry.md`](src/telemetry.md) — `controller_inputs_total`,
|
||||
`controller_state_changes_total` counters and the pattern-match
|
||||
duration histogram.
|
||||
- [`types/keys.md`](src/types/keys.md) — `CONTROLLER_INPUT_KEYS` and
|
||||
`createDefaultControllerInput`.
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` — Chunk A foundation. Chunks B (device adapters) and C
|
||||
(merger) extend the barrel with additional named exports.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/controller
|
||||
bunx turbo test:coverage --filter=@suaveplan/controller
|
||||
```
|
||||
|
||||
The coverage gate is 100/100/100/100 (istanbul). The package's
|
||||
`tests/setup/polyfills.ts` registers an in-memory `MeterProvider` before
|
||||
source modules import the OTel meter so the telemetry assertions resolve
|
||||
against a real exporter, with no mocks.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# `base` — `BaseController` abstract base
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Abstract base for every device-specific controller adapter shipped by
|
||||
this package. Owns the 37-field `ControllerInput` state, per-input
|
||||
gating (`{ threshold, deadzone }`), the typed `InputEventEmitter`, and
|
||||
the `pollRate`-gated `update()` cadence. Subclasses landing in Chunk B
|
||||
(`KeyboardController`, `GamepadController`, `TouchController`,
|
||||
`MouseController`) and Chunk C (`ControllerMerger`) extend this class
|
||||
and override `updateState()` to translate device-specific signals into
|
||||
the canonical input shape.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `Clock` (`@suaveplan/types`) — every wall-clock read goes
|
||||
through `this.clock()` so tests substitute a `FakeClock` without
|
||||
touching globals.
|
||||
- Injectable `IdGenerator` — every instance carries a stable `id` used
|
||||
by OTel attributes and audit logs.
|
||||
- `pollRate` gating — repeated `update()` calls within
|
||||
`1000 / pollRate` ms are coalesced; the first one inside the window
|
||||
runs `updateState()`, the rest are no-ops.
|
||||
- Per-input threshold + deadzone normalisation, clamp to `[0, 255]`,
|
||||
integer rounding.
|
||||
- Event-suppression: `input` and `state-change` events fire only when
|
||||
the post-normalisation value differs from the previous value AND the
|
||||
press-state changed accordingly.
|
||||
- Strict-mode opt-in — `strict: true` makes post-`destroy()` `setState`
|
||||
throw `ControllerError(DESTROYED_INSTANCE)` instead of silently
|
||||
no-op'ing.
|
||||
- Zod-validated boundary on constructor options; failures surface as
|
||||
`ControllerError(INVALID_OPTIONS)` carrying the Zod issue list.
|
||||
- Idempotent `destroy()` — safe to call multiple times.
|
||||
- OpenTelemetry instrumentation — every `input` and `state-change` event
|
||||
records into the package's counters, attributed to the subclass's
|
||||
`device` tag.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BaseController } from "@suaveplan/controller";
|
||||
|
||||
class FakeController extends BaseController {
|
||||
protected override updateState(): void {
|
||||
// pump device-specific signals into setState() / pendingStateUpdates
|
||||
}
|
||||
}
|
||||
|
||||
const c = new FakeController({ device: "fake", pollRate: 30 });
|
||||
c.onInput("up", (input, value) => console.log(input, value));
|
||||
c.setState({ up: 200 });
|
||||
console.log(c.getValue("up")); // 200
|
||||
c.destroy();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { BaseController, ControllerError, CONTROLLER_CODES } from "@suaveplan/controller";
|
||||
|
||||
let t = 0;
|
||||
const clock: Clock = () => t;
|
||||
|
||||
class TestController extends BaseController {
|
||||
public constructor() {
|
||||
super({ clock, pollRate: 10, device: "test", strict: true });
|
||||
}
|
||||
protected override updateState(): void {}
|
||||
}
|
||||
|
||||
const c = new TestController();
|
||||
c.setThreshold("lx", { deadzone: 40 });
|
||||
c.setState({ lx: 20 });
|
||||
console.log(c.getValue("lx")); // 0 — deadzone snapped to zero
|
||||
|
||||
c.destroy();
|
||||
try {
|
||||
c.setState({ up: 1 });
|
||||
} catch (err) {
|
||||
if (err instanceof ControllerError && err.code === CONTROLLER_CODES.DESTROYED_INSTANCE) {
|
||||
console.warn("controller is shut down");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new BaseController({ clock?, idGenerator?, pollRate?, device?, strict? })`
|
||||
(abstract — must be extended)
|
||||
- `onInput(input, cb): Disposer`
|
||||
- `onAnyInput(cb): Disposer`
|
||||
- `onCombo(cb): Disposer`
|
||||
- `getState(): ControllerInput`
|
||||
- `getValue(input): number`
|
||||
- `setState(partial): void`
|
||||
- `setThreshold(input, { threshold?, deadzone? }): void`
|
||||
- `getDirectionalVector(): { x, y }`
|
||||
- `update(): void` — pollRate-gated; subclass `updateState()` runs at
|
||||
most once per `1000 / pollRate` ms window
|
||||
- `destroy(): void` — idempotent; clears state, listeners, pending
|
||||
updates, emitter
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class is `abstract` so consumers can't instantiate it directly;
|
||||
TypeScript enforces this and the destructor logic is meaningful only in
|
||||
a subclass context. Threshold normalisation runs in the order
|
||||
`clamp → round → deadzone → threshold` to match the upstream behaviour,
|
||||
where the deadzone is checked against the clamped magnitude before the
|
||||
threshold floor is applied. The `pendingStateUpdates` map is captured
|
||||
before the gating check so subclass overrides of `updateState()` that
|
||||
batch multiple `setState` calls (e.g. the merger) see a consistent map
|
||||
across the tick. Strict mode is wired through to the inner
|
||||
`InputEventEmitter` so the destroyed-state contract is enforced
|
||||
end-to-end without duplicate guards.
|
||||
@@ -0,0 +1,126 @@
|
||||
# `error-codes` — `CONTROLLER.*` registry + `ControllerError`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable `CONTROLLER.*` error-code registry and the typed
|
||||
`ControllerError` class used by every public surface in
|
||||
`@suaveplan/controller`. Centralising the codes here lets consumers
|
||||
switch on `error.code` against a finite literal union without needing
|
||||
`instanceof`, and survives boundary serialisation (e.g. when an error is
|
||||
posted across a worker channel and reconstructed by the receiver).
|
||||
|
||||
## Features
|
||||
|
||||
- `CONTROLLER_CODES` — frozen record from reason → fully-qualified code
|
||||
string, built via `@suaveplan/error`'s `defineCodes` helper.
|
||||
- `ControllerError extends SuaveplanError<ControllerCode>` — concrete
|
||||
error class with the `code` field narrowed to the literal union.
|
||||
- Stable namespace prefix (`CONTROLLER.`) shared across this package and
|
||||
every future Chunk B/C device adapter and merger code.
|
||||
- Forwards `cause` and `context` to the `SuaveplanError` base so the
|
||||
error wire format (validated by `@suaveplan/error`'s Zod schema) is
|
||||
preserved on traversal.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CONTROLLER_CODES, ControllerError } from "@suaveplan/controller";
|
||||
|
||||
throw new ControllerError(
|
||||
CONTROLLER_CODES.INVALID_INPUT_KEY,
|
||||
"Unknown input key passed to setThreshold()",
|
||||
{ context: { input: "notAKey" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CONTROLLER_CODES, ControllerError } from "@suaveplan/controller";
|
||||
|
||||
function handle(err: unknown): void {
|
||||
if (!(err instanceof ControllerError)) {
|
||||
throw err;
|
||||
}
|
||||
switch (err.code) {
|
||||
case CONTROLLER_CODES.INVALID_OPTIONS:
|
||||
console.warn("config rejected", err.context?.issues);
|
||||
break;
|
||||
case CONTROLLER_CODES.INVALID_INPUT_KEY:
|
||||
console.warn("unknown input", err.context?.input);
|
||||
break;
|
||||
case CONTROLLER_CODES.DESTROYED_INSTANCE:
|
||||
console.warn("operation on destroyed controller");
|
||||
break;
|
||||
case CONTROLLER_CODES.KEYBOARD_TARGET_MISSING:
|
||||
console.warn("keyboard target unresolved", err.context);
|
||||
break;
|
||||
case CONTROLLER_CODES.INVALID_KEY_MAP:
|
||||
console.warn("invalid key map entry", err.context);
|
||||
break;
|
||||
case CONTROLLER_CODES.GAMEPAD_API_UNAVAILABLE:
|
||||
console.warn("gamepad API unavailable");
|
||||
break;
|
||||
case CONTROLLER_CODES.INVALID_GAMEPAD_MAP:
|
||||
console.warn("invalid gamepad map entry", err.context);
|
||||
break;
|
||||
case CONTROLLER_CODES.DUPLICATE_CHILD:
|
||||
console.warn("merger child already attached", err.context);
|
||||
break;
|
||||
case CONTROLLER_CODES.UNKNOWN_CHILD:
|
||||
console.warn("merger child not attached", err.context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The switch is exhaustive against `ControllerCode`; adding a new code
|
||||
without handling it surfaces as a `noFallthroughCasesInSwitch`-friendly
|
||||
type error in strict-mode consumers.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CONTROLLER_CODES`
|
||||
- `INVALID_OPTIONS` — Zod boundary rejected the constructor options
|
||||
bundle; `context.issues` carries the Zod issue array.
|
||||
- `INVALID_INPUT_KEY` — `setThreshold` (or future device adapters)
|
||||
received an input key not present in `CONTROLLER_INPUT_KEYS`.
|
||||
- `DESTROYED_INSTANCE` — operation attempted after `destroy()` on an
|
||||
instance that does not silently no-op (strict-mode opt-in).
|
||||
- `KEYBOARD_TARGET_MISSING` — `KeyboardController.attach()` was
|
||||
called but neither an `EventTargetLike` was injected nor a global
|
||||
`window` was discoverable; `context.target` reports the resolved
|
||||
value.
|
||||
- `INVALID_KEY_MAP` — `KeyboardController` received a `keyMap` whose
|
||||
value is not one of `CONTROLLER_INPUT_KEYS`; `context.entry`
|
||||
carries the `[key, input]` tuple that failed validation.
|
||||
- `GAMEPAD_API_UNAVAILABLE` — `GamepadController.update()` was
|
||||
called but neither a `GamepadSource` was injected nor a global
|
||||
`navigator.getGamepads` was discoverable.
|
||||
- `INVALID_GAMEPAD_MAP` — `GamepadController` received a
|
||||
`buttonMap`/`axisMap` whose value targets an input key not present
|
||||
in `CONTROLLER_INPUT_KEYS`; `context.entry` carries the failing
|
||||
tuple.
|
||||
- `DUPLICATE_CHILD` — `ControllerMerger.addController()` received a
|
||||
child already attached, or a second child whose `id` collides with
|
||||
an existing one; `context` carries the child id.
|
||||
- `UNKNOWN_CHILD` — `ControllerMerger.removeController()` was called
|
||||
with a child that is not currently attached.
|
||||
- `ControllerCode` — literal union of every code string.
|
||||
- `class ControllerError extends SuaveplanError<ControllerCode>` —
|
||||
constructor: `(code, message, { cause?, context? })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes are registered with `defineCodes("CONTROLLER", [...] as const)` so
|
||||
the returned record is `Object.freeze`d at module load. Chunks B and C
|
||||
will need to extend the union; the pattern is to declare a second
|
||||
`defineCodes(...)` block in their own modules and merge at the package
|
||||
barrel via a union type. The class deliberately does not capture the
|
||||
`Clock` directly — `SuaveplanError`'s base captures `Date.now()` via the
|
||||
optional `clock` option, which the controller package opts out of so the
|
||||
error timestamps remain unambiguously wall-clock for log correlation.
|
||||
@@ -0,0 +1,90 @@
|
||||
# `events` — `InputEventEmitter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Typed event dispatcher used by `BaseController` to fan input, state and
|
||||
combo events out to listeners. Centralising the dispatcher in its own
|
||||
module keeps the lifecycle FSM in `BaseController` focused on
|
||||
normalisation logic, and lets downstream consumers (e.g. the merger in
|
||||
Chunk C) reuse the same disposable-listener contract without depending
|
||||
on the base class.
|
||||
|
||||
## Features
|
||||
|
||||
- Three typed channels: `onInput(key, cb)`, `onStateChange(cb)`,
|
||||
`onCombo(cb)`. Every registration returns a `Disposer` that removes
|
||||
exactly the registered listener without affecting siblings.
|
||||
- Per-key bucket storage with `Set` semantics: O(1) add, O(1) remove,
|
||||
iteration in insertion order.
|
||||
- Last-value tracking — each `emitInput` passes the previous value to
|
||||
the listener so consumers can compute deltas without storing state.
|
||||
- Strict-mode opt-in: `new InputEventEmitter({ strict: true })` throws
|
||||
`ControllerError(DESTROYED_INSTANCE)` when an `on*` method is called
|
||||
after `destroy()`. Default mode silently returns a no-op disposer.
|
||||
- Zod-validated options boundary — invalid construction throws
|
||||
`ControllerError(INVALID_OPTIONS)` with the Zod issue array in
|
||||
`context.issues`.
|
||||
- Idempotent `destroy()` — calling twice is safe.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InputEventEmitter } from "@suaveplan/controller";
|
||||
|
||||
const emitter = new InputEventEmitter();
|
||||
const off = emitter.onInput("up", (input, value, prev) => {
|
||||
console.log(input, value, prev);
|
||||
});
|
||||
emitter.emitInput("up", 255);
|
||||
off();
|
||||
emitter.destroy();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { InputEventEmitter, ControllerError, CONTROLLER_CODES } from "@suaveplan/controller";
|
||||
|
||||
const emitter = new InputEventEmitter({ strict: true });
|
||||
emitter.destroy();
|
||||
try {
|
||||
emitter.onInput("a", () => undefined);
|
||||
} catch (err) {
|
||||
if (err instanceof ControllerError && err.code === CONTROLLER_CODES.DESTROYED_INSTANCE) {
|
||||
console.warn("emitter no longer accepting listeners");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The strict mode is what `BaseController` opts into to enforce its own
|
||||
destroyed-state guarantees; default mode preserves the upstream emitter
|
||||
shape used by older fan-out callers that expect post-destroy
|
||||
registrations to be silent.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new InputEventEmitter({ strict? }: InputEventEmitterOptions)`
|
||||
- `onInput(input, cb): Disposer`
|
||||
- `onStateChange(cb): Disposer`
|
||||
- `onCombo(cb): Disposer`
|
||||
- `emitInput(input, value): void`
|
||||
- `emitStateChange(state, oldState?): void`
|
||||
- `emitCombo(combo): void`
|
||||
- `clear(): void`
|
||||
- `destroy(): void`
|
||||
- `destroyed: boolean` (getter)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The per-key bucket is allocated lazily so an emitter with no listeners
|
||||
on a given key pays no map cost. Disposers capture the `callback` and
|
||||
`input` by closure rather than indexing into the listener registry, so
|
||||
multiple `onInput("up", cb)` calls with the same callback are coalesced
|
||||
by the `Set` (no double-fire), and disposing one call removes the entry
|
||||
cleanly. `emitInput` records the new value into `lastInputValues` even
|
||||
when there is no registered listener, so a late subscriber's first event
|
||||
sees the correct `prevValue`.
|
||||
@@ -0,0 +1,114 @@
|
||||
# `gamepad.internal` — gamepad-adapter validators + global resolver
|
||||
|
||||
## Purpose
|
||||
|
||||
Module-private helpers used by `GamepadController` to keep the main
|
||||
class file under the repository's 300-line hard cap. Centralising
|
||||
these as standalone functions also makes them easy to fuzz from
|
||||
focused unit tests in `gamepad.test.ts` without instantiating a
|
||||
full controller.
|
||||
|
||||
The exports are not part of the package's public API — they are
|
||||
re-bound by name inside `GamepadController` and the package barrel
|
||||
does not surface them. Treat the file as the implementation backing
|
||||
store for the gamepad adapter's option-validation and global
|
||||
discovery surface.
|
||||
|
||||
## Features
|
||||
|
||||
- `validateButtonMap(map)` — verifies every entry in a
|
||||
`GamepadButtonMap` targets a key listed in
|
||||
`CONTROLLER_INPUT_KEYS`. Returns a frozen shallow copy on
|
||||
success; throws `ControllerError(INVALID_GAMEPAD_MAP)` on the
|
||||
first invalid entry with the offending tuple in `context.entry`.
|
||||
- `validateAxisMap(map)` — verifies each
|
||||
`positive` / `negative` / `analog` binding inside every axis
|
||||
entry. Each entry is independently frozen, then the outer record
|
||||
is frozen. Errors carry the `[rawIndex, role, value]` tuple in
|
||||
`context.entry`.
|
||||
- `resolveGlobalGamepadSource()` — discovers
|
||||
`globalThis.navigator.getGamepads` lazily, returning a bound
|
||||
`GamepadSource` when the global is present and a function. Returns
|
||||
`undefined` otherwise — the adapter only throws
|
||||
`GAMEPAD_API_UNAVAILABLE` when `update()` is later called without
|
||||
having injected a source.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
validateButtonMap,
|
||||
validateAxisMap,
|
||||
} from "./gamepad.internal.js";
|
||||
|
||||
const buttonMap = validateButtonMap({ 0: "a", 1: "b" });
|
||||
const axisMap = validateAxisMap({
|
||||
0: { positive: "right", negative: "left", analog: "lx" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The validators throw at the first invalid entry, so callers that
|
||||
want to collect every problem can wrap each entry in a try/catch:
|
||||
|
||||
```ts
|
||||
import {
|
||||
validateButtonMap,
|
||||
validateAxisMap,
|
||||
} from "./gamepad.internal.js";
|
||||
|
||||
const candidate = { 0: "a", 99: "bogus" } as Record<number, string>;
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
validateButtonMap(candidate as Parameters<typeof validateButtonMap>[0]);
|
||||
} catch (err) {
|
||||
errors.push(err);
|
||||
}
|
||||
if (errors.length === 0) {
|
||||
// candidate is valid
|
||||
}
|
||||
|
||||
// Hosts without `navigator` (Node, Bun outside a DOM polyfill)
|
||||
// receive `undefined` and must inject a source explicitly:
|
||||
import { resolveGlobalGamepadSource } from "./gamepad.internal.js";
|
||||
const source = resolveGlobalGamepadSource();
|
||||
if (source === undefined) {
|
||||
// fall back to a virtual source
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `validateButtonMap(map: GamepadButtonMap): GamepadButtonMap` —
|
||||
validates + freezes a button map. Throws
|
||||
`ControllerError(INVALID_GAMEPAD_MAP)` on invalid entries.
|
||||
- `validateAxisMap(map: GamepadAxisMap): GamepadAxisMap` —
|
||||
validates + freezes an axis map, including each per-binding
|
||||
inner object. Throws `ControllerError(INVALID_GAMEPAD_MAP)` on
|
||||
invalid entries.
|
||||
- `resolveGlobalGamepadSource(): GamepadSource | undefined` — best
|
||||
-effort discovery of the host's `navigator.getGamepads`. Always
|
||||
returns synchronously and never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Validators take the input map as a value (not a class field) so
|
||||
the unit tests can exercise them without standing up a full
|
||||
`GamepadController`. The maps are checked once at construction
|
||||
time; consumers cannot mutate the runtime-bound versions.
|
||||
- `Object.freeze` is applied at two levels for axis maps (each
|
||||
binding and the outer record) so accidental rebinding via
|
||||
`axisMap[2] = …` surfaces as a `TypeError` in strict-mode
|
||||
consumers rather than silently mutating the adapter.
|
||||
- `resolveGlobalGamepadSource` binds the discovered function to
|
||||
`navigator` so callers that later strip the receiver (e.g.
|
||||
destructuring) keep the correct `this`. Returning `() =>
|
||||
getGamepads()` rather than the raw bound function keeps the
|
||||
signature predictable when the underlying global is replaced
|
||||
after construction (rare, but observed in some test harnesses).
|
||||
- The function `globalThis as unknown as { navigator?: … }` cast is
|
||||
the only place in the gamepad module that touches a host global;
|
||||
centralising it here keeps the cast localised and means the
|
||||
Biome `noExplicitAny` rule never has to allow exceptions in the
|
||||
main class file.
|
||||
@@ -0,0 +1,142 @@
|
||||
# `gamepad` — `GamepadController`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Browser-tier device adapter that polls the Web Gamepad API and
|
||||
normalises button + axis state into the canonical 37-field
|
||||
`ControllerInput` shape exposed by `BaseController`. The adapter
|
||||
covers the W3C "standard mapping" layout (face buttons, bumpers,
|
||||
triggers, sticks, D-pad, system buttons) out of the box and exposes
|
||||
the button + axis maps so non-standard pads or custom layouts can be
|
||||
described per-instance.
|
||||
|
||||
The class never reads from `navigator` at module-load time. The
|
||||
`GamepadSource` is either supplied via `options.source` or resolved
|
||||
lazily from `globalThis.navigator.getGamepads()` when `update()`
|
||||
runs. Tests inject a deterministic source returning fixture pads and
|
||||
drive the adapter directly — no jsdom or `navigator` polyfill
|
||||
required.
|
||||
|
||||
## Features
|
||||
|
||||
- Standard W3C button mapping for all 17 indices, including analog
|
||||
triggers (`lt`/`rt`) and D-pad-to-digital mapping.
|
||||
- Standard axis mapping covering both digital halves
|
||||
(`right`/`left`/`up`/`down`) and centred analog representations
|
||||
(`lx`/`ly`/`rx`/`ry`).
|
||||
- Per-axis deadzone (defaults to `0.1`) applied before the magnitude
|
||||
is rescaled to the `[0, 255]` field range.
|
||||
- Per-instance `padIndex` so two adapters on the same source can
|
||||
drive split-screen co-op without further routing.
|
||||
- Connection diff — when the source returns `null`, `undefined`, or
|
||||
a pad with `connected: false`, the adapter zeroes the mapped
|
||||
fields exactly once and stops emitting until the pad reappears.
|
||||
- Validates every map entry against `CONTROLLER_INPUT_KEYS`; invalid
|
||||
entries throw `ControllerError(INVALID_GAMEPAD_MAP)` with the
|
||||
offending tuple in `context.entry`.
|
||||
- Inherits the full `BaseController` surface — `onInput`,
|
||||
`onAnyInput`, `setThreshold`, `getDirectionalVector`,
|
||||
telemetry instrumentation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GamepadController } from "@suaveplan/controller/gamepad";
|
||||
|
||||
const gp = new GamepadController();
|
||||
gp.onInput("a", (_input, value) => {
|
||||
if (value === 255) console.log("A pressed");
|
||||
});
|
||||
|
||||
// pump every frame
|
||||
function tick() {
|
||||
gp.update();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
tick();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the adapter from a synthetic source with deterministic timing
|
||||
— no browser globals required:
|
||||
|
||||
```ts
|
||||
import { GamepadController } from "@suaveplan/controller/gamepad";
|
||||
import type { GamepadLike } from "@suaveplan/controller/gamepad";
|
||||
|
||||
let pad: GamepadLike | null = null;
|
||||
const gp = new GamepadController({
|
||||
source: () => [pad],
|
||||
deadzone: 0.2,
|
||||
padIndex: 0,
|
||||
axisMap: {
|
||||
0: { positive: "right", negative: "left", analog: "lx" },
|
||||
1: { positive: "down", negative: "up", analog: "ly" },
|
||||
},
|
||||
buttonMap: { 0: "a", 1: "b" },
|
||||
clock: () => performance.now(),
|
||||
pollRate: 120,
|
||||
});
|
||||
|
||||
pad = {
|
||||
buttons: [
|
||||
{ pressed: true, value: 1 },
|
||||
{ pressed: false, value: 0 },
|
||||
],
|
||||
axes: [0.75, -0.5],
|
||||
};
|
||||
gp.update();
|
||||
console.log(gp.getValue("a")); // 255
|
||||
console.log(gp.getValue("right")); // 191
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class GamepadController extends BaseController`
|
||||
- `constructor(options?: GamepadControllerOptions)`
|
||||
- `update(): void` — polls the source, diffs against last frame,
|
||||
pushes updates through `setState()`. Throws
|
||||
`ControllerError(GAMEPAD_API_UNAVAILABLE)` when no source is
|
||||
available. No-op after `destroy()`.
|
||||
- `getDeadzone(): number` — resolved deadzone.
|
||||
- `getPadIndex(): number` — bound pad index.
|
||||
- `getButtonMap(): GamepadButtonMap` — immutable button map.
|
||||
- `getAxisMap(): GamepadAxisMap` — immutable axis map.
|
||||
- `DEFAULT_GAMEPAD_BUTTON_MAP: GamepadButtonMap` — W3C standard
|
||||
mapping.
|
||||
- `DEFAULT_GAMEPAD_AXIS_MAP: GamepadAxisMap` — W3C standard axis
|
||||
layout (left + right sticks).
|
||||
- `interface GamepadControllerOptions extends BaseControllerOptions`
|
||||
— adds `source`, `buttonMap`, `axisMap`, `deadzone`, `padIndex`.
|
||||
- `interface GamepadLike` — structural subset of `Gamepad`
|
||||
(`buttons[i].pressed`, `buttons[i].value`, `axes[i]`,
|
||||
`connected`).
|
||||
- `type GamepadSource = () => readonly (GamepadLike | null | undefined)[]`.
|
||||
- `interface GamepadAxisBinding` — `positive`, `negative`, `analog`
|
||||
fields, all optional.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Button values are taken from `buttons[i].value` when present so
|
||||
analog triggers report the full `[0, 255]` range. Pads that omit
|
||||
`value` (older drivers, software emulators) fall back to a binary
|
||||
`pressed ? 255 : 0`.
|
||||
- Axis values are clamped to `[-1, 1]` before the deadzone test so
|
||||
drivers that report slightly over-range readings (common on dead
|
||||
or worn sticks) cannot push the field above `255`.
|
||||
- The disconnect zero-out is one-shot — guarded by a `wasConnected`
|
||||
flag — so a permanently absent pad does not flood downstream
|
||||
consumers with redundant `value=0` events every poll.
|
||||
- The Zod options schema is `.strict()` so unknown options are
|
||||
rejected at the boundary as `ControllerError(INVALID_OPTIONS)`;
|
||||
field-level rejections (e.g. `deadzone: -1`) flow through the
|
||||
same code with the Zod issues in `context.issues`.
|
||||
- `super.update()` re-applies the `pollRate` gate, so callers may
|
||||
invoke `update()` more often than the configured rate without
|
||||
paying repeated traversal cost — the source function is only
|
||||
invoked once per gated tick.
|
||||
@@ -0,0 +1,134 @@
|
||||
# `keyboard` — `KeyboardController`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Browser-tier device adapter that normalises DOM `keydown`/`keyup`
|
||||
events into the canonical 37-field `ControllerInput` shape exposed by
|
||||
`BaseController`. The adapter exists so games, UIs, and headless test
|
||||
harnesses can consume the same controller surface whether the user is
|
||||
pressing physical keys, dispatching synthetic events from a test
|
||||
double, or driving the adapter from a non-DOM emitter such as a
|
||||
custom virtual keyboard.
|
||||
|
||||
The class never touches a global at module load time. The target is
|
||||
either supplied via `options.target` or resolved lazily from
|
||||
`globalThis.window` when `attach()` runs. Tests inject an
|
||||
`EventTargetLike` double and drive the adapter directly — no jsdom or
|
||||
browser environment is required.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `keyMap` covering arrow keys, WASD, ZXCV face buttons,
|
||||
QERT triggers, and `Digit1`–`Digit4` function keys out of the box.
|
||||
- Validates every key-map entry against `CONTROLLER_INPUT_KEYS` at
|
||||
construction; invalid entries throw
|
||||
`ControllerError(INVALID_KEY_MAP)` with the offending tuple in
|
||||
`context.entry`.
|
||||
- `attach()` / `detach()` are idempotent and survive `destroy()`
|
||||
cleanly (no listeners leak, `attach()` after destroy is a no-op).
|
||||
- Repeats and double-presses are suppressed (`event.repeat === true`
|
||||
and already-pressed keys are ignored).
|
||||
- Optional `preventDefault: true` to suppress default browser
|
||||
behaviour (e.g. spacebar scrolling) without leaking that decision
|
||||
into the input pipeline.
|
||||
- Inherits the full `BaseController` surface — `onInput`,
|
||||
`onAnyInput`, `onCombo`, `setThreshold`, `getDirectionalVector`,
|
||||
`update()`, telemetry instrumentation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { KeyboardController } from "@suaveplan/controller/keyboard";
|
||||
|
||||
const kb = new KeyboardController();
|
||||
kb.onInput("up", (_input, value) => {
|
||||
console.log(value === 255 ? "pressed up" : "released up");
|
||||
});
|
||||
// later
|
||||
kb.destroy();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the adapter from a synthetic emitter — no DOM required:
|
||||
|
||||
```ts
|
||||
import { KeyboardController } from "@suaveplan/controller/keyboard";
|
||||
import type { EventTargetLike } from "@suaveplan/controller/keyboard";
|
||||
|
||||
const listeners = new Map<string, Set<(e: unknown) => void>>();
|
||||
const target: EventTargetLike = {
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? new Set();
|
||||
bucket.add(listener as never);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
listeners.get(type)?.delete(listener as never);
|
||||
},
|
||||
};
|
||||
|
||||
const kb = new KeyboardController({
|
||||
target,
|
||||
keyMap: { Space: "a", KeyJ: "b" },
|
||||
preventDefault: true,
|
||||
pollRate: 120,
|
||||
});
|
||||
|
||||
const dispatch = (type: string, event: unknown) => {
|
||||
for (const cb of listeners.get(type) ?? []) cb(event as never);
|
||||
};
|
||||
dispatch("keydown", { code: "Space" });
|
||||
console.log(kb.getValue("a")); // 255
|
||||
dispatch("keyup", { code: "Space" });
|
||||
kb.destroy();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class KeyboardController extends BaseController`
|
||||
- `constructor(options?: KeyboardControllerOptions)`
|
||||
- `attach(): void` — bind `keydown`/`keyup` to the resolved
|
||||
target. Throws `ControllerError(KEYBOARD_TARGET_MISSING)` when
|
||||
no target is available.
|
||||
- `detach(): void` — idempotent removal of listeners.
|
||||
- `getPressedKeys(): readonly string[]` — snapshot of physical
|
||||
keys currently held down (by `code` or `key` identifier).
|
||||
- `getKeyMap(): KeyboardKeyMap` — immutable key → input map.
|
||||
- `destroy(): void` — detaches listeners and clears state.
|
||||
- `DEFAULT_KEYBOARD_KEY_MAP: KeyboardKeyMap` — frozen default map
|
||||
used when `options.keyMap` is omitted.
|
||||
- `interface KeyboardControllerOptions extends BaseControllerOptions`
|
||||
— adds `target`, `keyMap`, `autoAttach`, `preventDefault`.
|
||||
- `interface EventTargetLike` — structural subset of `EventTarget`.
|
||||
- `interface KeyboardEventLike` — structural subset of
|
||||
`KeyboardEvent` (`code`, `key`, `repeat`, `preventDefault`).
|
||||
- `type KeyboardKeyMap = Readonly<Record<string, keyof ControllerInput>>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The adapter writes through `BaseController.setState({ [input]:
|
||||
value })` rather than reaching into `pendingStateUpdates`. This
|
||||
keeps the OTel counters (`controller_inputs_total`,
|
||||
`controller_state_changes_total`) and the `InputEventEmitter`
|
||||
firing on the same code path as direct programmatic updates.
|
||||
- Key identification consults `event.code` first, then `event.key`.
|
||||
Both must be strings; the adapter rejects events whose identifier
|
||||
is `undefined`. This matches the upstream behaviour and aligns
|
||||
with W3C UI Events.
|
||||
- Repeat suppression is double-guarded: the adapter ignores
|
||||
`event.repeat === true` and also short-circuits when the key is
|
||||
already tracked as pressed.
|
||||
- The Zod options schema covers every public option; rejections are
|
||||
surfaced as `ControllerError(INVALID_OPTIONS)` with the Zod issues
|
||||
in `context.issues`. The schema is `.strict()` so unknown keys are
|
||||
rejected at the boundary — typos surface as construction errors
|
||||
rather than silent ignores.
|
||||
- `setState` accepts a partial `ControllerInput`, so the adapter
|
||||
computes the field per event rather than buffering. This keeps the
|
||||
per-event latency below the `pollRate` gate that `BaseController`
|
||||
applies to `update()`.
|
||||
@@ -0,0 +1,169 @@
|
||||
# `merger` — `ControllerMerger`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ControllerMerger` fuses an ordered list of `BaseController` children
|
||||
into a single virtual controller whose `ControllerInput` snapshot is the
|
||||
per-field maximum of every attached child. It is the layer that lets a
|
||||
game treat keyboard, gamepad, touch, and remote network controllers as
|
||||
one unified action source without re-binding consumers when a player
|
||||
adds or removes a device mid-session.
|
||||
|
||||
The merger extends `BaseController` so it transparently inherits the
|
||||
typed `InputEventEmitter`, threshold/deadzone normalisation, OpenTelemetry
|
||||
counters, and lifecycle guards. Children are observed through their
|
||||
public `onAnyInput` and `onCombo` subscriptions only — the merger never
|
||||
peeks at protected state, so any subclass of `BaseController` (current
|
||||
or future) plugs in without an adapter shim.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-field `Math.max` reduction across N children, walked in stable
|
||||
insertion order over the canonical `CONTROLLER_INPUT_KEYS`.
|
||||
- Subscription-based wiring — child state-change events are funnelled
|
||||
into per-child snapshots, then merged on `update()`.
|
||||
- Combo events from any child are re-emitted through the merger's own
|
||||
`eventEmitter` so consumers only listen in one place.
|
||||
- Duplicate-detection by both instance identity and `child.id`, raising
|
||||
`ControllerError(CONTROLLER.DUPLICATE_CHILD)` early.
|
||||
- `removeController()` validates membership and throws
|
||||
`ControllerError(CONTROLLER.UNKNOWN_CHILD)` for stale references.
|
||||
- Lifecycle propagation — `destroy()` disposes every subscription and
|
||||
destroys every child before clearing local state.
|
||||
- pollRate gating inherited from `BaseController`, driven by the same
|
||||
injected `Clock` so tests can use a `FakeClock` without globals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
BaseController,
|
||||
ControllerMerger,
|
||||
} from "@suaveplan/controller";
|
||||
|
||||
// Two concrete children produced by Chunk B's adapters.
|
||||
declare const keyboard: BaseController;
|
||||
declare const gamepad: BaseController;
|
||||
|
||||
const merger = new ControllerMerger({ children: [keyboard, gamepad] });
|
||||
|
||||
merger.onAnyInput((state) => {
|
||||
// Single subscription regardless of how many devices are connected.
|
||||
render(state);
|
||||
});
|
||||
|
||||
// Drive every tick of the game loop.
|
||||
function tick(): void {
|
||||
merger.update();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
BaseController,
|
||||
CONTROLLER_CODES,
|
||||
ControllerError,
|
||||
ControllerMerger,
|
||||
} from "@suaveplan/controller";
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
|
||||
const fakeClock: Clock = (() => {
|
||||
let t = 0;
|
||||
return () => (t += 16);
|
||||
})();
|
||||
let n = 0;
|
||||
const ids: IdGenerator = () => `m-${++n}`;
|
||||
|
||||
const merger = new ControllerMerger({
|
||||
clock: fakeClock,
|
||||
idGenerator: ids,
|
||||
pollRate: 120,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
declare const padA: BaseController;
|
||||
declare const padB: BaseController;
|
||||
merger.addController(padA);
|
||||
|
||||
try {
|
||||
merger.addController(padA);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof ControllerError &&
|
||||
err.code === CONTROLLER_CODES.DUPLICATE_CHILD
|
||||
) {
|
||||
console.warn("already attached:", err.context);
|
||||
}
|
||||
}
|
||||
|
||||
merger.addController(padB);
|
||||
merger.setThreshold("lx", { deadzone: 8, threshold: 32 });
|
||||
merger.update();
|
||||
|
||||
merger.removeController(padA);
|
||||
merger.destroy(); // destroys padB as well
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ControllerMerger extends BaseController`
|
||||
- `constructor(options?: ControllerMergerOptions)` — accepts every
|
||||
`BaseControllerOptions` field plus `children?: readonly BaseController[]`
|
||||
seeded eagerly via `addController()`.
|
||||
- `addController(child: BaseController): void` — subscribes to
|
||||
`onAnyInput` + `onCombo`, captures the current snapshot, marks the
|
||||
merger dirty. Throws `CONTROLLER.DUPLICATE_CHILD` on identity or
|
||||
`id` collision.
|
||||
- `removeController(child: BaseController): void` — disposes
|
||||
subscriptions, drops the snapshot, marks dirty. Throws
|
||||
`CONTROLLER.UNKNOWN_CHILD` when the child is not attached.
|
||||
- `getChildren(): MergerChildren` — read-only snapshot of children
|
||||
in insertion order.
|
||||
- `get childCount(): number` — current attachment count.
|
||||
- `update(): void` — polls every child, then runs the per-field max
|
||||
reduction over `CONTROLLER_INPUT_KEYS` and routes diffs through
|
||||
`updateInput` so threshold/deadzone gating + event emission stay
|
||||
consistent.
|
||||
- `destroy(): void` — disposes all subscriptions, destroys every
|
||||
child, clears local state, then defers to `BaseController.destroy`.
|
||||
- `interface ControllerMergerOptions extends BaseControllerOptions`
|
||||
- `children?: readonly BaseController[]` — initial seed.
|
||||
- `type MergerChildren = readonly BaseController[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The merger captures child snapshots **eagerly on subscription** so the
|
||||
first `update()` after construction reflects whatever state the child
|
||||
already has, even if no further input events fire between `addController`
|
||||
and `update`. Snapshots are stored keyed by `child.id` rather than by
|
||||
identity reference so the lookup remains O(1) and the duplicate guard
|
||||
shares the same key space.
|
||||
|
||||
`update()` is overridden in full rather than using the base
|
||||
`updateState()` template. The base `update()` short-circuits when
|
||||
`needsUpdate` is false, which would skip child polling entirely — a
|
||||
visible bug in a tick-driven game loop where children must always be
|
||||
polled regardless of whether the merger itself observed prior changes.
|
||||
The override polls children first, then applies the same pollRate gating
|
||||
the base class would have applied before mutating local state.
|
||||
|
||||
Per-field reduction uses `Math.max` because every input value lives in
|
||||
`[0, 255]` and the desired semantic is "any source wins" — a pressed
|
||||
button on the keyboard plus a released button on the gamepad should
|
||||
read as pressed, and a partially deflected stick on one pad should not
|
||||
be cancelled by a centred stick on another. This matches the upstream
|
||||
contract and avoids subtle precedence rules between devices.
|
||||
|
||||
`destroy()` propagates to children because the merger owns the wiring;
|
||||
leaving children alive after the merger goes away leaves dangling
|
||||
subscriptions on the children's emitters and is the failure mode
|
||||
removed-without-destroy would otherwise create. Callers that need
|
||||
independent child lifecycles call `removeController()` before
|
||||
`destroy()`.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `telemetry` — OpenTelemetry instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`:
|
||||
two counters tracking input and state-change throughput per device, and
|
||||
one histogram for the pattern engine's match-attempt duration. Both
|
||||
counters are exercised by `BaseController` via the `recordInput` and
|
||||
`recordStateChange` helpers; the histogram is reserved for the pattern
|
||||
matcher landing in Chunk B/C.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy-bound meter — instruments resolve against the globally registered
|
||||
`MeterProvider` on first import and fall back to a no-op meter when
|
||||
none is registered. Production callers pay zero cost for OTel.
|
||||
- `controller_inputs_total` counter with `device` + `input` attributes
|
||||
for fine-grained per-input throughput.
|
||||
- `controller_state_changes_total` counter with the `device` attribute.
|
||||
- `controller_pattern_match_duration_ms` histogram (unit `ms`) for the
|
||||
pattern engine.
|
||||
- Typed attribute bundles (`InputCounterAttributes`,
|
||||
`StateCounterAttributes`) prevent string-typo bugs at the call site.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordInput, recordStateChange } from "@suaveplan/controller";
|
||||
|
||||
recordInput({ device: "keyboard", input: "up" });
|
||||
recordStateChange({ device: "keyboard" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { recordInput } from "@suaveplan/controller";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
recordInput({ device: "gamepad", input: "a" });
|
||||
await provider.forceFlush();
|
||||
const points = exporter.getMetrics();
|
||||
```
|
||||
|
||||
The same pattern is exercised in the package's polyfill setup; see
|
||||
`tests/setup/polyfills.ts` for the canonical wire-up used by the
|
||||
in-memory MeterProvider assertions.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `inputsCounter: Counter` — OTel counter `controller_inputs_total`.
|
||||
- `stateChangesCounter: Counter` —
|
||||
`controller_state_changes_total`.
|
||||
- `patternMatchDurationHistogram: Histogram` —
|
||||
`controller_pattern_match_duration_ms`.
|
||||
- `recordInput({ device, input }): void` — single-call helper.
|
||||
- `recordStateChange({ device }): void` — single-call helper.
|
||||
- `recordPatternMatchDuration(durationMs): void` — histogram helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are module-scoped so the meter is resolved exactly once
|
||||
per process. Tests inject their `MeterProvider` via
|
||||
`metrics.setGlobalMeterProvider` BEFORE the source module is imported —
|
||||
the package's `tests/setup/polyfills.ts` runs as a setup file and
|
||||
guarantees the binding order. Switching to a deferred-resolve pattern
|
||||
would require every adapter to plumb a meter object through its
|
||||
constructor; the current shape keeps the device-adapter APIs free of
|
||||
observability boilerplate while still allowing override via OTel's
|
||||
`setGlobalMeterProvider` contract.
|
||||
@@ -0,0 +1,93 @@
|
||||
# `controller.types` — shared types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the canonical TypeScript types shared across every module in
|
||||
`@suaveplan/controller`. Centralising the public type vocabulary here
|
||||
keeps the shape of `ControllerInput` consistent across `BaseController`,
|
||||
the event emitter, the device adapters (Chunks B) and the merger
|
||||
(Chunk C), and lets downstream consumers (`@suaveplan/ecs`,
|
||||
`@suaveplan/game-server`) import the shape from a single subpath without
|
||||
pulling the runtime in.
|
||||
|
||||
## Features
|
||||
|
||||
- `ControllerInput` — the canonical 37-field input shape every adapter
|
||||
normalises into. Fields are integers in `[0, 255]` post-normalisation.
|
||||
- `ControllerState` — backward-compatible alias for `ControllerInput`.
|
||||
- `Vector2D` — return type of `BaseController.getDirectionalVector()`.
|
||||
- `InputThresholds` — `{ threshold?, deadzone? }` gating bundle accepted
|
||||
by `setThreshold(input, ...)`.
|
||||
- `InputChangeCallback`, `StateChangeCallback`, `ComboCallback` — the
|
||||
three listener signatures used by `InputEventEmitter`.
|
||||
- `ControllerSnapshot` — `{ input, value, timestamp }` point-in-time
|
||||
record used by the pattern engine that lands in a follow-up chunk.
|
||||
- `InputCounterAttributes`, `StateCounterAttributes` — typed attribute
|
||||
bundles for the OTel counters in `telemetry.ts`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ControllerInput, Vector2D } from "@suaveplan/controller";
|
||||
|
||||
function magnitude(v: Vector2D): number {
|
||||
return Math.hypot(v.x, v.y);
|
||||
}
|
||||
|
||||
function isJumping(s: ControllerInput): boolean {
|
||||
return s.a >= 200;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ControllerInput,
|
||||
InputChangeCallback,
|
||||
InputThresholds,
|
||||
} from "@suaveplan/controller";
|
||||
|
||||
interface AbilityBinding<K extends keyof ControllerInput> {
|
||||
input: K;
|
||||
thresholds: InputThresholds;
|
||||
onTrigger: InputChangeCallback;
|
||||
}
|
||||
|
||||
function bind<K extends keyof ControllerInput>(b: AbilityBinding<K>): AbilityBinding<K> {
|
||||
return b;
|
||||
}
|
||||
|
||||
const jump = bind({
|
||||
input: "a",
|
||||
thresholds: { deadzone: 32, threshold: 200 },
|
||||
onTrigger: (_input, value, prev) => {
|
||||
if (value >= 200 && prev < 200) console.log("jump!");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ControllerInput` — 37-field numeric shape.
|
||||
- `type ControllerState = ControllerInput` — alias.
|
||||
- `interface Vector2D { x: number; y: number }`.
|
||||
- `interface InputThresholds { threshold?: number; deadzone?: number }`.
|
||||
- `type InputChangeCallback = (input, value, prev) => void`.
|
||||
- `type StateChangeCallback = (state, oldState?) => void`.
|
||||
- `type ComboCallback = (combo: string[]) => void`.
|
||||
- `interface ControllerSnapshot { input, value, timestamp }`.
|
||||
- `interface InputCounterAttributes { device, input }`.
|
||||
- `interface StateCounterAttributes { device }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every type is declared with explicit field order so adding a new key
|
||||
shows up as an intentional diff in code review. The 37 fields are split
|
||||
into named comment-bands (directional, face, triggers, function, analog
|
||||
sticks, stick buttons, analog triggers, motion, touch, special) so the
|
||||
file scans like the spec's grouped description. The runtime iteration
|
||||
order over these keys lives in `keys.ts` — keep the two files in lockstep
|
||||
when adding or removing a key. All callback shapes accept the typed
|
||||
`keyof ControllerInput` discriminator (rather than `string`) so misspelled
|
||||
keys are caught at the call site.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Controller input keys
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the canonical iteration order over the 37 `ControllerInput` keys
|
||||
and a `createDefaultControllerInput()` factory that returns a fresh
|
||||
zero-initialised state object. Centralising the key list here prevents the
|
||||
37-field shape from drifting across `BaseController`, the device adapters,
|
||||
and the merger — every consumer that walks the shape iterates this same
|
||||
constant.
|
||||
|
||||
## Features
|
||||
|
||||
- `CONTROLLER_INPUT_KEYS` — `readonly` tuple of the 37 keys in stable
|
||||
documented order (directional → face → triggers → function → analog
|
||||
sticks → stick buttons → analog triggers → motion → touch → special).
|
||||
- `createDefaultControllerInput()` — factory returning a new object with
|
||||
every key initialised to `0`.
|
||||
- Deterministic ordering, suitable for the merger's per-field max
|
||||
reduction and for any serialiser that needs reproducible output.
|
||||
- Zero allocation cost beyond the returned object — the key list is
|
||||
shared module state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CONTROLLER_INPUT_KEYS,
|
||||
createDefaultControllerInput,
|
||||
} from "@suaveplan/controller";
|
||||
|
||||
const state = createDefaultControllerInput();
|
||||
console.log(state.up); // 0
|
||||
console.log(CONTROLLER_INPUT_KEYS.length); // 37
|
||||
```
|
||||
|
||||
The shape has exactly 37 enumerated keys (4 directional + 4 face + 4
|
||||
trigger + 4 function + 4 analog axes + 2 stick buttons + 2 analog
|
||||
triggers + 6 motion + 3 touch + 4 special). The exported tuple
|
||||
preserves the exact iteration order the merger relies on.
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CONTROLLER_INPUT_KEYS,
|
||||
createDefaultControllerInput,
|
||||
type ControllerInput,
|
||||
} from "@suaveplan/controller";
|
||||
|
||||
function mergeMax(a: ControllerInput, b: ControllerInput): ControllerInput {
|
||||
const out = createDefaultControllerInput();
|
||||
for (const key of CONTROLLER_INPUT_KEYS) {
|
||||
out[key] = Math.max(a[key], b[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
The merger that ships in Chunk C uses exactly this loop shape.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CONTROLLER_INPUT_KEYS: readonly (keyof ControllerInput)[]` — frozen
|
||||
tuple of the 32 controller-input keys.
|
||||
- `createDefaultControllerInput(): ControllerInput` — returns a brand-new
|
||||
object with every field set to `0`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The key list is declared `as const` so TypeScript infers the literal
|
||||
tuple type and downstream code can narrow `keyof ControllerInput` against
|
||||
it without a runtime check. The factory iterates the same tuple to
|
||||
guarantee that adding or removing a key flows through one source of
|
||||
truth. Both exports are tree-shakeable; consumers that only need one pay
|
||||
no cost for the other.
|
||||
91
plugins/document-navigator/docs/gaming/games/ecs/README.md
Normal file
91
plugins/document-navigator/docs/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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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,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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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,166 @@
|
||||
# @suaveplan/game-server - Universal game server framework: typed dependency-injection container, error registry and Zod-validated core types (game-config, session-metadata, player-info, move-validation-result) as foundation for the broader engine/session/state/matchmaking surface migrated from main
|
||||
|
||||
Universal game-server framework. Migrated from `@suaveplan/game-server` on
|
||||
the reference branch `claude/review-game-engine-arch-Kaykr` (main repo
|
||||
`packages/universal/game-server/`). The port lands in
|
||||
dependency-ordered slices; this initial slice (`0.1.0`) ships the
|
||||
foundations that every later slice will depend on:
|
||||
|
||||
- **`./errors`** — `GameServerError` + `GAME_SERVER_CODES` (`defineCodes` registry).
|
||||
- **`./types`** — `GameConfig`, `SessionMetadata`, `PlayerInfo`,
|
||||
`MoveValidationResult`, `GameResult`, `SessionType`, `DeepReadonly`,
|
||||
plus Zod schemas and throwing/non-throwing validators for every boundary.
|
||||
- **`./engine/di-container`** — typed singleton/transient service locator,
|
||||
ported with a typed `GAME_SERVER.SERVICE_NOT_REGISTERED` error and a
|
||||
cache-invalidating re-register semantic (improvement).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [abstract-game-logic](./src/core/abstract-game-logic/abstract-game-logic.md) — `AbstractGameLogic<TState, TMove>` is the abstract base every concrete game type extends. It implements the `IG…
|
||||
- [abstract-game-logic](./src/core/abstract-game-logic/abstract-game-logic.md) — `AbstractGameLogic<TState, TMove>` is the abstract base every concrete game type extends. It implements the `IG…
|
||||
- [game-event-emitter](./src/core/events/game-event-emitter/game-event-emitter.md) — `GameEventEmitter` is the typed event hub every game-server subsystem publishes to and listens on. It exposes the stand…
|
||||
- [game-event-emitter](./src/core/events/game-event-emitter/game-event-emitter.md) — `GameEventEmitter` is the typed event hub every game-server subsystem publishes to and listens on. It exposes the stand…
|
||||
- [game-events](./src/core/events/game-events/game-events.md) — This module owns the typed event-payload map every game-server consumer keys off — players, sessions, game lifecycle, m…
|
||||
- [game-events](./src/core/events/game-events/game-events.md) — This module owns the typed event-payload map every game-server consumer keys off — players, sessions, game lifecycle, m…
|
||||
- [i-game-logic](./src/core/interfaces/i-game-logic/i-game-logic.md) — `IGameLogic` is the strategy-pattern contract every concrete game type plugs into the universal game server through. Th…
|
||||
- [i-game-logic](./src/core/interfaces/i-game-logic/i-game-logic.md) — `IGameLogic` is the strategy-pattern contract every concrete game type plugs into the universal game server through. Th…
|
||||
- [i-player-manager](./src/core/interfaces/i-player-manager/i-player-manager.md) — `IPlayerManager` is the contract every connection-tracking implementation must satisfy so the engine, session manager,…
|
||||
- [i-player-manager](./src/core/interfaces/i-player-manager/i-player-manager.md) — `IPlayerManager` is the contract every connection-tracking implementation must satisfy so the engine, session manager,…
|
||||
- [i-session-manager](./src/core/interfaces/i-session-manager/i-session-manager.md) — `ISessionManager` is the contract every session-tree implementation must satisfy. The session hierarchy is the core org…
|
||||
- [i-session-manager](./src/core/interfaces/i-session-manager/i-session-manager.md) — `ISessionManager` is the contract every session-tree implementation must satisfy. The session hierarchy is the core org…
|
||||
- [i-state-manager](./src/core/interfaces/i-state-manager/i-state-manager.md) — `IStateManager<TState>` is the contract every state-management implementation in the game server stack must satisfy. St…
|
||||
- [i-state-manager](./src/core/interfaces/i-state-manager/i-state-manager.md) — `IStateManager<TState>` is the contract every state-management implementation in the game server stack must satisfy. St…
|
||||
- [di-container](./src/engine/di-container/di-container.md) — `DIContainer` is a small, type-safe service locator that the broader `@suaveplan/game-server` engine uses to thread sha…
|
||||
- [di-container](./src/engine/di-container/di-container.md) — `DIContainer` is a small, type-safe service locator that the broader `@suaveplan/game-server` engine uses to thread sha…
|
||||
- [game-engine](./src/engine/game-engine/game-engine.md) — `GameEngine` is the top-level orchestrator for `@suaveplan/game-server`. It owns a `DIContainer`, a `GameLoop`, and a `…
|
||||
- [game-engine](./src/engine/game-engine/game-engine.md) — `GameEngine` is the top-level orchestrator for `@suaveplan/game-server`. It owns a `DIContainer`, a `GameLoop`, and a `…
|
||||
- [game-loop](./src/engine/game-loop/game-loop.md) — `GameLoop` drives a deterministic, injection-friendly game loop with three operating modes (`tick-based`, `event-driven…
|
||||
- [game-loop](./src/engine/game-loop/game-loop.md) — `GameLoop` drives a deterministic, injection-friendly game loop with three operating modes (`tick-based`, `event-driven…
|
||||
- [player-manager](./src/engine/player-manager/player-manager.md) — `PlayerManager` is the `IPlayerManager` implementation backing the player lifecycle in `@suaveplan/game-server`. It tra…
|
||||
- [player-manager](./src/engine/player-manager/player-manager.md) — `PlayerManager` is the `IPlayerManager` implementation backing the player lifecycle in `@suaveplan/game-server`. It tra…
|
||||
- [plugin-manager](./src/engine/plugin-manager/plugin-manager.md) — `PluginManager` is the typed plugin registry for `@suaveplan/game-server`. It owns the install/uninstall/enable/disable…
|
||||
- [plugin-manager](./src/engine/plugin-manager/plugin-manager.md) — `PluginManager` is the typed plugin registry for `@suaveplan/game-server`. It owns the install/uninstall/enable/disable…
|
||||
- [session-lifecycle](./src/engine/session-lifecycle/session-lifecycle.md) — `SessionLifecycle` is the per-session finite-state machine that models each session's progression through the three hig…
|
||||
- [session-lifecycle](./src/engine/session-lifecycle/session-lifecycle.md) — `SessionLifecycle` is the per-session finite-state machine that models each session's progression through the three hig…
|
||||
- [session-node](./src/engine/session-node/session-node.md) — `SessionGraphNode` is the internal mutable graph node powering the hierarchical session tree at the heart of `@suavepla…
|
||||
- [session-node](./src/engine/session-node/session-node.md) — `SessionGraphNode` is the internal mutable graph node powering the hierarchical session tree at the heart of `@suavepla…
|
||||
- [session-store](./src/engine/session-store/session-store.md) — `SessionStore` is the `ISessionManager` implementation that consumers wire into a `GameEngine`. It composes a `SessionT…
|
||||
- [session-store](./src/engine/session-store/session-store.md) — `SessionStore` is the `ISessionManager` implementation that consumers wire into a `GameEngine`. It composes a `SessionT…
|
||||
- [session-tree](./src/engine/session-tree/session-tree.md) — `SessionTree` is the hierarchical session graph backing `@suaveplan/game-server`. It owns the `Map<id, SessionGraphNode…
|
||||
- [session-tree](./src/engine/session-tree/session-tree.md) — `SessionTree` is the hierarchical session graph backing `@suaveplan/game-server`. It owns the `Map<id, SessionGraphNode…
|
||||
- [anti-cheat-validator](./src/enhanced/anti-cheat/anti-cheat-validator/anti-cheat-validator.md) — `AntiCheatValidator` performs server-authoritative validation for player movement and action streams in real-time multi…
|
||||
- [anti-cheat-validator.internal](./src/enhanced/anti-cheat/anti-cheat-validator/anti-cheat-validator.internal.md) — Internal pure helpers backing `AntiCheatValidator`. Holds default configuration constants (movement, rate limits, punis…
|
||||
- [anti-cheat-validator](./src/enhanced/anti-cheat/anti-cheat-validator/anti-cheat-validator.md) — `AntiCheatValidator` performs server-authoritative validation for player movement and action streams in real-time multi…
|
||||
- [interest-manager](./src/enhanced/interest-management/interest-manager/interest-manager.md) — `InterestManager` implements area-of-interest (AOI) culling for open-world multiplayer games: it tracks zones, entities…
|
||||
- [interest-manager.internal](./src/enhanced/interest-management/interest-manager/interest-manager.internal.md) — Pure geometry helpers backing `InterestManager`: Euclidean `vectorDistance` (z defaults to 0 for 2D interop), `zoneCont…
|
||||
- [interest-manager](./src/enhanced/interest-management/interest-manager/interest-manager.md) — `InterestManager` implements area-of-interest (AOI) culling for open-world multiplayer games: it tracks zones, entities…
|
||||
- [lag-compensator](./src/enhanced/lag-compensation/lag-compensator/lag-compensator.md) — `LagCompensator` performs server-side state rewinding for accurate hit registration in networked multiplayer games. It…
|
||||
- [lag-compensator.internal](./src/enhanced/lag-compensation/lag-compensator/lag-compensator.internal.md) — Pure helpers backing `LagCompensator`: `RTT_SAMPLE_SIZE`, the `initialStats` factory, RTT-sample-window management (`pu…
|
||||
- [lag-compensator](./src/enhanced/lag-compensation/lag-compensator/lag-compensator.md) — `LagCompensator` performs server-side state rewinding for accurate hit registration in networked multiplayer games. It…
|
||||
- [leaderboard-manager](./src/enhanced/leaderboard/leaderboard-manager/leaderboard-manager.md) — `LeaderboardManager` provides multi-leaderboard score tracking for game servers with three independent ranking modes —…
|
||||
- [leaderboard-manager](./src/enhanced/leaderboard/leaderboard-manager/leaderboard-manager.md) — `LeaderboardManager` provides multi-leaderboard score tracking for game servers with three independent ranking modes —…
|
||||
- [game-persistence](./src/enhanced/persistence/game-persistence/game-persistence.md) — `GamePersistence` persists game state to a pluggable key-value backend via the `StorageLike` structural contract from `…
|
||||
- [game-persistence](./src/enhanced/persistence/game-persistence/game-persistence.md) — `GamePersistence` persists game state to a pluggable key-value backend via the `StorageLike` structural contract from `…
|
||||
- [replay-player](./src/enhanced/replay/replay-player/replay-player.md) — `ReplayPlayer` plays back a `ReplayData<TState, TInput>` document produced by `ReplayRecorder`. It exposes the controls…
|
||||
- [replay-player](./src/enhanced/replay/replay-player/replay-player.md) — `ReplayPlayer` plays back a `ReplayData<TState, TInput>` document produced by `ReplayRecorder`. It exposes the controls…
|
||||
- [replay-recorder](./src/enhanced/replay/replay-recorder/replay-recorder.md) — `ReplayRecorder` captures gameplay into a deterministic, replay-ready `ReplayData<TState, TInput>` document. It records…
|
||||
- [replay-recorder](./src/enhanced/replay/replay-recorder/replay-recorder.md) — `ReplayRecorder` captures gameplay into a deterministic, replay-ready `ReplayData<TState, TInput>` document. It records…
|
||||
- [game-instance-registry](./src/enhanced/service-discovery/game-instance-registry/game-instance-registry.md) — `GameInstanceRegistry` is the in-memory primitive that tracks every live game-server instance in a multi-host deploymen…
|
||||
- [game-instance-registry.internal](./src/enhanced/service-discovery/game-instance-registry/game-instance-registry.internal.md) — Pure helpers extracted from `GameInstanceRegistry` so the orchestrator class stays inside the per-file budget. The help…
|
||||
- [game-instance-registry](./src/enhanced/service-discovery/game-instance-registry/game-instance-registry.md) — `GameInstanceRegistry` is the in-memory primitive that tracks every live game-server instance in a multi-host deploymen…
|
||||
- [bitpacker-state-sync](./src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.md) — `BitPackerStateSync` is the byte-packed state-synchronisation primitive for low-bandwidth, high-frequency multiplayer u…
|
||||
- [bitpacker-state-sync](./src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.md) — `BitPackerStateSync` is the byte-packed state-synchronisation primitive for low-bandwidth, high-frequency multiplayer u…
|
||||
- [vector-clock-sync](./src/enhanced/timing/vector-clock-sync/vector-clock-sync.md) — `VectorClockSync` is the distributed event-ordering primitive used by multiplayer game sessions to decide whether two a…
|
||||
- [vector-clock-sync.internal](./src/enhanced/timing/vector-clock-sync/vector-clock-sync.internal.md) — This module factors out the pure helpers that drive `VectorClockSync` — happens-before / concurrent predicates, conflic…
|
||||
- [vector-clock-sync](./src/enhanced/timing/vector-clock-sync/vector-clock-sync.md) — `VectorClockSync` is the distributed event-ordering primitive used by multiplayer game sessions to decide whether two a…
|
||||
- [tournament-manager](./src/enhanced/tournament/tournament-manager/tournament-manager.md) — `TournamentManager` orchestrates single-elimination and round-robin tournaments. Participants register during the `pend…
|
||||
- [tournament-bracket.internal](./src/enhanced/tournament/tournament-manager/tournament-bracket.internal.md) — Pure bracket-generation helpers used by `TournamentManager`. Extracted to a sibling internal module to keep `tournament…
|
||||
- [tournament-manager](./src/enhanced/tournament/tournament-manager/tournament-manager.md) — `TournamentManager` orchestrates single-elimination and round-robin tournaments. Participants register during the `pend…
|
||||
- [tournament-standings.internal](./src/enhanced/tournament/tournament-manager/tournament-standings.internal.md) — Pure helper utilities for the standings table inside a tournament: applying a win/loss outcome, marking participants el…
|
||||
- [errors](./src/errors/game-server-error.md) — `GameServerError` is the single typed-exception class for the `@suaveplan/game-server` package. It extends `SuaveplanEr…
|
||||
- [game-server-error](./src/errors/game-server-error.md) — `GameServerError` is the single typed-exception class for the `@suaveplan/game-server` package. It extends `SuaveplanEr…
|
||||
- [enhanced-server](./src/integrations/enhanced-server/enhanced-server.md) — `EnhancedGameServer` is the top-of-stack integration layer for `@suaveplan/game-server`. Waves B and C-1 shipped the en…
|
||||
- [enhanced-server](./src/integrations/enhanced-server/enhanced-server.md) — `EnhancedGameServer` is the top-of-stack integration layer for `@suaveplan/game-server`. Waves B and C-1 shipped the en…
|
||||
- [matchmaking-engine](./src/matchmaking/matchmaking-engine/matchmaking-engine.md) — `MatchmakingEngine` is the top-level façade for the game-server's matchmaking pipeline. It composes three optional coll…
|
||||
- [matchmaking-engine](./src/matchmaking/matchmaking-engine/matchmaking-engine.md) — `MatchmakingEngine` is the top-level façade for the game-server's matchmaking pipeline. It composes three optional coll…
|
||||
- [queue-manager](./src/matchmaking/queue-manager/queue-manager.md) — `QueueManager` is the in-memory priority queue at the heart of the matchmaking pipeline. Each call to `addToQueue` inse…
|
||||
- [queue-manager](./src/matchmaking/queue-manager/queue-manager.md) — `QueueManager` is the in-memory priority queue at the heart of the matchmaking pipeline. Each call to `addToQueue` inse…
|
||||
- [skill-matcher](./src/matchmaking/skill-matcher/skill-matcher.md) — `SkillMatcher` picks the closest-rated viable opponent for a queue seeker out of a candidate pool, widening its accepta…
|
||||
- [skill-matcher](./src/matchmaking/skill-matcher/skill-matcher.md) — `SkillMatcher` picks the closest-rated viable opponent for a queue seeker out of a candidate pool, widening its accepta…
|
||||
- [spectator-manager](./src/matchmaking/spectator-manager/spectator-manager.md) — `SpectatorManager` owns the live spectator roster for every active game session. Spectators join a session through `add…
|
||||
- [spectator-manager](./src/matchmaking/spectator-manager/spectator-manager.md) — `SpectatorManager` owns the live spectator roster for every active game session. Spectators join a session through `add…
|
||||
- [winner-stays-on](./src/matchmaking/winner-stays-on/winner-stays-on.md) — `WinnerStaysOnManager` models the classic arcade format where the round winner keeps their seat while the next challeng…
|
||||
- [winner-stays-on](./src/matchmaking/winner-stays-on/winner-stays-on.md) — `WinnerStaysOnManager` models the classic arcade format where the round winner keeps their seat while the next challeng…
|
||||
- [network-adapter](./src/network/network-adapter/network-adapter.md) — `NetworkAdapter` is the unified bridge between the `@suaveplan/game-server` framework and any concrete network transpor…
|
||||
- [network-adapter](./src/network/network-adapter/network-adapter.md) — `NetworkAdapter` is the unified bridge between the `@suaveplan/game-server` framework and any concrete network transpor…
|
||||
- [reconnection-manager](./src/network/reconnection-manager/reconnection-manager.md) — `ReconnectionManager` drives transport reconnection with exponential backoff and configurable jitter for the `@suavepla…
|
||||
- [reconnection-manager](./src/network/reconnection-manager/reconnection-manager.md) — `ReconnectionManager` drives transport reconnection with exponential backoff and configurable jitter for the `@suavepla…
|
||||
- [webrtc-transport](./src/network/webrtc-transport/webrtc-transport.md) — `WebRTCTransport` is the game-server-tier `Transport` adapter for WebRTC data channels. It wraps an underlying client c…
|
||||
- [webrtc-transport](./src/network/webrtc-transport/webrtc-transport.md) — `WebRTCTransport` is the game-server-tier `Transport` adapter for WebRTC data channels. It wraps an underlying client c…
|
||||
- [websocket-transport](./src/network/websocket-transport/websocket-transport.md) — `WebSocketTransport` is the game-server-tier `Transport` adapter for WebSocket connections. It wraps an underlying clie…
|
||||
- [websocket-transport](./src/network/websocket-transport/websocket-transport.md) — `WebSocketTransport` is the game-server-tier `Transport` adapter for WebSocket connections. It wraps an underlying clie…
|
||||
- [observability](./src/observability/otel.md) — This module owns the three OpenTelemetry instruments the `@suaveplan/game-server` engine, loop, and plugin manager emit…
|
||||
- [otel](./src/observability/otel.md) — This module owns the three OpenTelemetry instruments the `@suaveplan/game-server` engine, loop, and plugin manager emit…
|
||||
- [network-tier-adapter](./src/performance/network-tier-adapter/network-tier-adapter.md) — `NetworkTierAdapter` bridges the game-server's matchmaking + session layer to the `@suaveplan/network` tier model. Each…
|
||||
- [network-tier-adapter](./src/performance/network-tier-adapter/network-tier-adapter.md) — `NetworkTierAdapter` bridges the game-server's matchmaking + session layer to the `@suaveplan/network` tier model. Each…
|
||||
- [object-pool](./src/performance/object-pool/object-pool.md) — `ObjectPool<T>` is a generic typed object pool that dampens GC pressure in hot allocation paths — game-loop frame alloc…
|
||||
- [object-pool](./src/performance/object-pool/object-pool.md) — `ObjectPool<T>` is a generic typed object pool that dampens GC pressure in hot allocation paths — game-loop frame alloc…
|
||||
- [session-pool](./src/performance/session-pool/session-pool.md) — `SessionPool` is a session-specific wrapper around `ObjectPool<T>` that recycles `PooledSession` records — plain-data s…
|
||||
- [session-pool](./src/performance/session-pool/session-pool.md) — `SessionPool` is a session-specific wrapper around `ObjectPool<T>` that recycles `PooledSession` records — plain-data s…
|
||||
- [ab-test-plugin](./src/plugins/ab-test-plugin/ab-test-plugin.md) — `ABTestPlugin` registers weighted experiments and assigns players to variants deterministically via an injected `Rng`.…
|
||||
- [ab-test-plugin](./src/plugins/ab-test-plugin/ab-test-plugin.md) — `ABTestPlugin` registers weighted experiments and assigns players to variants deterministically via an injected `Rng`.…
|
||||
- [analytics-plugin](./src/plugins/analytics-plugin/analytics-plugin.md) — `AnalyticsPlugin` tracks per-session counters and per-event activity for a `@suaveplan/game-server` engine. It subscrib…
|
||||
- [analytics-plugin](./src/plugins/analytics-plugin/analytics-plugin.md) — `AnalyticsPlugin` tracks per-session counters and per-event activity for a `@suaveplan/game-server` engine. It subscrib…
|
||||
- [base-plugin](./src/plugins/base-plugin/base-plugin.md) — `BasePlugin` is the abstract base class every concrete `@suaveplan/game-server` plugin extends. It implements the `Engi…
|
||||
- [base-plugin](./src/plugins/base-plugin/base-plugin.md) — `BasePlugin` is the abstract base class every concrete `@suaveplan/game-server` plugin extends. It implements the `Engi…
|
||||
- [feature-flag-plugin](./src/plugins/feature-flag-plugin/feature-flag-plugin.md) — `FeatureFlagPlugin` wraps a `@suaveplan/feature-flags` `FlagManager` and exposes its evaluation surface as a lifecycle-…
|
||||
- [feature-flag-plugin](./src/plugins/feature-flag-plugin/feature-flag-plugin.md) — `FeatureFlagPlugin` wraps a `@suaveplan/feature-flags` `FlagManager` and exposes its evaluation surface as a lifecycle-…
|
||||
- [logging-plugin](./src/plugins/logging-plugin/logging-plugin.md) — `LoggingPlugin` subscribes to a `GameEventEmitter` at install time and forwards every observed event to an injected `Lo…
|
||||
- [logging-plugin](./src/plugins/logging-plugin/logging-plugin.md) — `LoggingPlugin` subscribes to a `GameEventEmitter` at install time and forwards every observed event to an injected `Lo…
|
||||
- [persistence-plugin](./src/plugins/persistence-plugin/persistence-plugin.md) — `PersistencePlugin` tracks per-session game state via a `GameEventEmitter` and persists it to an injectable `StorageLik…
|
||||
- [persistence-plugin](./src/plugins/persistence-plugin/persistence-plugin.md) — `PersistencePlugin` tracks per-session game state via a `GameEventEmitter` and persists it to an injectable `StorageLik…
|
||||
- [rules-plugin](./src/plugins/rules-plugin/rules-plugin.md) — `RulesPlugin` integrates `@suaveplan/rules` with the game-server engine. It maintains three rule sets — move validation…
|
||||
- [rules-plugin](./src/plugins/rules-plugin/rules-plugin.md) — `RulesPlugin` integrates `@suaveplan/rules` with the game-server engine. It maintains three rule sets — move validation…
|
||||
- [workflow-plugin](./src/plugins/workflow-plugin/workflow-plugin.md) — `WorkflowPlugin` lets the game-server orchestrate multi-step `@suaveplan/workflow` engines under a stable id. Callers r…
|
||||
- [workflow-plugin](./src/plugins/workflow-plugin/workflow-plugin.md) — `WorkflowPlugin` lets the game-server orchestrate multi-step `@suaveplan/workflow` engines under a stable id. Callers r…
|
||||
- [delta-compressor](./src/state/delta-compressor/delta-compressor.md) — `DeltaCompressor` is the wire-format layer that sits between `StateDiffer` and `StateSync`. It computes a `StateDelta`…
|
||||
- [delta-compressor](./src/state/delta-compressor/delta-compressor.md) — `DeltaCompressor` is the wire-format layer that sits between `StateDiffer` and `StateSync`. It computes a `StateDelta`…
|
||||
- [state-differ](./src/state/state-differ/state-differ.md) — `StateDiffer` is a pure structural diff and patch utility for plain-object game state. It produces a compact, determini…
|
||||
- [state-differ](./src/state/state-differ/state-differ.md) — `StateDiffer` is a pure structural diff and patch utility for plain-object game state. It produces a compact, determini…
|
||||
- [state-manager](./src/state/state-manager/state-manager.md) — `StateManager` is the canonical, observable container for game-session state. It holds a single typed value, exposes re…
|
||||
- [state-manager](./src/state/state-manager/state-manager.md) — `StateManager` is the canonical, observable container for game-session state. It holds a single typed value, exposes re…
|
||||
- [state-sync](./src/state/state-sync/state-sync.md) — `StateSync` is the orchestration layer that turns `StateManager` mutations into wire packets and applies received packe…
|
||||
- [state-sync](./src/state/state-sync/state-sync.md) — `StateSync` is the orchestration layer that turns `StateManager` mutations into wire packets and applies received packe…
|
||||
- [types](./src/types/common.md) — This module defines the runtime Zod schemas and validator helpers for the core boundary types of `@suaveplan/game-serve…
|
||||
- [common](./src/types/common.md) — This module defines the runtime Zod schemas and validator helpers for the core boundary types of `@suaveplan/game-serve…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Status of the upstream migration
|
||||
|
||||
| Surface area | Status |
|
||||
|---|---|
|
||||
| `core/types`, `errors`, `engine/di-container` | Shipped in this slice. |
|
||||
| `core/{AbstractGameLogic, interfaces/*, events/*}` | Deferred (see spec change `enterprise-game-server-core`). |
|
||||
| `engine/{GameEngine, GameLoop, PlayerManager, PluginManager, SessionLifecycle, SessionNode, SessionStore, SessionTree}` | Deferred — each requires kebab-case rename, 5-file layout, typed errors, OTel wiring, Zod boundaries, 100% test coverage. |
|
||||
| `state/{DeltaCompressor, StateDiffer, StateManager, StateSync}` | Deferred. |
|
||||
| `matchmaking/{MatchmakingEngine, QueueManager, SpectatorManager, skill-matcher, winner-stays-on}` | Deferred. |
|
||||
| `network/{NetworkAdapter, ReconnectionManager, WebRTCTransport, WebSocketTransport}` | Blocked — missing dependencies `@suaveplan/network-webrtc`, `@suaveplan/network-websockets`. |
|
||||
| `performance/*` → `telemetry/*` | Deferred (folds into engine slice). |
|
||||
| `plugins/*` | Blocked — missing dependencies `@suaveplan/workflow`, `@suaveplan/keys`. |
|
||||
| `enhanced/*` (73 files) | Priority 2 — separate change `enterprise-game-server-enhanced`. |
|
||||
| `integrations/*` (game-lobby UI + enhanced-server) | Priority 3 — React-coupled, belongs in a `react-game-lobby` package. |
|
||||
|
||||
See the design proposal for the
|
||||
full deferral table and rationale.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/errors/game-server-error.md`](src/errors/game-server-error.md)
|
||||
- [`src/types/common.md`](src/types/common.md)
|
||||
- [`src/engine/di-container/di-container.md`](src/engine/di-container/di-container.md)
|
||||
- [`src/engine/game-engine/game-engine.md`](src/engine/game-engine/game-engine.md)
|
||||
- [`src/engine/game-loop/game-loop.md`](src/engine/game-loop/game-loop.md)
|
||||
- [`src/engine/plugin-manager/plugin-manager.md`](src/engine/plugin-manager/plugin-manager.md)
|
||||
- [`src/observability/otel.md`](src/observability/otel.md)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# `AbstractGameLogic` — Template-Method base class for game rules
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AbstractGameLogic<TState, TMove>` is the abstract base every concrete
|
||||
game type extends. It implements the `IGameLogic` contract,
|
||||
orchestrates the `processMove` template method (validate → before hook →
|
||||
apply → state install → after hook → instrument), owns the player
|
||||
roster, and threads an injected `Clock` + `IdGenerator` so subclass move
|
||||
logic is deterministic by default. Every concrete game (chess, bingo,
|
||||
gem-crush, tic-tac-toe) fills in the five rule-specific abstract methods
|
||||
and optionally overrides the five lifecycle hooks; the base class
|
||||
handles the framework integration, instrumentation, and error
|
||||
discipline.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `IGameLogic` end-to-end — subclasses inherit `processMove`,
|
||||
`getGameState`, `dispose`, and player-roster utilities for free.
|
||||
- Injected `Clock` + `IdGenerator` — never calls `Date.now()` or
|
||||
`crypto.randomUUID()` directly; tests use fakes for determinism.
|
||||
- OTel instrumentation built in — every `processMove` records the
|
||||
`game_server.logic.tick_duration_ms` histogram and the
|
||||
`game_server.logic.moves_total` / `rejections_total` counters under the
|
||||
meter `@suaveplan/game-server` (override with `meterName`).
|
||||
- Typed errors — invalid moves, duplicate players, and missing players
|
||||
throw `GameServerError` with `INVALID_MOVE` / `PLAYER_ALREADY_EXISTS` /
|
||||
`PLAYER_NOT_FOUND` codes (no plain `Error`).
|
||||
- Lifecycle hooks (`beforeMoveProcessing`, `afterMoveProcessing`,
|
||||
`onPlayerAdded`, `onPlayerRemoved`, `onDispose`) — default no-ops;
|
||||
subclasses override only what they need.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbstractGameLogic,
|
||||
type GameResult,
|
||||
type MoveValidationResult,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
interface Counter { count: number; }
|
||||
type CounterMove = { delta: number };
|
||||
|
||||
class CounterGame extends AbstractGameLogic<Counter, CounterMove> {
|
||||
validateMove(_id: string, m: CounterMove): MoveValidationResult {
|
||||
return m.delta >= 0 ? this.valid() : this.invalid("negative");
|
||||
}
|
||||
protected applyMove(_id: string, m: CounterMove, s: Counter): Counter {
|
||||
return { count: s.count + m.delta };
|
||||
}
|
||||
isGameOver(s: Counter): boolean { return s.count >= 10; }
|
||||
getGameResult(s: Counter): GameResult {
|
||||
return { winner: s.count >= 10 ? "alice" : null };
|
||||
}
|
||||
initializeGame(): Counter { return { count: 0 }; }
|
||||
}
|
||||
|
||||
const game = new CounterGame({ initialState: { count: 0 } });
|
||||
game.addPlayer("alice");
|
||||
game.processMove("alice", { delta: 3 }, game.getGameState());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake clock and id generator for deterministic replay tests, and
|
||||
hook into the move-lifecycle to emit custom analytics:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbstractGameLogic,
|
||||
type GameResult,
|
||||
type MoveValidationResult,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
class ReplayableGame extends AbstractGameLogic<{ count: number }, { delta: number }> {
|
||||
private readonly moves: Array<{ id: string; delta: number; at: number }> = [];
|
||||
|
||||
validateMove(_id: string, m: { delta: number }): MoveValidationResult {
|
||||
return m.delta > 0 ? this.valid() : this.invalid("non-positive");
|
||||
}
|
||||
protected applyMove(_id: string, m: { delta: number }, s: { count: number }) {
|
||||
return { count: s.count + m.delta };
|
||||
}
|
||||
protected afterMoveProcessing(playerId: string, move: { delta: number }) {
|
||||
this.moves.push({
|
||||
id: this.nextId(),
|
||||
delta: move.delta,
|
||||
at: this.clock(),
|
||||
});
|
||||
}
|
||||
isGameOver(s: { count: number }) { return s.count >= 100; }
|
||||
getGameResult(s: { count: number }): GameResult {
|
||||
return { winner: s.count >= 100 ? "alice" : null };
|
||||
}
|
||||
initializeGame() { return { count: 0 }; }
|
||||
|
||||
getMoves() { return [...this.moves]; }
|
||||
}
|
||||
|
||||
let t = 0;
|
||||
const game = new ReplayableGame({
|
||||
initialState: { count: 0 },
|
||||
clock: () => ++t,
|
||||
idGenerator: () => `m-${t}`,
|
||||
});
|
||||
game.addPlayer("alice");
|
||||
game.processMove("alice", { delta: 5 }, game.getGameState());
|
||||
console.log(game.getMoves()); // [{ id: "m-1", delta: 5, at: 2 }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new AbstractGameLogic({ initialState, clock?, idGenerator?, meterName? })`
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `initialState` | required | Starting `TState`. |
|
||||
| `clock` | `() => Date.now()` | Injectable. |
|
||||
| `idGenerator` | `() => crypto.randomUUID()` | Injectable. |
|
||||
| `meterName` | `"@suaveplan/game-server"` | OTel meter. |
|
||||
|
||||
### Public methods
|
||||
|
||||
- `processMove(playerId, move, state): TState` — template method.
|
||||
- `getGameState(): TState`
|
||||
- `addPlayer(playerId, data?): void`
|
||||
- `removePlayer(playerId): void`
|
||||
- `getPlayer(playerId): unknown`
|
||||
- `hasPlayer(playerId): boolean`
|
||||
- `getPlayerIds(): ReadonlyArray<string>`
|
||||
- `getPlayerCount(): number`
|
||||
- `dispose(): void`
|
||||
|
||||
### Abstract methods (subclass MUST implement)
|
||||
|
||||
- `validateMove(playerId, move, state): MoveValidationResult`
|
||||
- `applyMove(playerId, move, state): TState` (protected)
|
||||
- `isGameOver(state): boolean`
|
||||
- `getGameResult(state): GameResult`
|
||||
- `initializeGame(config): TState`
|
||||
|
||||
### Hooks (subclass MAY override)
|
||||
|
||||
- `beforeMoveProcessing(playerId, move)`
|
||||
- `afterMoveProcessing(playerId, move, newState)`
|
||||
- `onPlayerAdded(playerId, data?)`
|
||||
- `onPlayerRemoved(playerId, data?)`
|
||||
- `onDispose()`
|
||||
|
||||
### Errors thrown
|
||||
|
||||
| Code | Cause |
|
||||
| --- | --- |
|
||||
| `GAME_SERVER.INVALID_MOVE` | `validateMove` returned `{ valid: false }`. |
|
||||
| `GAME_SERVER.PLAYER_ALREADY_EXISTS` | `addPlayer` on a known id. |
|
||||
| `GAME_SERVER.PLAYER_NOT_FOUND` | `removePlayer` on an unknown id. |
|
||||
|
||||
### OTel instruments
|
||||
|
||||
| Name | Kind | Attributes |
|
||||
| --- | --- | --- |
|
||||
| `game_server.logic.tick_duration_ms` | Histogram | none |
|
||||
| `game_server.logic.moves_total` | Counter | none |
|
||||
| `game_server.logic.rejections_total` | Counter | `reason` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`processMove` clamps the elapsed time with `Math.max(0, ...)` so a
|
||||
non-monotonic clock (fake or wall clock during NTP step-back) never
|
||||
records a negative histogram value. Instruments are cached by `meterName`
|
||||
— passing different names rebinds them, passing the same name reuses the
|
||||
existing meter so multiple `AbstractGameLogic` instances share one set
|
||||
of counters. `cloneState` uses JSON round-trip by default — subclasses
|
||||
holding non-JSON-safe state (Maps, Sets, Dates, class instances) should
|
||||
override it.
|
||||
@@ -0,0 +1,132 @@
|
||||
# `GameEventEmitter` — typed event emitter for the game framework
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameEventEmitter` is the typed event hub every game-server subsystem
|
||||
publishes to and listens on. It exposes the standard `on`/`once`/`off`/
|
||||
`emit` surface, plus four ergonomic extensions (`safeEmit`, `waitFor`,
|
||||
`onMultiple`, `onN`) that solve the common patterns observed in upstream
|
||||
game-server code without forcing callers to hand-roll wrappers. Listener
|
||||
payloads are a single, named, type-safe object per event key
|
||||
(`GameEvents[K]`), not the variadic tuples upstream used — this fits
|
||||
`@suaveplan/events`-style single-payload listeners and keeps the surface
|
||||
predictable when wiring into reactive bindings.
|
||||
|
||||
## Features
|
||||
|
||||
- Strongly-typed `on`/`once`/`emit` — payload type is inferred from the
|
||||
event key, no casts at call sites.
|
||||
- `safeEmit(event, payload)` — listener exceptions are caught and re-
|
||||
emitted on the `error` channel; the original emission keeps running for
|
||||
the remaining listeners.
|
||||
- `waitFor(event, timeoutMs?)` — returns a `Promise` that resolves with
|
||||
the next payload or rejects with `GameServerError(EVENT_WAIT_TIMEOUT)`
|
||||
after the timeout.
|
||||
- `onN(event, count, listener)` — auto-unsubscribes after `count` fires.
|
||||
- `onMultiple(events, listener)` — single listener observes multiple keys
|
||||
and receives the event name as its first arg.
|
||||
- Configurable per-key listener cap (default 100) — throws
|
||||
`GameServerError(LISTENER_LIMIT_EXCEEDED)` on overflow.
|
||||
- Mid-emit safety: listeners can call `off(...)` on themselves without
|
||||
shifting the iteration index of in-flight listeners.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
|
||||
const events = new GameEventEmitter();
|
||||
const unsub = events.on("game:started", ({ sessionId }) => {
|
||||
console.log("started", sessionId);
|
||||
});
|
||||
events.emit("game:started", { sessionId: "s1" });
|
||||
unsub();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wait for a `game:ended` event with a 30s ceiling, and use `safeEmit` so a
|
||||
faulty analytics listener can't bring down the rest of the pipeline:
|
||||
|
||||
```ts
|
||||
import {
|
||||
GameEventEmitter,
|
||||
GAME_SERVER_CODES,
|
||||
GameServerError,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
const events = new GameEventEmitter({ maxListenersPerKey: 250 });
|
||||
|
||||
events.on("error", ({ error }) => console.error("listener crashed", error));
|
||||
|
||||
events.on("game:ended", ({ sessionId, result }) => {
|
||||
if (Math.random() < 0.001) throw new Error("analytics flaked");
|
||||
console.log(`${sessionId} winner: ${result.winner}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const { sessionId } = await events.waitFor("game:ended", 30_000);
|
||||
console.log("game ended:", sessionId);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.EVENT_WAIT_TIMEOUT
|
||||
) {
|
||||
console.warn("no game ended in 30s — timing out");
|
||||
}
|
||||
}
|
||||
|
||||
// Elsewhere in the engine — safeEmit so analytics crash doesn't propagate.
|
||||
events.safeEmit("game:ended", {
|
||||
sessionId: "s1",
|
||||
result: { winner: "alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new GameEventEmitter(options?: GameEventEmitterOptions)`
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `maxListenersPerKey` | `100` | Set to `Infinity` to disable. |
|
||||
|
||||
### Methods
|
||||
|
||||
- `on(event, listener): Unsubscribe`
|
||||
- `once(event, listener): Unsubscribe`
|
||||
- `off(event, listener): void`
|
||||
- `emit(event, payload): boolean` — `true` iff at least one listener was invoked.
|
||||
- `safeEmit(event, payload): boolean` — listener errors emit on `"error"`.
|
||||
- `onMultiple(events, listener): Unsubscribe` — listener `(event, payload)`.
|
||||
- `waitFor(event, timeoutMs?): Promise<GameEvents[K]>`
|
||||
- `onN(event, count, listener): Unsubscribe` — `count > 0` required.
|
||||
- `listenerCount(event): number`
|
||||
- `removeAllListeners(): void`
|
||||
|
||||
### Errors thrown
|
||||
|
||||
| Code | Cause |
|
||||
| --- | --- |
|
||||
| `GAME_SERVER.LISTENER_LIMIT_EXCEEDED` | More than `maxListenersPerKey` listeners on one key. |
|
||||
| `GAME_SERVER.INVALID_LISTENER_COUNT` | `onN(count <= 0)`. |
|
||||
| `GAME_SERVER.EVENT_WAIT_TIMEOUT` | `waitFor(..., timeoutMs)` elapsed. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`emit` and `safeEmit` iterate a shallow snapshot (`[...list]`) so a
|
||||
listener that calls `off` for itself mid-fire does not cause the loop to
|
||||
skip the next listener. `safeEmit` deliberately does not recurse through
|
||||
`safeEmit("error", ...)` — it emits the `error` event directly and
|
||||
swallows secondary failures, because re-routing a failed error handler
|
||||
into the same `safeEmit` pipeline would mask the original cause.
|
||||
`waitFor(timeoutMs)` only schedules a timer when `timeoutMs` is a positive
|
||||
number — passing `0` is treated as "no timeout" so callers don't get a
|
||||
race condition between the immediate timeout-reject and the
|
||||
resolution path.
|
||||
@@ -0,0 +1,105 @@
|
||||
# `GameEvents` map and `GAME_EVENT_KEYS` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the typed event-payload map every game-server consumer
|
||||
keys off — players, sessions, game lifecycle, moves, and errors — plus
|
||||
the closed array `GAME_EVENT_KEYS` and predicate `isGameEventKey` that
|
||||
let downstream code enumerate or validate event names without hard-coding
|
||||
strings everywhere. suaveplan collapses upstream's variadic `[a, b]` tuples
|
||||
to single object payloads so the map satisfies `@suaveplan/events`'
|
||||
`EventMap = Record<string, unknown>` contract and listeners receive a
|
||||
single, named, shape-safe argument.
|
||||
|
||||
## Features
|
||||
|
||||
- Compile-time typed map (`GameEvents` interface in
|
||||
`../../types/core.types.ts`) — every key has a single payload shape.
|
||||
- Runtime registry (`GAME_EVENT_KEYS`) — readonly array of all 25
|
||||
well-known event names, in declaration order.
|
||||
- Predicate (`isGameEventKey(value)`) — narrows a `string` to a
|
||||
`keyof GameEvents` for safe wire-input validation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
GAME_EVENT_KEYS,
|
||||
isGameEventKey,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
// Enumerate every event for analytics setup
|
||||
for (const key of GAME_EVENT_KEYS) {
|
||||
console.log("registering listener for", key);
|
||||
}
|
||||
|
||||
// Validate untrusted input
|
||||
function onWireMessage(raw: { event: string }): void {
|
||||
if (!isGameEventKey(raw.event)) {
|
||||
throw new Error(`unknown event: ${raw.event}`);
|
||||
}
|
||||
// raw.event is now `keyof GameEvents`
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the registry to mass-wire per-event counters at server startup so
|
||||
every emitted event is recorded without copy-pasting `metricsCounter`
|
||||
registrations:
|
||||
|
||||
```ts
|
||||
import {
|
||||
GAME_EVENT_KEYS,
|
||||
GameEventEmitter,
|
||||
} from "@suaveplan/game-server";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
export function instrumentEvents(emitter: GameEventEmitter): void {
|
||||
const meter = metrics.getMeter("@suaveplan/game-server");
|
||||
for (const key of GAME_EVENT_KEYS) {
|
||||
const counter = meter.createCounter(
|
||||
`game_server.events.${key.replace(/:/g, "_")}.total`,
|
||||
{ description: `Total ${key} events` }
|
||||
);
|
||||
emitter.on(key, () => counter.add(1));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `GAME_EVENT_KEYS: ReadonlyArray<keyof GameEvents>`
|
||||
|
||||
Frozen array literal. Declaration order matches the source-of-truth in
|
||||
`core.types.ts` so callers can build deterministic indexes off the array
|
||||
position.
|
||||
|
||||
### `isGameEventKey(value: string): value is keyof GameEvents`
|
||||
|
||||
Returns `true` iff `value` is a member of `GAME_EVENT_KEYS`. Narrows the
|
||||
input on the truthy branch.
|
||||
|
||||
### `GameEvents` (re-exported from `@suaveplan/game-server`)
|
||||
|
||||
| Group | Keys |
|
||||
| --- | --- |
|
||||
| Player | `player:connected`, `player:disconnected`, `player:reconnected`, `player:timeout`, `player:heartbeat`, `player:joined`, `player:left` |
|
||||
| Session | `session:created`, `session:destroyed`, `session:updated`, `session:metadata:changed` |
|
||||
| Game | `game:initialized`, `game:started`, `game:ended`, `game:paused`, `game:resumed`, `game:state:changed` |
|
||||
| Move | `move:requested`, `move:validated`, `move:processed`, `move:rejected` |
|
||||
| Error | `error`, `error:game`, `error:network`, `error:state` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The keys are deliberately declared in a single `const` array rather than
|
||||
derived via `Object.keys(...)` so the runtime registry stays exhaustive
|
||||
even if a future type-level edit adds a key without touching the array.
|
||||
The `conformance` gate covers the parity between
|
||||
`GAME_EVENT_KEYS` and the `GameEvents` interface — a key added to one
|
||||
must be added to the other, and missing entries surface as broken
|
||||
conformance tests rather than runtime silent failures.
|
||||
@@ -0,0 +1,122 @@
|
||||
# `IGameLogic` — strategy contract for pluggable game rules
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IGameLogic` is the strategy-pattern contract every concrete game type plugs
|
||||
into the universal game server through. The interface defines the seven
|
||||
methods (`validateMove`, `processMove`, `getGameState`, `isGameOver`,
|
||||
`getGameResult`, `initializeGame`, `dispose`) that the engine, session
|
||||
manager, and event pipeline all call against; concrete games — chess,
|
||||
bingo, tic-tac-toe, gem-crush — implement these methods to expose their
|
||||
rules to the framework. The companion `runIGameLogicConformance` harness
|
||||
gives migrators and contract testers a runtime probe that exercises every
|
||||
method against a caller-supplied factory and returns a structured pass/fail
|
||||
result list — interfaces alone are types-only and contribute no runtime
|
||||
coverage, so the harness is the bridge that makes the contract testable.
|
||||
|
||||
## Features
|
||||
|
||||
- Fully generic on `TState` and `TMove` — no `any` escape hatches; callers
|
||||
thread their own state and move types end-to-end.
|
||||
- Synchronous surface — every method returns directly so move processing
|
||||
can be driven by tight loops (matchmaking ticks, replay scrubbing).
|
||||
- Side-effect-free contract — `validateMove` MUST NOT mutate state; only
|
||||
`processMove` and `initializeGame` install state.
|
||||
- Conformance harness `runIGameLogicConformance(factory, fixtures)` runs
|
||||
the eight scenarios listed below and reports each as a
|
||||
`ConformanceCheckResult`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IGameLogic } from "@suaveplan/game-server";
|
||||
|
||||
class Counter implements IGameLogic<{ count: number }, { delta: number }> {
|
||||
private state = { count: 0 };
|
||||
initializeGame(): { count: number } {
|
||||
this.state = { count: 0 };
|
||||
return this.state;
|
||||
}
|
||||
getGameState() { return this.state; }
|
||||
validateMove(_id: string, move: { delta: number }) {
|
||||
return move.delta >= 0
|
||||
? { valid: true as const }
|
||||
: { valid: false as const, reason: "negative" };
|
||||
}
|
||||
processMove(id: string, move: { delta: number }, state: { count: number }) {
|
||||
const v = this.validateMove(id, move, state);
|
||||
if (!v.valid) throw new Error(v.reason);
|
||||
this.state = { count: state.count + move.delta };
|
||||
return this.state;
|
||||
}
|
||||
isGameOver(s: { count: number }) { return s.count >= 10; }
|
||||
getGameResult(s: { count: number }) {
|
||||
return { winner: s.count >= 10 ? "p1" : null };
|
||||
}
|
||||
dispose(): void { /* release resources */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Verify a third-party game implementation against the contract before
|
||||
plugging it into the engine:
|
||||
|
||||
```ts
|
||||
import { runIGameLogicConformance } from "@suaveplan/game-server";
|
||||
|
||||
const results = runIGameLogicConformance(() => new Counter(), {
|
||||
config: { start: 0 },
|
||||
playerId: "p1",
|
||||
validMove: { delta: 1 },
|
||||
invalidMove: { delta: -1 },
|
||||
});
|
||||
|
||||
const failures = results.filter((r) => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Implementation does not satisfy IGameLogic: ${failures
|
||||
.map((r) => `${r.scenario} (${r.detail ?? "no detail"})`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IGameLogic<TState, TMove>`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `validateMove(playerId, move, state)` | `MoveValidationResult` | Side-effect-free. |
|
||||
| `processMove(playerId, move, state)` | `TState` | MAY throw if validation fails. |
|
||||
| `getGameState()` | `TState` | Returns the implementation's installed state. |
|
||||
| `isGameOver(state)` | `boolean` | Pure predicate over `state`. |
|
||||
| `getGameResult(state)` | `GameResult` | Called once `isGameOver` returns true. |
|
||||
| `initializeGame(config)` | `TState` | Resets state from `Record<string, unknown>`. |
|
||||
| `dispose()` | `void` | Idempotent cleanup. |
|
||||
|
||||
### `runIGameLogicConformance(factory, fixtures)`
|
||||
|
||||
- `factory: IGameLogicFactory<TState, TMove>` — zero-arg factory producing
|
||||
a fresh impl. Called once per harness run.
|
||||
- `fixtures: IGameLogicFixtures<TState, TMove>` — `config`, `playerId`,
|
||||
`validMove`, `invalidMove`.
|
||||
- Returns `ConformanceCheckResult[]` — 8 entries, one per scenario.
|
||||
- Does not throw. Failures are captured as `{ passed: false, detail }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness deliberately runs scenarios in dependency order:
|
||||
`initializeGame` → `getGameState` → `validateMove` truthy → `validateMove`
|
||||
falsy → `processMove` → `isGameOver` → `getGameResult` → `dispose`. Each
|
||||
step uses the state returned by the previous one so the harness mirrors a
|
||||
real game tick. Because `processMove` is the only mutating call, the
|
||||
harness does not assert internal-state invariants beyond what `getGameState`
|
||||
returns — implementations that internally cache state with non-trivial
|
||||
semantics (CRDTs, event-sourced) should add their own integration tests
|
||||
in addition to running the conformance harness.
|
||||
@@ -0,0 +1,97 @@
|
||||
# `IPlayerManager` — player connection and lifecycle contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IPlayerManager` is the contract every connection-tracking implementation
|
||||
must satisfy so the engine, session manager, matchmaker, and analytics
|
||||
layer can address players uniformly. It owns the eleven lifecycle methods
|
||||
(`connect`, `disconnect`, `getConnection`, `getSessionPlayers`,
|
||||
`getAllConnections`, `heartbeat`, `checkTimeouts`, `updateMetadata`,
|
||||
`getPlayer`, `startHeartbeatMonitor`, `stopHeartbeatMonitor`) and the
|
||||
companion conformance harness `runIPlayerManagerConformance` that
|
||||
exercises each one end-to-end. Implementations are free to back the
|
||||
records with in-memory maps, Redis, SurrealDB, or any transport-specific
|
||||
store as long as the contract holds.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel-agnostic — `metadata` is `Record<string, unknown>` so transports
|
||||
(WebSocket, WebRTC, HTTP long-poll) can attach their own session shape.
|
||||
- Heartbeat-aware — `heartbeat`, `checkTimeouts`, `startHeartbeatMonitor`,
|
||||
`stopHeartbeatMonitor` give the lifecycle owner full timing control.
|
||||
- Session-aware — `getSessionPlayers(sessionId)` and the `sessionId` field
|
||||
let session managers do cheap per-session lookups.
|
||||
- Readonly returns — `getSessionPlayers` and `getAllConnections` return
|
||||
`ReadonlyArray<PlayerConnection>` so callers cannot accidentally mutate
|
||||
internal storage.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IPlayerManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: IPlayerManager;
|
||||
|
||||
const conn = mgr.connect("alice", "table:42", { region: "eu" });
|
||||
mgr.heartbeat("alice");
|
||||
mgr.updateMetadata("alice", { region: "us" });
|
||||
mgr.disconnect("alice", "client closed");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness as part of an integration test for a new
|
||||
SurrealDB-backed player manager:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runIPlayerManagerConformance } from "@suaveplan/game-server";
|
||||
import { SurrealPlayerManager } from "./surreal-player-manager.js";
|
||||
|
||||
describe("SurrealPlayerManager", () => {
|
||||
it("passes the IPlayerManager contract", { timeout: 5000 }, () => {
|
||||
const results = runIPlayerManagerConformance(
|
||||
() => new SurrealPlayerManager({ url: process.env.SURREAL_URL! })
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IPlayerManager`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `connect(playerId, sessionId, metadata?)` | `PlayerConnection` | Replaces any existing record for `playerId`. |
|
||||
| `disconnect(playerId, reason)` | `void` | Implementations MAY drop or mark records — the harness accepts either. |
|
||||
| `getConnection(playerId)` | `PlayerConnection \| null` | |
|
||||
| `getSessionPlayers(sessionId)` | `ReadonlyArray<PlayerConnection>` | |
|
||||
| `getAllConnections()` | `ReadonlyArray<PlayerConnection>` | |
|
||||
| `heartbeat(playerId)` | `void` | Updates `lastHeartbeat`. |
|
||||
| `checkTimeouts()` | `void` | MAY transition stale records to `timeout`. |
|
||||
| `updateMetadata(playerId, metadata)` | `void` | Merges into the record's `metadata`. |
|
||||
| `getPlayer(playerId)` | `PlayerInfo \| null` | |
|
||||
| `startHeartbeatMonitor(intervalMs)` | `void` | |
|
||||
| `stopHeartbeatMonitor()` | `void` | |
|
||||
|
||||
### `runIPlayerManagerConformance(factory)`
|
||||
|
||||
- `factory: IPlayerManagerFactory` — produces a fresh `IPlayerManager`.
|
||||
- Returns `IPlayerManagerCheckResult[]` (11 entries) — each carries the
|
||||
scenario name, a pass/fail flag, and optional detail.
|
||||
- Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness intentionally calls `startHeartbeatMonitor` followed
|
||||
immediately by `stopHeartbeatMonitor` and `checkTimeouts` so
|
||||
implementations that schedule background work via `setInterval` can be
|
||||
verified without leaking handles. The harness uses synthetic ids (`p1`,
|
||||
`s1`) — implementations that require validated id formats should expose
|
||||
their parsing as a pre-step or accept the harness fixture ids.
|
||||
@@ -0,0 +1,102 @@
|
||||
# `ISessionManager` — hierarchical session contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ISessionManager` is the contract every session-tree implementation must
|
||||
satisfy. The session hierarchy is the core organising structure of the
|
||||
game server — a `venue` contains `table`s, a `table` contains `queue`s, a
|
||||
`queue` contains `game`s, a `game` contains `player`s and `spectator`s —
|
||||
and the engine, matchmaker, and persistence layer all walk this tree
|
||||
through the methods defined here. The companion conformance harness
|
||||
`runISessionManagerConformance` exercises every method against a caller-
|
||||
supplied factory and reports per-scenario results.
|
||||
|
||||
## Features
|
||||
|
||||
- Arbitrary nesting depth — children reference parents, parents reference
|
||||
children, both directions are queryable in O(1) for a Map-backed impl.
|
||||
- Async persistence — `persist()` / `restore()` return promises so
|
||||
implementations can hit Redis, SurrealDB, or local storage without
|
||||
bending the contract.
|
||||
- Read-only result arrays — `getSessionsByType`, `getChildren`, and
|
||||
`getAllSessions` return `ReadonlyArray<SessionNode>` so callers cannot
|
||||
mutate internal structures.
|
||||
- Cascading destroy — `destroySession(id)` MUST recursively destroy every
|
||||
descendant. The conformance harness specifically checks this.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ISessionManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: ISessionManager;
|
||||
|
||||
const venue = mgr.createSession(null, {
|
||||
id: "v1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: Date.now(), updatedAt: Date.now() },
|
||||
});
|
||||
const table = mgr.createSession("v1", {
|
||||
id: "t1",
|
||||
type: "table",
|
||||
metadata: { createdAt: Date.now(), updatedAt: Date.now() },
|
||||
});
|
||||
mgr.destroySession("v1"); // removes table too
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness inside a vitest suite to verify a SurrealDB-
|
||||
backed session manager honours the cascade contract:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runISessionManagerConformance } from "@suaveplan/game-server";
|
||||
import { SurrealSessionManager } from "./surreal-session-manager.js";
|
||||
|
||||
describe("SurrealSessionManager", () => {
|
||||
it("passes the ISessionManager contract", { timeout: 30000 }, async () => {
|
||||
const results = await runISessionManagerConformance(
|
||||
() => new SurrealSessionManager({ url: process.env.SURREAL_URL! })
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ISessionManager`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `createSession(parentId, data)` | `SessionNode` | `parentId === null` for top-level. |
|
||||
| `getSession(id)` | `SessionNode \| null` | |
|
||||
| `destroySession(id)` | `void` | Cascades to descendants. |
|
||||
| `getSessionsByType(type)` | `ReadonlyArray<SessionNode>` | |
|
||||
| `getChildren(id)` | `ReadonlyArray<SessionNode>` | Immediate children only. |
|
||||
| `getParent(id)` | `SessionNode \| null` | |
|
||||
| `updateMetadata(id, partial)` | `void` | Shallow merge into metadata. |
|
||||
| `getAllSessions()` | `ReadonlyArray<SessionNode>` | Whole tree, flat. |
|
||||
| `persist()` | `Promise<void>` | |
|
||||
| `restore()` | `Promise<void>` | |
|
||||
|
||||
### `runISessionManagerConformance(factory)`
|
||||
|
||||
- `factory: ISessionManagerFactory` — produces a fresh `ISessionManager`.
|
||||
- Returns `Promise<ISessionManagerCheckResult[]>` (11 entries).
|
||||
- Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness creates one `venue` and one `table` to verify both top-level
|
||||
and nested creation paths. The cascade scenario destroys the root `venue1`
|
||||
and then checks that the previously-created child `table1` is also gone —
|
||||
implementations that leak children when their parent is destroyed will
|
||||
fail this scenario. `persist` and `restore` are called against an in-
|
||||
flight tree (i.e. without prior data) so implementations that require
|
||||
warm caches need to seed their own tests in addition to the harness.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `IStateManager` — immutable state contract with observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IStateManager<TState>` is the contract every state-management
|
||||
implementation in the game server stack must satisfy. State managers are
|
||||
the canonical home for per-session game data — board position, score,
|
||||
turn order, replay buffer — and the engine, persistence layer, and
|
||||
sync engine all observe state transitions through `subscribe`. suaveplan
|
||||
returns `Readonly<TState>` instead of `DeepReadonly<TState>` from
|
||||
`getState` because the deep mapped type interacts poorly with Zod-derived
|
||||
shapes and method-bearing objects; callers needing recursive readonly
|
||||
guarantees can layer their own typing on top.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic on `TState extends object` — no escape hatches; every method is
|
||||
fully typed.
|
||||
- Observability built-in — `subscribe(listener)` receives
|
||||
`StateChangeEvent<TState>` with previous + current + timestamp.
|
||||
- Replayable history — `getHistory()` returns the recorded transitions;
|
||||
`clearHistory()` empties them.
|
||||
- Granular updates — `update(key, value)`, `updateMany(partial)`, or
|
||||
`setState(replacement)` cover single-key, multi-key, and total
|
||||
replacement.
|
||||
- `reset()` reverts to the initial state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IStateManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: IStateManager<{ score: number; turn: string }>;
|
||||
|
||||
const unsub = mgr.subscribe((e) => {
|
||||
console.log("transition", e.previousState, "->", e.currentState);
|
||||
});
|
||||
mgr.update("score", 10);
|
||||
mgr.updateMany({ score: 12, turn: "p2" });
|
||||
mgr.setState({ score: 0, turn: "p1" });
|
||||
unsub();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness against a third-party Immer-backed state
|
||||
manager and surface any contract violations:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runIStateManagerConformance } from "@suaveplan/game-server";
|
||||
import { ImmerStateManager } from "./immer-state-manager.js";
|
||||
|
||||
describe("ImmerStateManager", () => {
|
||||
it("passes the IStateManager contract", { timeout: 5000 }, () => {
|
||||
const results = runIStateManagerConformance(
|
||||
() => new ImmerStateManager({ score: 0, turn: "p1" }),
|
||||
{
|
||||
updateKey: "score",
|
||||
updateValue: 5,
|
||||
partialUpdate: { turn: "p2" },
|
||||
replacement: { score: 99, turn: "p1" },
|
||||
}
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IStateManager<TState extends object>`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `getState()` | `Readonly<TState>` | |
|
||||
| `update<K>(key, value)` | `void` | Emits `StateChangeEvent`. |
|
||||
| `updateMany(partial)` | `void` | Single batched event. |
|
||||
| `setState(next)` | `void` | Full replacement. |
|
||||
| `subscribe(listener)` | `() => void` | Returns unsubscribe. |
|
||||
| `reset()` | `void` | Reverts to constructor state. |
|
||||
| `getHistory()` | `ReadonlyArray<StateChangeEvent<TState>>` | |
|
||||
| `clearHistory()` | `void` | |
|
||||
|
||||
### `runIStateManagerConformance(factory, fixtures)`
|
||||
|
||||
- `factory: IStateManagerFactory<TState>` — fresh manager per run.
|
||||
- `fixtures: IStateManagerFixtures<TState>` — drives the harness.
|
||||
- Returns `IStateManagerCheckResult[]` (9 entries).
|
||||
- Does not throw; reports failures in-band.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness counts observed events per scenario rather than asserting on
|
||||
specific payload shapes because `update` / `updateMany` / `setState` may
|
||||
internally coalesce or batch — what matters is that at least one event is
|
||||
delivered per call. The unsubscribe scenario asserts strict equality
|
||||
(`observed === 3`) on the post-unsubscribe call to catch implementations
|
||||
that return a no-op cleanup function. Reset is the last call so it does
|
||||
not interfere with history-buffer scenarios.
|
||||
@@ -0,0 +1,131 @@
|
||||
# DIContainer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DIContainer` is a small, type-safe service locator that the broader
|
||||
`@suaveplan/game-server` engine uses to thread shared dependencies — a
|
||||
`Clock`, a `LoggerLike`, an `EventBus`, networking adapters, plugin
|
||||
registries — through component boundaries without forcing every call
|
||||
site to take a deep parameter list. It is intentionally minimal: register
|
||||
a factory under a string or symbol identifier, resolve to either a cached
|
||||
singleton or a fresh transient instance, and inspect what is registered
|
||||
for testing.
|
||||
|
||||
This file is the first feature migrated from the reference branch
|
||||
`claude/review-game-engine-arch-Kaykr`
|
||||
(`src/engine/DIContainer.ts`). The port replaces the upstream's
|
||||
plain `throw new Error(...)` on unresolved identifiers with a typed
|
||||
`GameServerError` carrying the stable code
|
||||
`GAME_SERVER.SERVICE_NOT_REGISTERED`, so consumers can branch on
|
||||
`error.code` rather than scraping message strings.
|
||||
|
||||
## Features
|
||||
|
||||
- **Singleton lifecycle** — factory invoked once; the instance is cached.
|
||||
- **Transient lifecycle** — factory invoked on every `resolve`.
|
||||
- **Typed identifiers** — generic parameter on `ServiceIdentifier<T>` carries through `register` and `resolve`.
|
||||
- **Fluent API** — `register` returns `this` for chaining.
|
||||
- **Re-register invalidates cache** — calling `register` again clears any cached singleton instance (improvement over upstream).
|
||||
- **Typed error on miss** — `GameServerError(GAME_SERVER.SERVICE_NOT_REGISTERED)`.
|
||||
- **Introspection** — `has`, `getRegisteredServices`, and `clear` round out the surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { DIContainer } from "@suaveplan/game-server/engine/di-container";
|
||||
|
||||
const container = new DIContainer();
|
||||
|
||||
container.register("logger", () => new ConsoleLogger(), "singleton");
|
||||
container.register("requestId", () => crypto.randomUUID(), "transient");
|
||||
|
||||
const logger1 = container.resolve<ConsoleLogger>("logger");
|
||||
const logger2 = container.resolve<ConsoleLogger>("logger");
|
||||
console.log(logger1 === logger2); // true — singleton
|
||||
|
||||
const r1 = container.resolve<string>("requestId");
|
||||
const r2 = container.resolve<string>("requestId");
|
||||
console.log(r1 === r2); // false — transient
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { DIContainer } from "@suaveplan/game-server/engine/di-container";
|
||||
import { GAME_SERVER_CODES, GameServerError } from "@suaveplan/game-server/errors";
|
||||
|
||||
interface IClock { now(): number }
|
||||
const CLOCK = Symbol("Clock") as ServiceIdentifier<IClock>;
|
||||
|
||||
const container = new DIContainer()
|
||||
.register<IClock>(CLOCK, () => ({ now: () => Date.now() }))
|
||||
.register("events", () => new EventBus());
|
||||
|
||||
try {
|
||||
container.resolve("nope");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.SERVICE_NOT_REGISTERED
|
||||
) {
|
||||
// surface a typed missing-dep diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
// Re-registering invalidates the cached singleton:
|
||||
container.register<IClock>(CLOCK, () => new TestClock());
|
||||
const freshClock = container.resolve<IClock>(CLOCK); // new TestClock instance
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DIContainer`
|
||||
|
||||
#### `register<T>(identifier, factory, lifecycle = "singleton"): this`
|
||||
|
||||
Register a service. Re-registering an identifier replaces the factory
|
||||
and discards any cached singleton instance.
|
||||
|
||||
#### `resolve<T>(identifier): T`
|
||||
|
||||
Resolve a registered service. Throws `GameServerError` with code
|
||||
`GAME_SERVER.SERVICE_NOT_REGISTERED` if the identifier is unknown.
|
||||
|
||||
#### `has(identifier): boolean`
|
||||
|
||||
#### `unregister(identifier): boolean`
|
||||
|
||||
Returns `true` when the identifier was previously registered.
|
||||
|
||||
#### `clear(): void`
|
||||
|
||||
Remove every registration and cached singleton.
|
||||
|
||||
#### `getRegisteredServices(): ServiceIdentifier[]`
|
||||
|
||||
### Types
|
||||
|
||||
- `ServiceIdentifier<T = unknown>` — `string | symbol`.
|
||||
- `ServiceLifecycle` — `"singleton" | "transient"`.
|
||||
- `ServiceDefinition<T>` — internal record holding identifier, factory, singleton flag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Re-register-invalidates-cache is the only behavioural change versus the
|
||||
upstream. The upstream kept the stale singleton after a second
|
||||
`register` call, which masked test-double swaps — code that worked in
|
||||
unit tests because the test installed its fake first, but failed in
|
||||
integration because production startup happened to register the real
|
||||
service first. Discarding the cached instance on re-register matches the
|
||||
"last writer wins" semantics every other suaveplan registry uses
|
||||
(`@suaveplan/events`, `@suaveplan/config`).
|
||||
|
||||
The unresolved-identifier path raises `GameServerError`, not a plain
|
||||
`Error`, because everything in this package is expected to be
|
||||
discriminable via `error.code`. `String(identifier)` is included in
|
||||
both the message and the `context` bag so symbols and plain strings
|
||||
surface identically in logs.
|
||||
@@ -0,0 +1,153 @@
|
||||
# GameEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameEngine` is the top-level orchestrator for `@suaveplan/game-server`. It owns a `DIContainer`, a `GameLoop`, and a `PluginManager`, plus opaque structural slots (`sessionManager`, `playerManager`, `stateManager`, `gameLogic`) that downstream waves fill in. The engine never reaches into those slots — it only registers them in the container and disposes them on shutdown — so this Wave B-1 slice can land before the concrete session/state/network packages are ready and stay stable as they arrive.
|
||||
|
||||
The class enforces a strict lifecycle (`start → tick* → stop → dispose`) and rejects out-of-order calls with the corresponding `GameServerError`: `ENGINE_NOT_STARTED`, `ENGINE_ALREADY_STARTED`, `ENGINE_DISPOSED`. Every transition emits a structured log line, a typed event onto the injected `EventBusLike`, and an OTel `game_server.engine.lifecycle_total{op}` counter sample. Construction Zod-validates the entire options bag at the boundary so callers see a `INVALID_ENGINE_OPTIONS` error immediately rather than later silent misbehaviour.
|
||||
|
||||
## Features
|
||||
|
||||
- Lifecycle FSM with `ENGINE_NOT_STARTED` / `ENGINE_ALREADY_STARTED` / `ENGINE_DISPOSED` guards.
|
||||
- Constructor-time DI registration under `ENGINE_SERVICE_IDS` so plugins and downstream packages can resolve dependencies through a single shared container.
|
||||
- Zod-validated options (`heartbeatIntervalMs`, `playerTimeoutMs`, `autoStartLoop`, `loop`) with `INVALID_ENGINE_OPTIONS` on failure.
|
||||
- `registerPlugin` / `getPlugin` delegates to the underlying `PluginManager`.
|
||||
- `getStats()` returns the union of engine state, loop stats, plugin stats, and session/player counts.
|
||||
- `dispose()` is idempotent and tears down loop, plugins, then opaque slots in reverse-construction order.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { GameEngine } from "@suaveplan/game-server/engine/game-engine";
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const loop = new GameLoop({ clock, timers, logger, events });
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
|
||||
const engine = new GameEngine(
|
||||
{
|
||||
clock,
|
||||
timers,
|
||||
idGenerator,
|
||||
logger,
|
||||
events,
|
||||
gameLoop: loop,
|
||||
pluginManager: plugins,
|
||||
},
|
||||
{ autoStartLoop: true, loop: { mode: "event-driven" } },
|
||||
);
|
||||
|
||||
engine.start();
|
||||
await engine.tick();
|
||||
await engine.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { GameEngine, ENGINE_SERVICE_IDS } from "@suaveplan/game-server/engine/game-engine";
|
||||
|
||||
const engine = new GameEngine(
|
||||
{
|
||||
clock,
|
||||
timers,
|
||||
idGenerator,
|
||||
logger,
|
||||
events,
|
||||
gameLoop,
|
||||
pluginManager,
|
||||
sessionManager,
|
||||
playerManager,
|
||||
stateManager,
|
||||
gameLogic,
|
||||
},
|
||||
{ heartbeatIntervalMs: 5_000, playerTimeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
// Cross-cutting plugin resolves shared services through the engine container:
|
||||
await engine.registerPlugin({
|
||||
name: "telemetry",
|
||||
version: "1.0.0",
|
||||
async onInstall(ctx) {
|
||||
const log = engine.container.resolve(ENGINE_SERVICE_IDS.logger);
|
||||
log.info("telemetry plugin online", { ts: clock() });
|
||||
},
|
||||
});
|
||||
|
||||
engine.start();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await engine.tick();
|
||||
}
|
||||
engine.stop();
|
||||
await engine.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GameEngine`
|
||||
|
||||
#### `new GameEngine(deps, options?)`
|
||||
|
||||
`deps`: `{ clock, timers, idGenerator, logger, events, gameLoop,
|
||||
pluginManager, sessionManager?, playerManager?, stateManager?,
|
||||
gameLogic? }`. Throws `INVALID_ENGINE_OPTIONS` on schema failure.
|
||||
|
||||
#### `start(): void` / `stop(): void` / `tick(): Promise<void>`
|
||||
|
||||
Lifecycle FSM. Wrong-state calls throw the corresponding code.
|
||||
|
||||
#### `registerPlugin(plugin): Promise<void>` / `getPlugin(name): EnginePlugin`
|
||||
|
||||
Delegates to the underlying `PluginManager`.
|
||||
|
||||
#### `getStats(): GameEngineStats`
|
||||
|
||||
Union of `{ running, disposed, loop, plugins, sessions, players }`.
|
||||
|
||||
#### `isRunning(): boolean` / `isDisposed(): boolean`
|
||||
|
||||
Read-only state probes.
|
||||
|
||||
#### `dispose(): Promise<void>`
|
||||
|
||||
Idempotent shutdown. Future state-changing calls throw `ENGINE_DISPOSED`.
|
||||
|
||||
### Service identifiers
|
||||
|
||||
`ENGINE_SERVICE_IDS` — `logger`, `events`, `clock`, `timers`,
|
||||
`idGenerator`, `gameLoop`, `pluginManager`, `sessionManager`,
|
||||
`playerManager`, `stateManager`, `gameLogic`.
|
||||
|
||||
### Types
|
||||
|
||||
`GameEngineOptions`, `GameEngineOptionsInput`, `GameEngineStats`,
|
||||
`GameEngineDeps`, `GameEngineEventMap`, `ISessionManagerLike`,
|
||||
`IPlayerManagerLike`, `IStateManagerLike`, `IGameLogicLike`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The decision to expose `.container` publicly is deliberate — plugins
|
||||
need a way to reach shared services, and the upstream contract
|
||||
guarantees this. Hiding the container behind a `getService(name)`
|
||||
delegate would buy nothing and force every consumer to add an extra
|
||||
indirection.
|
||||
|
||||
`dispose()` traps any error from the chained cleanup steps and
|
||||
re-raises a `GameServerError` so callers can still discriminate via
|
||||
`code`. The first thing `dispose()` does on the error path is flip
|
||||
`disposed = true` so subsequent calls cannot accidentally re-enter
|
||||
shutdown mid-failure. This is the only place in the engine where state
|
||||
changes happen outside the strict success-path order, and it is
|
||||
documented because the failure mode (partial cleanup) is the worst kind
|
||||
of state.
|
||||
|
||||
The opaque session/player/state/game-logic slots are typed as `*Like`
|
||||
interfaces with `dispose?` as their only structural requirement. This
|
||||
matches the upstream contract while keeping Wave B-1 independent of
|
||||
Wave B-2's concrete shapes — downstream waves can extend these
|
||||
interfaces with whatever else they need without rebreaking the engine.
|
||||
@@ -0,0 +1,139 @@
|
||||
# GameLoop
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameLoop` drives a deterministic, injection-friendly game loop with three operating modes (`tick-based`, `event-driven`, `hybrid`). Every wall-clock read routes through an injected `Clock` and every scheduled interval routes through an injected `TimersLike`, which is what makes the loop testable end-to-end under a `FakeClock` without resorting to `vi.useFakeTimers()` or other runner-pinned helpers.
|
||||
|
||||
The loop owns no state other than its callback registry and a small counter set (`tickCount`, `frameCount`, `totalTime`). Callers register update and fixed-update callbacks, then either start the loop in `tick-based`/`hybrid` mode so it schedules itself, or stay in `event-driven` mode and drive `tick()` / `triggerUpdate()` from their own dispatcher. Either way the loop emits a `loop:started` / `loop:stopped` / `loop:paused` / `loop:resumed` event onto the injected `EventBusLike`, and surfaces callback failures as `loop:error` so a host process can route them to its preferred error pipeline without the loop itself terminating.
|
||||
|
||||
## Features
|
||||
|
||||
- Three modes: `tick-based`, `event-driven`, `hybrid`.
|
||||
- `Clock` + `TimersLike` injection — no direct `Date.now()` or `setInterval`.
|
||||
- `Zod`-validated `GameLoopOptions` rejecting on `INVALID_GAME_LOOP_OPTIONS`.
|
||||
- Lifecycle errors as `GameServerError` (`GAME_LOOP_NOT_RUNNING`, `GAME_LOOP_ALREADY_RUNNING`).
|
||||
- Pause/resume refreshes `lastTickTime` so resumes never see a giant delta.
|
||||
- Callback errors surface via the injected event bus; the loop keeps running.
|
||||
- OTel `game_server.loop.tick_duration_ms` histogram sample per tick.
|
||||
- `dispose()` is idempotent and safely halts a running loop.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
|
||||
const loop = new GameLoop(
|
||||
{ clock: () => Date.now(), timers, logger, events },
|
||||
{ mode: "tick-based", tickRate: 30 },
|
||||
);
|
||||
const off = loop.onFixedUpdate((tick) => world.step(tick));
|
||||
loop.start();
|
||||
// later …
|
||||
off();
|
||||
loop.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
import { FakeClock } from "@suaveplan/testing/fake-clock";
|
||||
|
||||
const fake = new FakeClock(0);
|
||||
const handles = new Map<number, { cb: () => void; due: number }>();
|
||||
let next = 1;
|
||||
const timers = {
|
||||
setTimer(cb: () => void, delayMs: number) {
|
||||
const h = next++;
|
||||
handles.set(h, { cb, due: fake.now() + delayMs });
|
||||
return h;
|
||||
},
|
||||
clearTimer(h: number) {
|
||||
handles.delete(h);
|
||||
},
|
||||
};
|
||||
|
||||
const loop = new GameLoop(
|
||||
{ clock: () => fake.now(), timers, logger, events },
|
||||
{ mode: "tick-based", tickRate: 60, maxDeltaTime: 50 },
|
||||
);
|
||||
loop.onUpdate(async (delta) => physics.advance(delta));
|
||||
loop.start();
|
||||
|
||||
// Drive the loop deterministically:
|
||||
fake.advance(16);
|
||||
for (const [, entry] of [...handles.entries()].filter(([, e]) => e.due <= fake.now())) {
|
||||
entry.cb();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GameLoop`
|
||||
|
||||
#### `new GameLoop(deps: GameLoopDeps, options?: GameLoopOptionsInput)`
|
||||
|
||||
`deps`: `{ clock, timers, logger, events }`. Zod-validates `options` and
|
||||
freezes them on the `.options` field. Throws
|
||||
`GameServerError(GAME_SERVER.INVALID_GAME_LOOP_OPTIONS)` on any schema
|
||||
failure.
|
||||
|
||||
#### `start(): void`
|
||||
|
||||
Begins the loop. Throws `GAME_LOOP_ALREADY_RUNNING` if called twice.
|
||||
|
||||
#### `stop(): void` / `pause(): void` / `resume(): void`
|
||||
|
||||
Throw `GAME_LOOP_NOT_RUNNING` if called before `start()`. `pause()` and
|
||||
`resume()` are no-ops when called repeatedly in the same state.
|
||||
|
||||
#### `tick(): Promise<void>` / `triggerUpdate(): Promise<void>`
|
||||
|
||||
Run a single tick or update phase. Both update `lastTickTime`, clamp
|
||||
delta to `maxDeltaTime`, and emit `game_server.loop.tick_duration_ms`.
|
||||
|
||||
#### `onUpdate(cb): () => void` / `onFixedUpdate(cb): () => void`
|
||||
|
||||
Register callbacks. Both return unsubscribe functions.
|
||||
|
||||
#### `getStats(): GameLoopStats`
|
||||
|
||||
Returns mode, running/paused flags, tick rate, totalTime, tickCount,
|
||||
frameCount, callback counts, averageTickMs.
|
||||
|
||||
#### `clearCallbacks(): void` / `dispose(): void`
|
||||
|
||||
`dispose()` is idempotent — calling it twice is safe.
|
||||
|
||||
### Types
|
||||
|
||||
`GameLoopMode`, `GameLoopOptions`, `GameLoopOptionsInput`, `GameLoopStats`,
|
||||
`GameLoopDeps`, `UpdateCallback`, `FixedUpdateCallback`, `GameLoopEventMap`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The scheduler uses `TimersLike.setTimer` recursively (one-shot, then
|
||||
re-schedule from inside the callback) rather than `setInterval`. This makes
|
||||
the loop's timing precisely additive — the next tick's delay starts from
|
||||
when the previous tick's callback completed, not from when the previous
|
||||
tick's timer fired. With a real timer that introduces a small per-tick
|
||||
jitter; with a `FakeClock`-driven timer it makes deterministic test
|
||||
playback trivial.
|
||||
|
||||
`runFixedUpdates` and `runUpdates` iterate the callback `Set` in insertion
|
||||
order (which `Set` guarantees) and catch each callback's error
|
||||
independently. A throwing callback never blocks its siblings, and the
|
||||
loop never terminates from a callback failure — both behaviours match the
|
||||
upstream contract documented in `../suaveplan/.../GameLoop.ts`.
|
||||
|
||||
`dispose()` is structurally idempotent: it flips a `disposed` flag then
|
||||
short-circuits subsequent calls. After dispose, `tick()` and
|
||||
`triggerUpdate()` silently no-op rather than throw — this is the only
|
||||
deliberate lifecycle leniency in the class. The reason: `dispose()` is
|
||||
typically called from a host shutdown hook, and host hosts often race
|
||||
with in-flight tick scheduling. Throwing here would produce noisy
|
||||
shutdown errors that consumers cannot meaningfully act on.
|
||||
@@ -0,0 +1,132 @@
|
||||
# PlayerManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PlayerManager` is the `IPlayerManager` implementation backing the player
|
||||
lifecycle in `@suaveplan/game-server`. It tracks every player's connection
|
||||
state (`connected`, `disconnected`, `reconnecting`, `timeout`), the session
|
||||
each player is bound to, the heartbeat-driven liveness signal, and the
|
||||
reconnection-grace window that distinguishes a transient disconnect from a
|
||||
permanent departure. Every wall-clock observation routes through the
|
||||
injected `Clock`, and the optional heartbeat-monitor loop schedules itself
|
||||
via the injected `TimersLike` — there are no `Date.now()` or `setInterval`
|
||||
calls.
|
||||
|
||||
The manager emits a typed `PlayerManagerEventMap` onto the injected
|
||||
EventBus so other subsystems (matchmaking, presence, audit) can react to
|
||||
join/leave/timeout signals without polling. Every state mutation is also
|
||||
mirrored to OTel via `recordPlayerLifecycle` so dashboards see the full
|
||||
counter set.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's canonical `IPlayerManager` contract.
|
||||
- Injectable `Clock` and `TimersLike` — fully deterministic under fakes.
|
||||
- Zod-validated options boundary (`timeoutThresholdMs`,
|
||||
`reconnectionGraceMs`) with stable `INVALID_PLAYER_MANAGER_OPTIONS`
|
||||
failure code.
|
||||
- `INVALID_HEARTBEAT_INTERVAL` for non-positive integer interval inputs.
|
||||
- Reconnection path: a `connect` against an existing disconnected /
|
||||
timeout entry transitions to `reconnecting`/`connected` rather than
|
||||
building a fresh record (preserves `connectedAt`).
|
||||
- Grace-window cleanup: in `checkTimeouts`, disconnected entries older
|
||||
than `reconnectionGraceMs` are evicted so a future `connect` builds a
|
||||
fresh record.
|
||||
- Defensive copies — public API returns frozen plain-object snapshots
|
||||
rather than mutable internals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { PlayerManager } from "../player-manager/index.js";
|
||||
|
||||
const mgr = new PlayerManager({
|
||||
clock: () => Date.now(),
|
||||
timers,
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
mgr.connect("alice", "table-1", { tier: "gold" });
|
||||
mgr.heartbeat("alice");
|
||||
console.log(mgr.getConnection("alice")?.status); // "connected"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Custom timeout + grace window, with the heartbeat monitor running
|
||||
// via the injected timers source.
|
||||
const mgr = new PlayerManager(deps, {
|
||||
timeoutThresholdMs: 60_000,
|
||||
reconnectionGraceMs: 5_000,
|
||||
});
|
||||
|
||||
mgr.startHeartbeatMonitor(1_000); // sweep every second
|
||||
mgr.connect("alice", "table-1");
|
||||
// ...network drop...
|
||||
mgr.disconnect("alice", "network_lost");
|
||||
// 5s pass without reconnect — checkTimeouts evicts the entry.
|
||||
// A later connect("alice", "table-1") builds a fresh record.
|
||||
mgr.stopHeartbeatMonitor();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new PlayerManager(deps, options?)` where `deps` is
|
||||
`{ clock, timers, logger, events }`. Options:
|
||||
- `timeoutThresholdMs` (default 30000) — ms without heartbeat before a
|
||||
connected player is marked `timeout`.
|
||||
- `reconnectionGraceMs` (default 10000) — ms after a disconnect during
|
||||
which a `connect()` is treated as a reconnection.
|
||||
|
||||
### IPlayerManager surface (11 methods)
|
||||
|
||||
`connect`, `disconnect`, `getConnection`, `getSessionPlayers`,
|
||||
`getAllConnections`, `heartbeat`, `checkTimeouts`, `updateMetadata`,
|
||||
`getPlayer`, `startHeartbeatMonitor`, `stopHeartbeatMonitor`.
|
||||
|
||||
### Extras
|
||||
|
||||
- `getStats()` — connection-status histogram + average duration.
|
||||
- `dispose()` — stop monitor + drop every connection.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_PLAYER_MANAGER_OPTIONS` — Zod failure on constructor.
|
||||
- `INVALID_HEARTBEAT_INTERVAL` — non-positive integer to
|
||||
`startHeartbeatMonitor`.
|
||||
|
||||
### Events
|
||||
|
||||
`player:connected`, `player:disconnected`, `player:reconnected`,
|
||||
`player:timeout`, `player:heartbeat`, `player:joined`, `player:left`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The heartbeat monitor is implemented via `TimersLike.setTimer` chained in
|
||||
a self-rescheduling tick rather than via `setInterval`. That makes the
|
||||
behaviour deterministic under `FakeTimers` — the test suite explicitly
|
||||
fires due timers rather than depending on real wall-clock progression.
|
||||
The chain self-cancels by checking `heartbeatHandle !== undefined` before
|
||||
scheduling the next tick — so a `stopHeartbeatMonitor` between ticks
|
||||
prevents the next tick from being scheduled.
|
||||
|
||||
Connection records are mutable internally for hot-path efficiency
|
||||
(`heartbeat` is called every frame in worst case) but every public
|
||||
accessor returns a `freeze(...)`-built defensive copy. The metadata bag
|
||||
in particular is shallow-copied per accessor call — callers can mutate
|
||||
the returned object without affecting the internal state.
|
||||
|
||||
The grace-window logic in `checkTimeouts` is what distinguishes a
|
||||
"reconnection" from a "fresh connection". A disconnected entry within
|
||||
the grace window is still resolvable via `getConnection`, so a later
|
||||
`connect()` call routes through the reconnection path; once the grace
|
||||
expires, the entry is evicted entirely and a future `connect()` builds a
|
||||
fresh record (with a new `connectedAt`).
|
||||
@@ -0,0 +1,145 @@
|
||||
# PluginManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PluginManager` is the typed plugin registry for `@suaveplan/game-server`. It owns the install/uninstall/enable/disable lifecycle, drives plugin dependency ordering via topological sort, and wraps every plugin lifecycle hook in a single discriminable error code (`PLUGIN_LIFECYCLE_FAILED`) so consumers can centralise their error handling without scraping cause chains.
|
||||
|
||||
The manager carries no global state — plugins are stored under their declared `name`, and the install order is captured separately so `disposeAll()` can tear plugins down in reverse. Every state-changing call (`install`, `uninstall`, `enable`, `disable`) emits both a structured log line and a typed event onto the injected `EventBusLike`, and bumps the `game_server.plugin.lifecycle_total` OTel counter with a low-cardinality `op`/`outcome` attribute pair.
|
||||
|
||||
## Features
|
||||
|
||||
- Typed `EnginePlugin` contract with optional `onInstall` / `onUninstall` / `onEnable` / `onDisable` hooks.
|
||||
- Zod-validated plugin shape on every registration path.
|
||||
- `DUPLICATE_PLUGIN` on second install with the same name; `PLUGIN_NOT_FOUND` on every unknown-name lookup; `PLUGIN_LIFECYCLE_FAILED` wrapping hook errors, missing deps, and cycles.
|
||||
- `installAll(plugins)` accepts an unordered batch and resolves dependencies via topological sort.
|
||||
- `disposeAll()` tears plugins down in reverse install order; `isEnabled` is checked before `onDisable` fires (so callers can call uninstall on a still-enabled plugin without an explicit disable first).
|
||||
- Structured logging + typed event emission on every state change.
|
||||
- OTel `game_server.plugin.lifecycle_total` counter incremented with `op`/`outcome` attributes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
await plugins.install({
|
||||
name: "metrics",
|
||||
version: "1.0.0",
|
||||
async onInstall(ctx) {
|
||||
ctx.logger.info("metrics plugin online");
|
||||
},
|
||||
});
|
||||
await plugins.enable("metrics");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
|
||||
await plugins.installAll([
|
||||
{ name: "metrics", version: "1.0.0" },
|
||||
{
|
||||
name: "leaderboard",
|
||||
version: "0.3.0",
|
||||
dependencies: ["metrics"],
|
||||
async onInstall(ctx) {
|
||||
ctx.events.emit("plugin:installed", {
|
||||
name: "leaderboard",
|
||||
version: "0.3.0",
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// `metrics` is installed first because `leaderboard.dependencies = ["metrics"]`.
|
||||
|
||||
await plugins.enable("leaderboard");
|
||||
await plugins.enable("metrics");
|
||||
|
||||
// Errors carry the offending plugin + hook name:
|
||||
try {
|
||||
await plugins.install({
|
||||
name: "broken",
|
||||
version: "0.1.0",
|
||||
async onInstall() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === "GAME_SERVER.PLUGIN_LIFECYCLE_FAILED") {
|
||||
console.log(err.context); // { name: "broken", hook: "onInstall" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class PluginManager`
|
||||
|
||||
#### `new PluginManager(deps)`
|
||||
|
||||
`deps`: `{ logger: LoggerLike; events: EventBusLike<PluginManagerEventMap> }`.
|
||||
|
||||
#### `install(plugin): Promise<void>` / `installAll(plugins): Promise<void>`
|
||||
|
||||
`install` rejects with `DUPLICATE_PLUGIN` on a re-install. `installAll`
|
||||
topologically orders the batch; a missing dependency or a cycle rejects
|
||||
with `PLUGIN_LIFECYCLE_FAILED` whose `context` carries either `{ missing, requestedBy }`
|
||||
or `{ cycle }`.
|
||||
|
||||
#### `uninstall(name): Promise<void>`
|
||||
|
||||
Disables the plugin first if it is currently enabled, then runs
|
||||
`onUninstall`. Rejects with `PLUGIN_NOT_FOUND` on unknown name.
|
||||
|
||||
#### `enable(name): Promise<void>` / `disable(name): Promise<void>`
|
||||
|
||||
Idempotent — calling `enable` on an already-enabled plugin is a no-op
|
||||
that does not run the hook again.
|
||||
|
||||
#### `get(name): EnginePlugin` / `has(name): boolean` / `isEnabled(name): boolean`
|
||||
|
||||
`get` and `isEnabled` throw `PLUGIN_NOT_FOUND` on unknown name; `has`
|
||||
returns a boolean.
|
||||
|
||||
#### `list(): readonly string[]` / `getStats(): PluginManagerStats`
|
||||
|
||||
`list()` returns the install order; `getStats()` adds `enabled`/`disabled`
|
||||
counts.
|
||||
|
||||
#### `disposeAll(): Promise<void>`
|
||||
|
||||
Uninstall every plugin in reverse install order.
|
||||
|
||||
### Types
|
||||
|
||||
`EnginePlugin`, `EnginePluginContext`, `PluginManagerStats`,
|
||||
`PluginManagerEventMap`, `PluginManagerDeps`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Topological sort uses the standard three-set Tarjan algorithm
|
||||
(`visited` / `visiting` plus a `path` accumulator) so cycles surface with
|
||||
the offending edge sequence rather than a generic message. The error
|
||||
carries the cycle string in `context.cycle` so log scrapers can group by
|
||||
the participating plugins without parsing free text.
|
||||
|
||||
The decision to fold "schema validation" failures into the
|
||||
`PLUGIN_LIFECYCLE_FAILED` code rather than a dedicated `INVALID_PLUGIN`
|
||||
code keeps the error surface narrow — every plugin-related failure has
|
||||
the same discriminable code, with the specific failure mode visible in
|
||||
`context.hook` or `context.missing` or `context.cycle`. Consumers that
|
||||
want to branch on the precise failure mode read those fields; the
|
||||
`code` field is enough for a catch-all log emit.
|
||||
|
||||
`disposeAll` walks the install order in reverse so plugins with declared
|
||||
dependencies tear down after their dependents. This mirrors the
|
||||
upstream contract: a logging plugin installed first must be the last to
|
||||
go, because every other plugin's `onUninstall` may still need to log.
|
||||
@@ -0,0 +1,128 @@
|
||||
# SessionLifecycle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionLifecycle` is the per-session finite-state machine that models
|
||||
each session's progression through the three high-level phases of its
|
||||
existence: `lobby` (open for players, not yet running), `active` (the game
|
||||
is in progress), and `ended` (no further transitions allowed). The class
|
||||
owns the FSM transition table, the per-session entry record, and a full
|
||||
audit history of every transition the manager has observed.
|
||||
|
||||
Each transition is validated against a fixed allow-table: `lobby → active`,
|
||||
`lobby → ended`, and `active → ended` are legal; every other pair is
|
||||
rejected with a stable error code. The manager emits a
|
||||
`session:transitioned` event onto the injected EventBus on every legal
|
||||
transition and records two OTel instruments: a counter for the transition
|
||||
itself and a histogram for the dwell time in the state being left.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed three-state vocabulary: `lobby`, `active`, `ended`.
|
||||
- Strict allow-table — illegal transitions throw
|
||||
`INVALID_SESSION_TRANSITION`; transitions out of the terminal `ended`
|
||||
state throw `SESSION_ALREADY_ENDED`.
|
||||
- Injectable `Clock` — every state-entry timestamp and dwell sample is
|
||||
deterministic under `FakeClock`.
|
||||
- `canTransition(id, to)` — non-throwing predicate for callers that want
|
||||
to branch on legality rather than catch.
|
||||
- Full audit history preserved across `forget(id)` — operations teams
|
||||
can post-mortem a session even after its registry entry is dropped.
|
||||
- OTel instruments: `game_server.session.lifecycle_total` counter (tags
|
||||
`from`/`to`/`outcome`) and `game_server.session.dwell_ms` histogram
|
||||
(tag `state`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionLifecycle } from "../session-lifecycle/index.js";
|
||||
|
||||
const fsm = new SessionLifecycle({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
fsm.register("table-1");
|
||||
fsm.transition("table-1", "active");
|
||||
// ...game plays out...
|
||||
fsm.transition("table-1", "ended", "game_complete");
|
||||
console.log(fsm.getState("table-1")); // "ended"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Defensive read before transitioning — useful from a UI controller.
|
||||
if (fsm.canTransition(id, "ended")) {
|
||||
fsm.transition(id, "ended", "host_cancelled");
|
||||
}
|
||||
|
||||
// Audit reporting — group transitions by session for a post-mortem.
|
||||
const history = fsm.getHistoryFor("table-1");
|
||||
for (const t of history) {
|
||||
console.log(`${t.at}: ${t.from} → ${t.to} ${t.reason ?? ""}`);
|
||||
}
|
||||
|
||||
// Re-using ids: forget the entry but keep the audit trail.
|
||||
fsm.forget("table-1");
|
||||
console.log(fsm.getState("table-1")); // null
|
||||
console.log(fsm.getHistoryFor("table-1").length); // > 0 (preserved)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionLifecycle(deps)` where `deps` is `{ clock, logger, events }`.
|
||||
|
||||
### Methods
|
||||
|
||||
- `register(id)` — initialise an entry in the `lobby` state. Throws
|
||||
`DUPLICATE_SESSION_ID` on collision.
|
||||
- `transition(id, to, reason?)` — validated mutation; emits
|
||||
`session:transitioned`; records OTel.
|
||||
- `getState(id)` / `getEntry(id)` — null for unknown ids.
|
||||
- `getAllEntries()` — defensive snapshot.
|
||||
- `canTransition(id, to)` — non-throwing legality predicate.
|
||||
- `getHistory()` / `getHistoryFor(id)` — defensive copies.
|
||||
- `forget(id)` — drop the entry; history is preserved.
|
||||
|
||||
### Errors
|
||||
|
||||
- `SESSION_NOT_FOUND` — `transition` on an unregistered id.
|
||||
- `SESSION_ALREADY_ENDED` — `transition` on an entry already in `ended`.
|
||||
- `INVALID_SESSION_TRANSITION` — `to` not in the allow-table for the
|
||||
current state.
|
||||
- `DUPLICATE_SESSION_ID` — `register` collision.
|
||||
|
||||
### Events
|
||||
|
||||
- `session:transitioned` — payload is the full `SessionTransition`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The allow-table is a `Readonly<Record<SessionState, ReadonlyArray<...>>>`
|
||||
constant — adding a new state requires updating both the
|
||||
`SessionState` union and the table, and every `transition` call walks the
|
||||
table at most twice (once for the rejection check, once for the
|
||||
recording). Forgetting to update the table would manifest as the FSM
|
||||
silently rejecting every transition out of the new state, which the test
|
||||
suite would catch.
|
||||
|
||||
The history is appended to a single shared array rather than
|
||||
per-session — this keeps the audit log monotonically ordered by wall
|
||||
clock, which matters when post-mortems span multiple sessions. The
|
||||
`getHistoryFor(id)` helper filters out the rest with a single linear
|
||||
walk; for production-scale histories, replace this in a follow-up with a
|
||||
per-id index. The current implementation is sized for the lifecycle
|
||||
flows of typical real-time games (single-digit transitions per session).
|
||||
|
||||
OTel emission uses two attributes on the counter (`from` and `to`) so a
|
||||
dashboard can aggregate by either side of the transition. The dwell
|
||||
histogram tags only the state being left — the duration is measured from
|
||||
when the session entered that state to when it left.
|
||||
@@ -0,0 +1,126 @@
|
||||
# SessionGraphNode
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionGraphNode` is the internal mutable graph node powering the
|
||||
hierarchical session tree at the heart of `@suaveplan/game-server`. Every
|
||||
node holds a stable id, a closed-vocabulary session type, a metadata bag,
|
||||
the mutable bidirectional parent/children links that form the tree, and
|
||||
the two timestamps that mark when the node was created and last touched.
|
||||
|
||||
The class is named `SessionGraphNode` deliberately — Wave A's public
|
||||
`SessionNode` interface (in `core/types`) is a read-only structural view of
|
||||
exactly this shape. exposes the read-only interface to consumers
|
||||
and uses the mutable class internally, so callers cannot accidentally
|
||||
mutate the tree by reaching into `node.children.push(...)` through the
|
||||
public manager surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's `SessionNode` interface structurally (the class is
|
||||
the canonical implementation; the interface is the read-only view).
|
||||
- Injectable `Clock` — no `Date.now()` calls; the entire tree is
|
||||
deterministic under `FakeClock`.
|
||||
- Bidirectional parent/children link maintenance via `addChild` /
|
||||
`removeChild` with idempotent attach/detach semantics.
|
||||
- Tree-walking helpers: `getDepth`, `getDescendants` (DFS pre-order),
|
||||
`getAncestors`, `getRoot`, `isAncestorOf`, `isDescendantOf`.
|
||||
- `toJSON()` returning a `SerializedSessionNode` shape for `SessionStore`
|
||||
persistence.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
|
||||
const clock = () => 1700000000000;
|
||||
const root = new SessionGraphNode(
|
||||
"venue1",
|
||||
"venue",
|
||||
{ createdAt: 0, updatedAt: 0 },
|
||||
clock
|
||||
);
|
||||
|
||||
const table = new SessionGraphNode(
|
||||
"table1",
|
||||
"table",
|
||||
{ createdAt: 0, updatedAt: 0 },
|
||||
clock,
|
||||
root
|
||||
);
|
||||
root.addChild(table);
|
||||
console.log(table.getDepth()); // 1
|
||||
console.log(table.getRoot().id); // "venue1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
|
||||
// Persistence round-trip: serialise, then restore with original timestamps.
|
||||
const node = new SessionGraphNode("s1", "game", meta, clock);
|
||||
const json = node.toJSON();
|
||||
const restored = new SessionGraphNode(
|
||||
json.id,
|
||||
json.type,
|
||||
json.metadata,
|
||||
clock,
|
||||
null,
|
||||
{ createdAt: json.createdAt, updatedAt: json.updatedAt }
|
||||
);
|
||||
|
||||
// Removing a child clears the bidirectional link.
|
||||
root.removeChild(table);
|
||||
console.log(table.parent); // null
|
||||
console.log(root.children.length); // 0
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionGraphNode(id, type, metadata, clock, parent?, timestamps?)`
|
||||
|
||||
`timestamps` is optional — passed only when restoring from persistence to
|
||||
preserve the original `createdAt` / `updatedAt` stamps.
|
||||
|
||||
### Methods
|
||||
|
||||
- `addChild(child)` — attach a child; idempotent (re-adding is a no-op).
|
||||
- `removeChild(child)` — returns `true` if removed, `false` if not a
|
||||
child.
|
||||
- `updateMetadata(patch)` — shallow-merge patch; refreshes `updatedAt`.
|
||||
- `getDepth()` — 0 for a root, otherwise the distance to the root.
|
||||
- `getDescendants()` — depth-first pre-order array, excluding `this`.
|
||||
- `getAncestors()` — parent-to-root array.
|
||||
- `getRoot()` — the topmost ancestor (or `this`).
|
||||
- `isAncestorOf(other)` / `isDescendantOf(other)` — strict ancestry tests.
|
||||
- `toJSON()` — serialised plain object suitable for storage adapters.
|
||||
|
||||
### Read-only properties
|
||||
|
||||
`id`, `type`, `createdAt`, `updatedAt`.
|
||||
|
||||
### Mutable properties
|
||||
|
||||
`parent` (managed by `addChild`/`removeChild`), `children`, `metadata`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bidirectional parent/children invariant is held by `addChild` and
|
||||
`removeChild`. Direct mutation of `node.children` or `node.parent` is
|
||||
discouraged — callers should always go through these methods so the
|
||||
counterpart link stays in sync. The class is internal to the engine
|
||||
package and is not part of the public re-export surface; consumers should
|
||||
import `SessionNode` from `core/types` instead.
|
||||
|
||||
The constructor accepts an optional `timestamps` parameter solely for the
|
||||
persistence round-trip path — without it, a freshly restored node would
|
||||
have its `createdAt` set to the moment of restoration, losing the original
|
||||
creation time. With it, `SessionStore.restore` produces nodes that are
|
||||
byte-equivalent to the ones that were originally persisted.
|
||||
@@ -0,0 +1,137 @@
|
||||
# SessionStore
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionStore` is the `ISessionManager` implementation that consumers wire
|
||||
into a `GameEngine`. It composes a `SessionTree` (the in-memory
|
||||
hierarchical graph) with an injectable `SessionPersistence` adapter so the
|
||||
session graph survives process restarts. Callers receive the read-only
|
||||
`SessionNode` shape from `core/types` — the mutable `SessionGraphNode` is
|
||||
internal to the engine package and cannot be mutated through the public
|
||||
manager surface.
|
||||
|
||||
The store accepts a Zod-validated options bundle (`storageKey` for
|
||||
namespacing the persistence blob) and an injectable persistence adapter.
|
||||
When no adapter is supplied, an in-memory default is used so callers can
|
||||
exercise the full lifecycle in tests and ephemeral environments without a
|
||||
real storage backend.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's canonical `ISessionManager` contract (10 methods).
|
||||
- Zod-validated options boundary with stable
|
||||
`INVALID_SESSION_STORE_OPTIONS` failure code.
|
||||
- Pluggable `SessionPersistence` adapter (`get`/`set`/`delete`) so callers
|
||||
can swap in any byte-store.
|
||||
- Default in-memory persistence for ephemeral runs.
|
||||
- Restore reconstructs parent-before-child ordering automatically from
|
||||
any record permutation.
|
||||
- Forwards every tree mutation to the injected EventBus via the
|
||||
composed `SessionTree`.
|
||||
- Exposes `getStats`, `hasSession`, `clear`, and `clearPersistence` as
|
||||
ergonomic extras over the bare `ISessionManager` contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionStore } from "../session-store/index.js";
|
||||
|
||||
const store = new SessionStore({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
store.createSession(null, {
|
||||
id: "venue1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
store.createSession("venue1", {
|
||||
id: "table1",
|
||||
type: "table",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
|
||||
await store.persist();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Inject a SurrealDB-backed persistence adapter for durability.
|
||||
const persistence: SessionPersistence = {
|
||||
async get(key) {
|
||||
const row = await db.select(key);
|
||||
return row?.payload ?? null;
|
||||
},
|
||||
async set(key, payload) {
|
||||
await db.upsert(key, { payload });
|
||||
},
|
||||
async delete(key) {
|
||||
await db.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
const store = new SessionStore(
|
||||
{ clock, logger, events, persistence, storageKey: "shard-eu-1" },
|
||||
{}
|
||||
);
|
||||
|
||||
// On startup: restore prior state, then begin accepting traffic.
|
||||
await store.restore();
|
||||
console.log(store.getAllSessions().length); // restored count
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionStore(deps, options?)` where `deps` is
|
||||
`{ clock, logger, events, persistence?, storageKey? }`. Options accept
|
||||
`{ storageKey? }` and default to `game_server:sessions`.
|
||||
|
||||
### ISessionManager surface
|
||||
|
||||
`createSession`, `getSession`, `destroySession`, `getSessionsByType`,
|
||||
`getChildren`, `getParent`, `updateMetadata`, `getAllSessions`, `persist`,
|
||||
`restore` — all forwarding to the composed `SessionTree` and adapter.
|
||||
|
||||
### Extras
|
||||
|
||||
- `getStats()` — aggregate counts.
|
||||
- `hasSession(id)` — O(1) presence.
|
||||
- `clear()` — drop in-memory only.
|
||||
- `clearPersistence()` — drop the persisted blob.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_SESSION_STORE_OPTIONS` — Zod failure at construction.
|
||||
- All `SessionTree`-thrown codes (`INVALID_SESSION_DATA`,
|
||||
`DUPLICATE_SESSION_ID`, `PARENT_SESSION_NOT_FOUND`) pass through.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`restore` performs a fixed-point walk: it repeatedly scans the remaining
|
||||
records and attaches any whose parent is already present, halting when
|
||||
either every record is attached or no progress was made in a full pass.
|
||||
Orphan records (a `parentId` referencing a session not in the payload)
|
||||
are logged as warnings rather than thrown — losing the parent shouldn't
|
||||
prevent the rest of the tree from coming online. Real persistence
|
||||
adapters that guarantee referential integrity will never trigger that
|
||||
warning path.
|
||||
|
||||
The default `InMemoryPersistence` is intentionally namespaced inside the
|
||||
store instance — a fresh `SessionStore` always gets a fresh map. This
|
||||
keeps test isolation trivial: callers don't need to manually clear
|
||||
persistence between tests. For production usage, the adapter is always
|
||||
supplied.
|
||||
|
||||
`persist` snapshots the current tree by walking `getAllSessions()` and
|
||||
calling `toJSON()` on each node. The order of records in the payload is
|
||||
preserved insertion-order from the internal `Map`, but `restore` does not
|
||||
rely on that order — the fixed-point walk handles arbitrary permutations.
|
||||
@@ -0,0 +1,132 @@
|
||||
# SessionTree
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionTree` is the hierarchical session graph backing
|
||||
`@suaveplan/game-server`. It owns the `Map<id, SessionGraphNode>` index plus
|
||||
the `Set<rootId>` that tracks parent-less sessions, and it exposes the
|
||||
mutation surface used by `SessionStore` to satisfy the public
|
||||
`ISessionManager` contract. The tree supports unlimited nesting
|
||||
(`venue → table → queue → game → player`) with O(1) lookups by id.
|
||||
|
||||
Every wall-clock observation routes through the injected `Clock`, every
|
||||
mutation emits a typed event onto the injected `EventBus`, and every input
|
||||
boundary that accepts session data is validated through Zod with a
|
||||
`GameServerError` thrown on rejection. The class is internal to the engine
|
||||
package — consumers reach it through `SessionStore`, never directly.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `getSession` lookup via a single `Map`.
|
||||
- Bidirectional parent/children link maintenance via `SessionGraphNode`.
|
||||
- Zod-validated `SessionData` boundary with stable `INVALID_SESSION_DATA`
|
||||
failure code.
|
||||
- Duplicate-id and dangling-parent guards (`DUPLICATE_SESSION_ID`,
|
||||
`PARENT_SESSION_NOT_FOUND`).
|
||||
- Bottom-up `destroySession` cascading through every descendant.
|
||||
- `attachRestored` re-attach path for `SessionStore.restore` that does NOT
|
||||
emit `session:created` (restoration is not creation).
|
||||
- `validateIntegrity` audit for debug logging.
|
||||
- Aggregate `getStats` reporting total session count, root count, max
|
||||
depth, and per-type distribution.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionTree } from "../session-tree/index.js";
|
||||
|
||||
const tree = new SessionTree({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
const venue = tree.createSession(null, {
|
||||
id: "venue1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
tree.createSession("venue1", {
|
||||
id: "table1",
|
||||
type: "table",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
|
||||
console.log(tree.getChildren("venue1").map((c) => c.id)); // ["table1"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Destroying a parent cascades through all descendants bottom-up.
|
||||
tree.destroySession("venue1");
|
||||
console.log(tree.getAllSessions()); // []
|
||||
|
||||
// Persistence round-trip — restore via attachRestored without firing
|
||||
// the "session:created" event (because restoration is not creation).
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
const restored = new SessionGraphNode("a", "venue", meta, clock, null, {
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
});
|
||||
tree.attachRestored(restored, null);
|
||||
console.log(tree.hasSession("a")); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionTree(deps)` where `deps` is `{ clock, logger, events }`.
|
||||
|
||||
### Methods
|
||||
|
||||
- `createSession(parentId, data)` — validates `data`, throws on duplicate
|
||||
id or missing parent; emits `session:created`.
|
||||
- `getSession(id)` / `hasSession(id)` — O(1) presence checks.
|
||||
- `updateMetadata(id, patch)` — shallow merge; emits
|
||||
`session:metadata:changed` and `session:updated`.
|
||||
- `destroySession(id)` — bottom-up cascade; emits `session:destroyed`
|
||||
once per detached node.
|
||||
- `getSessionsByType(type)` — filter by closed `SessionType` vocabulary.
|
||||
- `getChildren` / `getParent` / `getDescendants` / `getAncestors`.
|
||||
- `getAllSessions()` / `getRoots()` / `getSessionCount()`.
|
||||
- `getStats()` — aggregate counts + max depth + per-type distribution.
|
||||
- `clear()` — drop everything; used by `SessionStore.restore`.
|
||||
- `attachRestored(node, parentId)` — re-attach during restore; throws on
|
||||
duplicate id or missing parent.
|
||||
- `validateIntegrity()` — `{valid, errors}` audit.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_SESSION_DATA` — Zod validation failure on `createSession`.
|
||||
- `DUPLICATE_SESSION_ID` — id collision on `createSession` or
|
||||
`attachRestored`.
|
||||
- `PARENT_SESSION_NOT_FOUND` — `parentId` references an unknown session.
|
||||
|
||||
### Events
|
||||
|
||||
- `session:created` / `session:destroyed` / `session:updated` /
|
||||
`session:metadata:changed`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`destroySession` walks descendants depth-first and then reverses the array
|
||||
so deepest sessions detach first — this matters when the EventBus has
|
||||
subscribers that expect leaf-before-parent ordering. The reverse-iteration
|
||||
of `getDescendants()` is deliberate: `getDescendants()` returns pre-order,
|
||||
which makes the children-before-parent walk a single `reverse()` call
|
||||
rather than a separate post-order traversal.
|
||||
|
||||
`attachRestored` is a deliberate seam in the tree's surface — it accepts a
|
||||
pre-built `SessionGraphNode` (with its preserved `createdAt`/`updatedAt`
|
||||
timestamps) rather than `SessionData`. This is the only path that bypasses
|
||||
the Zod validation, because validation already happened at the original
|
||||
`createSession` call before persistence. Restoration uses the same
|
||||
duplicate-id and missing-parent guards as creation, but emits no events —
|
||||
listeners should subscribe to the `SessionStore.restore()` completion
|
||||
signal if they need a notification.
|
||||
@@ -0,0 +1,71 @@
|
||||
# anti-cheat-validator.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal pure helpers backing `AntiCheatValidator`. Holds default
|
||||
configuration constants (movement, rate limits, punishment thresholds),
|
||||
the Euclidean `distance` calculator, the `recommend` punishment-escalation
|
||||
ladder, and the `emptyHistory` factory.
|
||||
|
||||
Splitting these out keeps `anti-cheat-validator.ts` below the 300-line code
|
||||
file limit while ensuring the helpers remain side-effect free, individually
|
||||
testable, and unit-coverable.
|
||||
|
||||
## Features
|
||||
|
||||
- Default `MovementConfig` (`maxSpeed`, `maxTeleportDistance`,
|
||||
`toleranceFactor`).
|
||||
- Default `PunishmentThresholds` (escalation ladder for warnings → minor →
|
||||
major → kick → temp/permanent ban).
|
||||
- Default `RateLimitConfig` array (60 actions / 1 s / all).
|
||||
- `distance(a, b)` Euclidean 3D distance (z defaults to 0).
|
||||
- `recommend(h, p)` punishment ladder driven by per-severity counts and
|
||||
current ban status.
|
||||
- `emptyHistory(playerId)` factory for new tracked players.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_MOVEMENT,
|
||||
distance,
|
||||
} from "./anti-cheat-validator.internal.js";
|
||||
|
||||
distance({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
|
||||
DEFAULT_MOVEMENT.maxSpeed; // 10
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
emptyHistory,
|
||||
recommend,
|
||||
DEFAULT_PUNISHMENTS,
|
||||
} from "./anti-cheat-validator.internal.js";
|
||||
|
||||
const h = emptyHistory("p1");
|
||||
h.violationsBySeverity.major = 3;
|
||||
recommend(h, DEFAULT_PUNISHMENTS); // "temp_ban"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_MOVEMENT: MovementConfig`
|
||||
- `DEFAULT_PUNISHMENTS: PunishmentThresholds`
|
||||
- `DEFAULT_RATE_LIMITS: RateLimitConfig[]`
|
||||
- `distance(a: Position, b: Position): number`
|
||||
- `recommend(h: PlayerViolationHistory, p: PunishmentThresholds):
|
||||
PunishmentRecommendation`
|
||||
- `emptyHistory(playerId: string): PlayerViolationHistory`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`distance` treats missing `z` as 0 so 2D positions interoperate with 3D
|
||||
checks. `recommend` short-circuits on the strictest applicable threshold
|
||||
(critical violation or active temp ban → permanent), then walks the ladder
|
||||
top-down. `emptyHistory` reserves an empty object for the per-type and
|
||||
per-severity tally maps so callers can use the safe `??` increment pattern
|
||||
without hitting `undefined` keys. Defaults match the migrated upstream
|
||||
configuration so the public observable behaviour of `AntiCheatValidator`
|
||||
is unchanged from the universal source.
|
||||
@@ -0,0 +1,131 @@
|
||||
# anti-cheat-validator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AntiCheatValidator` performs server-authoritative validation for player
|
||||
movement and action streams in real-time multiplayer games. It tracks per-
|
||||
player position history, applies movement-speed and teleport thresholds,
|
||||
enforces rolling-window rate limits, runs caller-registered action
|
||||
validators, accumulates violation histories, and escalates punishments
|
||||
through a configurable warn → minor → major → kick → temp-ban → permanent-
|
||||
ban ladder.
|
||||
|
||||
The validator is dependency-injectable: a `Clock` provides wall-clock
|
||||
time, an `IdGenerator` mints violation ids, and an OpenTelemetry `Meter`
|
||||
provides metrics emission. Configuration is validated with Zod at the
|
||||
constructor boundary; invalid input raises
|
||||
`GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`.
|
||||
|
||||
## Features
|
||||
|
||||
- Movement validation: teleport (distance), horizontal speed-hack,
|
||||
optional vertical speed-hack, timing-anomaly detection.
|
||||
- Rolling-window rate limiting per action type plus a global `"all"`
|
||||
bucket.
|
||||
- Pluggable action validators with full validation context
|
||||
(`getPlayerPosition`, `getPlayerHistory`, `getCurrentTick`).
|
||||
- Auto-flagging at a configurable violations-per-minute threshold.
|
||||
- Automatic punishment ladder driven by per-severity tallies and current
|
||||
ban status.
|
||||
- Per-player history, statistics, OTel counters
|
||||
(`game_server.anticheat.validation_total`,
|
||||
`game_server.anticheat.violation_total`).
|
||||
- Typed listener registry (`on`/`off`) for `violation:detected`,
|
||||
`player:flagged`/`:unflagged`/`:kicked`/`:temp_banned`/`:permanent_banned`,
|
||||
`rate_limit:exceeded`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AntiCheatValidator } from "@suaveplan/game-server/enhanced/anti-cheat/anti-cheat-validator";
|
||||
|
||||
const validator = new AntiCheatValidator();
|
||||
const r = validator.validateMovement({
|
||||
playerId: "p1",
|
||||
position: { x: 0, y: 0 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (!r.valid) console.warn(r.violation);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AntiCheatValidator } from "@suaveplan/game-server/enhanced/anti-cheat/anti-cheat-validator";
|
||||
|
||||
let now = 0;
|
||||
const validator = new AntiCheatValidator<
|
||||
{ x: number; y: number },
|
||||
{ damage: number }
|
||||
>(
|
||||
{
|
||||
rateLimits: [{ maxActions: 30, windowMs: 1_000, actionType: "shoot" }],
|
||||
movement: {
|
||||
maxSpeed: 12,
|
||||
maxTeleportDistance: 60,
|
||||
allowVertical: true,
|
||||
maxVerticalSpeed: 18,
|
||||
toleranceFactor: 1.25,
|
||||
},
|
||||
autoFlagThreshold: 6,
|
||||
enableAutoPunishment: true,
|
||||
},
|
||||
{
|
||||
clock: () => now,
|
||||
idGenerator: () => `v-${++now}`,
|
||||
}
|
||||
);
|
||||
|
||||
validator.registerValidator("shoot", async (action) => {
|
||||
return action.payload.damage <= 100
|
||||
? { valid: true, shouldWarn: false, recommendation: "none" }
|
||||
: {
|
||||
valid: false,
|
||||
shouldWarn: false,
|
||||
recommendation: "warn",
|
||||
reason: "damage out of range",
|
||||
};
|
||||
});
|
||||
|
||||
const unsub = validator.on("violation:detected", (v) => audit.write(v));
|
||||
// ... later
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new AntiCheatValidator(config?, deps?)` — validates `config` with
|
||||
`AntiCheatConfigSchema`; throws `GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`
|
||||
on failure. `deps`: `{ clock?, idGenerator?, meter? }`.
|
||||
- `validateMovement(data)`: `ValidationResult` — first call seeds the
|
||||
player's reference position; subsequent calls compare against it.
|
||||
- `validateAction(action)`: `Promise<ValidationResult>` — rate-checks then
|
||||
invokes any registered action validator.
|
||||
- `checkRateLimit(playerId, actionType, timestamp)`: `ValidationResult`.
|
||||
- `registerValidator(actionType, validator)` — throws
|
||||
`GAME_SERVER.INVALID_VALIDATION_RULE` on empty `actionType`.
|
||||
- `getPlayerHistory(playerId)`, `flagPlayer`, `unflagPlayer`, `banPlayer`,
|
||||
`isPlayerBanned`, `setTick`, `getStats`, `resetStats`, `clearPlayer`,
|
||||
`dispose`.
|
||||
- `on(event, listener)` returns an unsubscribe function; `off(event,
|
||||
listener)` removes a specific listener.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Heavy data-shape work (`processFlag`, `evaluateRateLimit`,
|
||||
`classifyMovement`, `recommend`) lives in the sibling
|
||||
`anti-cheat-validator.internal.ts` so the main class stays focused on
|
||||
lifecycle, dependency injection, and event dispatch. The Euclidean
|
||||
distance helper treats missing `z` as 0 so 2D and 3D positions
|
||||
interoperate.
|
||||
|
||||
Auto-punishment dispatches into `flagPlayer`/`banPlayer` so the listener
|
||||
registry sees the corresponding `player:*` events even when escalation is
|
||||
driven internally. Temp bans expire lazily on the next `isPlayerBanned`
|
||||
call rather than via a timer to avoid scheduling drift. Statistics include
|
||||
a live `uptimeMs` computed from the injected `Clock` so deterministic
|
||||
tests can advance time without observing real wall-clock skew.
|
||||
@@ -0,0 +1,72 @@
|
||||
# interest-manager.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure geometry helpers backing `InterestManager`: Euclidean
|
||||
`vectorDistance` (z defaults to 0 for 2D interop), `zoneContains`
|
||||
discriminating on `ZoneShape` (`circular` uses radius distance,
|
||||
`rectangular` uses half-extent checks honouring optional `depth`, `custom`
|
||||
delegates to the zone-supplied `containsPoint` predicate), and
|
||||
`calcPriority` clamping a `maxPriority - distance × decay + zoneBonus`
|
||||
score into `[0, maxPriority]`.
|
||||
|
||||
Extracting these keeps `interest-manager.ts` under the per-file line
|
||||
budget and isolates the geometric primitives so they can be unit-tested
|
||||
without instantiating the manager.
|
||||
|
||||
## Features
|
||||
|
||||
- `vectorDistance(a, b)` — 3D Euclidean distance with 2D fallback.
|
||||
- `zoneContains(zone, point)` — discriminated containment test across
|
||||
circular, rectangular (optionally 3D), and custom zones.
|
||||
- `calcPriority(d, n, max, decay)` — clamped priority score with a small
|
||||
bonus per shared subscribed zone.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
vectorDistance,
|
||||
zoneContains,
|
||||
} from "./interest-manager.internal.js";
|
||||
|
||||
vectorDistance({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
|
||||
zoneContains(
|
||||
{
|
||||
id: "z",
|
||||
shape: "circular",
|
||||
center: { x: 0, y: 0 },
|
||||
radius: 5,
|
||||
priority: 1,
|
||||
},
|
||||
{ x: 4, y: 0 }
|
||||
); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { calcPriority } from "./interest-manager.internal.js";
|
||||
|
||||
// At max distance the bonus still keeps shared-zone entities visible.
|
||||
calcPriority(80, 3, 10, 0.1); // 3.5
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `vectorDistance(a: Vector3, b: Vector3): number`
|
||||
- `zoneContains(zone: InterestZone, point: Vector3): boolean`
|
||||
- `calcPriority(distance: number, sharedZoneCount: number, maxPriority:
|
||||
number, decayRate: number): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`zoneContains` treats a missing `depth` on rectangular zones as 2D
|
||||
containment (no z-check), which matches the migrated upstream behaviour
|
||||
and lets callers seamlessly mix 2D and 3D entities. The custom-zone path
|
||||
intentionally invokes the user predicate without exception trapping —
|
||||
callers are responsible for ensuring `containsPoint` is total, and any
|
||||
thrown error propagates up through `InterestManager.isInZone`. The
|
||||
priority formula gives shared-zone entities a half-unit lift per
|
||||
subscribed zone so that interesting entities don't fall out of the top-N
|
||||
slice at the edge of `viewDistance`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# interest-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InterestManager` implements area-of-interest (AOI) culling for open-world
|
||||
multiplayer games: it tracks zones, entities, and players in memory and
|
||||
returns the bounded slice of entities each player can see, ranked by
|
||||
priority. The class is the standard mechanism for shrinking broadcast
|
||||
scope from O(world) to O(perceived) updates.
|
||||
|
||||
It is dependency-injectable: a `Clock` source provides timestamps and an
|
||||
OpenTelemetry `Meter` produces visibility-calculation counters and
|
||||
duration histograms. Configuration is validated with Zod; invalid input
|
||||
raises `GAME_SERVER.INVALID_INTEREST_CONFIG`. Unknown players passed to
|
||||
`updatePlayerPosition` raise
|
||||
`GAME_SERVER.INTEREST_PLAYER_NOT_REGISTERED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Three zone shapes: circular, rectangular (optionally 3D), custom
|
||||
(predicate-based).
|
||||
- Per-player `viewDistance` override (defaults to
|
||||
`config.defaultViewDistance`).
|
||||
- Priority-weighted visibility ranking via `maxPriority - distance ×
|
||||
decay + sharedZones × 0.5` with output clamped into `[0, maxPriority]`.
|
||||
- `getVisibleEntities` slices to `maxEntitiesPerPlayer`.
|
||||
- `calculateVisibilityChanges` emits enter/exit deltas only when the set
|
||||
actually changes (returns `null` on no-op).
|
||||
- Typed event registry: `entity:added/removed`,
|
||||
`player:subscribed/unsubscribed`, `visibility:changed`.
|
||||
- OTel: `game_server.interest.visibility_calc_total` (counter),
|
||||
`game_server.interest.visibility_calc_duration_ms` (histogram).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
|
||||
|
||||
const aoi = new InterestManager({ defaultViewDistance: 50 });
|
||||
aoi.registerPlayer("p1", { x: 0, y: 0 });
|
||||
aoi.updateEntity("npc", { x: 10, y: 0 }, { kind: "merchant" });
|
||||
const visible = aoi.getVisibleEntities("p1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
|
||||
|
||||
const aoi = new InterestManager<{ name: string }>(
|
||||
{
|
||||
defaultViewDistance: 80,
|
||||
maxEntitiesPerPlayer: 100,
|
||||
priorityDecayRate: 0.05,
|
||||
maxPriority: 20,
|
||||
},
|
||||
{ clock: () => testNow }
|
||||
);
|
||||
|
||||
aoi.registerZone({
|
||||
id: "downtown",
|
||||
shape: "rectangular",
|
||||
center: { x: 0, y: 0, z: 0 },
|
||||
width: 200,
|
||||
height: 200,
|
||||
depth: 30,
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
aoi.registerPlayer("p1", { x: 1, y: 1, z: 0 });
|
||||
aoi.subscribeToZone("p1", "downtown");
|
||||
|
||||
const unsub = aoi.on("visibility:changed", (change) => {
|
||||
syncEnterExitToClient(change.playerId, change.entered, change.exited);
|
||||
});
|
||||
|
||||
// In the server tick:
|
||||
aoi.updateEntity("merchant", { x: 5, y: 5, z: 0 }, { name: "Sera" });
|
||||
aoi.calculateVisibilityChanges("p1");
|
||||
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new InterestManager(config, deps?)` — validates `config` with
|
||||
`InterestManagerConfigSchema`; throws
|
||||
`GAME_SERVER.INVALID_INTEREST_CONFIG` on failure.
|
||||
- `registerZone(zone)` / `unregisterZone(zoneId)`; throws
|
||||
`GAME_SERVER.INTEREST_REGION_INVALID` on empty `zone.id`.
|
||||
- `updateEntity(id, position, data?, type?)` / `removeEntity(id)`.
|
||||
- `registerPlayer(id, position, viewDistance?)` /
|
||||
`updatePlayerPosition(id, position)` / `unregisterPlayer(id)`.
|
||||
- `subscribeToZone(playerId, zoneId)` /
|
||||
`unsubscribeFromZone(playerId, zoneId)`.
|
||||
- `getVisibleEntities(playerId)` / `calculateVisibilityChanges(playerId)`.
|
||||
- `isInZone(zoneId, point)` / `getZonesAtPoint(point)` / `getZone` /
|
||||
`getAllZones` / `getEntity` / `getStats` / `dispose`.
|
||||
- `on(event, listener) → unsubscribe`; `off(event, listener)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Geometry primitives (`vectorDistance`, `zoneContains`, `calcPriority`)
|
||||
live in `interest-manager.internal.ts` so the main class is dominated by
|
||||
lifecycle, dispatch, and metrics emission. `getVisibleEntities` is the
|
||||
hot path; it sorts the candidate set in descending priority and slices to
|
||||
`maxEntitiesPerPlayer` so the result is always bounded. The shared-zones
|
||||
walk only checks zones the player explicitly subscribed to, so an entity
|
||||
in an unsubscribed zone is treated as if the zone were absent. Visibility
|
||||
deltas are derived against the previous result rather than a snapshot of
|
||||
the world to support incremental synchronisation. `dispose` clears every
|
||||
internal collection so the manager can be safely garbage-collected after
|
||||
shutdown.
|
||||
@@ -0,0 +1,70 @@
|
||||
# lag-compensator.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helpers backing `LagCompensator`: `RTT_SAMPLE_SIZE`, the
|
||||
`initialStats` factory, RTT-sample-window management (`pushRttSample`,
|
||||
`calcLatency`), and snapshot neighbour-lookup logic (`findNeighbors`,
|
||||
`pickClosest`).
|
||||
|
||||
Splitting these out keeps the main class focused on lifecycle, dependency
|
||||
injection, OTel emission, and event dispatch, and lets the neighbour
|
||||
search be tested independently against contrived snapshot sequences.
|
||||
|
||||
## Features
|
||||
|
||||
- `RTT_SAMPLE_SIZE` constant (20).
|
||||
- `initialStats()` factory.
|
||||
- `pushRttSample(samples, rtt, cap)` — appends and trims oldest.
|
||||
- `calcLatency(playerId, rtt, samples, now)` — average, min, max, jitter.
|
||||
- `findNeighbors(history, rewindTimestamp)` — earliest snapshot strictly
|
||||
after, latest snapshot at-or-before.
|
||||
- `pickClosest(before, after, rewind)` — closer-of-two by absolute time
|
||||
delta, ties prefer `before`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
initialStats,
|
||||
pushRttSample,
|
||||
} from "./lag-compensator.internal.js";
|
||||
|
||||
const samples = pushRttSample([], 80, 20);
|
||||
initialStats().totalLookups; // 0
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { findNeighbors, pickClosest } from "./lag-compensator.internal.js";
|
||||
|
||||
const history = [
|
||||
{ tick: 1, timestamp: 100, state: { x: 0 }, isServerState: true },
|
||||
{ tick: 2, timestamp: 200, state: { x: 5 }, isServerState: true },
|
||||
];
|
||||
const { before, after } = findNeighbors(history, 150);
|
||||
const closest = before && after ? pickClosest(before, after, 150) : before;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RTT_SAMPLE_SIZE: 20`
|
||||
- `initialStats(): LagCompensatorStats`
|
||||
- `pushRttSample(samples: number[], rtt: number, cap: number): number[]`
|
||||
- `calcLatency(playerId: string, rtt: number, samples: number[], now:
|
||||
number): PlayerLatency`
|
||||
- `findNeighbors<TState>(history, rewindTimestamp): NeighborResult<TState>`
|
||||
- `pickClosest<TState>(before, after, rewind): StateSnapshot<TState>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`pushRttSample` returns a new array each call to keep ownership semantics
|
||||
clear and allow callers to detect a size change. `calcLatency` always
|
||||
divides by the actual sample count so the first update produces sensible
|
||||
stats; `Math.min(...samples)`/`Math.max(...samples)` are safe at 20-sample
|
||||
max. `findNeighbors` walks the history once and stops as soon as the
|
||||
strictly-greater snapshot is found, so it is O(N) in worst-case but
|
||||
typically terminates within a handful of iterations for a 128-frame
|
||||
buffer. The tie-break in `pickClosest` picks the earlier snapshot to
|
||||
match canonical rollback-network semantics.
|
||||
@@ -0,0 +1,123 @@
|
||||
# lag-compensator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LagCompensator` performs server-side state rewinding for accurate hit
|
||||
registration in networked multiplayer games. It maintains a bounded
|
||||
per-entity ring buffer of `StateSnapshot` records, tracks per-player RTT
|
||||
statistics (average, min, max, jitter), and exposes a `validateHit`
|
||||
method that combines RTT compensation with snapshot interpolation to
|
||||
adjudicate hit candidates against the world state the shooter actually
|
||||
perceived at the moment of input.
|
||||
|
||||
The compensator is dependency-injectable: a `Clock` supplies timestamps
|
||||
for `recordState` defaults and latency timestamps; an OpenTelemetry
|
||||
`Meter` produces a lookup counter and a compensation-amount histogram.
|
||||
Configuration is validated with Zod at the constructor boundary; invalid
|
||||
input raises `GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG`.
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded per-entity history with `history:pruned` events on overflow.
|
||||
- Per-player RTT tracking with a 20-sample rolling window
|
||||
(`RTT_SAMPLE_SIZE`).
|
||||
- `getStateAtTime` supports exact-match, before-only, after-only,
|
||||
closest-neighbour, and lerp-interpolated lookups.
|
||||
- `validateHit` applies optional RTT compensation
|
||||
(`enableRttCompensation`), perceives the shooter and target at the
|
||||
same rewind point, runs a caller-supplied predicate, and emits
|
||||
`hit:validated` with both compensated states.
|
||||
- Compensation clamping at `maxCompensationMs` flagged on the result via
|
||||
`clamped: true`.
|
||||
- OTel: `game_server.lagcomp.lookup_total` (counter),
|
||||
`game_server.lagcomp.compensation_ms` (histogram).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LagCompensator } from "@suaveplan/game-server/enhanced/lag-compensation/lag-compensator";
|
||||
|
||||
const lc = new LagCompensator<{ x: number; y: number }>();
|
||||
lc.recordState("p1", { x: 0, y: 0 }, 1);
|
||||
lc.recordState("p2", { x: 5, y: 0 }, 1);
|
||||
const result = lc.validateHit({
|
||||
shooterId: "p1",
|
||||
targetId: "p2",
|
||||
actionTimestamp: Date.now(),
|
||||
serverTimestamp: Date.now(),
|
||||
validator: (s, t) => Math.hypot(s.x - t.x, s.y - t.y) <= 6,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LagCompensator } from "@suaveplan/game-server/enhanced/lag-compensation/lag-compensator";
|
||||
|
||||
let now = 0;
|
||||
const lc = new LagCompensator<{ x: number; y: number }>(
|
||||
{
|
||||
maxHistorySize: 256,
|
||||
maxCompensationMs: 200,
|
||||
enableRttCompensation: true,
|
||||
interpolationFactor: 1,
|
||||
},
|
||||
{ clock: () => now }
|
||||
);
|
||||
|
||||
lc.updateRtt("shooter", 80);
|
||||
|
||||
for (let t = 0; t < 10; t++) {
|
||||
now = t * 16;
|
||||
lc.recordState("shooter", { x: t, y: 0 }, t);
|
||||
lc.recordState("target", { x: t * 2, y: 0 }, t);
|
||||
}
|
||||
|
||||
const unsub = lc.on("hit:validated", (r) => audit.push(r));
|
||||
|
||||
const result = lc.validateHit(
|
||||
{
|
||||
shooterId: "shooter",
|
||||
targetId: "target",
|
||||
actionTimestamp: now - 50,
|
||||
serverTimestamp: now,
|
||||
validator: (s, t) => Math.hypot(s.x - t.x, s.y - t.y) <= 10,
|
||||
},
|
||||
(a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t })
|
||||
);
|
||||
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new LagCompensator(config?, deps?)` — validates `config` with
|
||||
`LagCompensatorConfigSchema`; throws
|
||||
`GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG` on failure.
|
||||
- `recordState(entityId, state, tick, timestamp = clock())`.
|
||||
- `updateRtt(playerId, rtt)` / `getPlayerLatency(playerId)`.
|
||||
- `getStateAtTime(entityId, targetTimestamp, currentTimestamp = clock(),
|
||||
lerp?)`.
|
||||
- `validateHit(request, lerp?)`.
|
||||
- `getTrackedEntities()`, `clearEntityHistory(entityId)`,
|
||||
`removePlayer(playerId)`, `getStats()`, `resetStats()`, `dispose()`.
|
||||
- `on(event, listener) → unsubscribe`; `off(event, listener)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interpolation factor is applied symmetrically as
|
||||
`f × raw + (1 − f) × raw`, identical to the upstream behaviour even
|
||||
though it collapses algebraically — kept verbatim so engines tuning
|
||||
`interpolationFactor` for visual smoothing match validation results.
|
||||
Tied neighbour timestamps prefer the earlier snapshot in `pickClosest`
|
||||
to match canonical rollback-network semantics. `validateHit` halves the
|
||||
shooter's RTT for compensation under the standard one-way-latency
|
||||
assumption; disabling `enableRttCompensation` lets callers run their own
|
||||
network model. The lookup counter tags `{clamped: "true" | "false"}` so
|
||||
observability dashboards can separate compensated-but-bounded queries
|
||||
from edge cases that hit the cap. `dispose` clears every collection and
|
||||
resets statistics so the compensator can be safely garbage-collected.
|
||||
@@ -0,0 +1,113 @@
|
||||
# LeaderboardManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LeaderboardManager` provides multi-leaderboard score tracking for game
|
||||
servers with three independent ranking modes — raw score, win-count, and
|
||||
Elo. Each leaderboard is configured separately, sorted independently, and
|
||||
capped at a configurable max-entry size that evicts the lowest-ranked
|
||||
entries to keep memory bounded under unbounded submission volume.
|
||||
|
||||
The manager injects its `Clock` so deterministic tests and seeded replays
|
||||
can advance time without resorting to fake timers, and emits OTel counters
|
||||
(`game_server.leaderboard.submit_total`, `game_server.leaderboard.eviction_total`)
|
||||
so production deployments can monitor submission throughput and eviction
|
||||
pressure without bespoke instrumentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Three ranking modes: `score`, `wins`, `elo`.
|
||||
- Ascending (golf) or descending (default) sort.
|
||||
- Tie-break by secondary score.
|
||||
- Per-board `maxEntries` cap with rank-based eviction.
|
||||
- Elo update with configurable K-factor.
|
||||
- Injectable `Clock` for deterministic time.
|
||||
- Stable `GAME_SERVER.*` error codes (`LEADERBOARD_NOT_FOUND`,
|
||||
`INVALID_LEADERBOARD_CONFIG`, `INVALID_SCORE_SUBMISSION`,
|
||||
`LEADERBOARD_ENTRY_INVALID`).
|
||||
- Zod-validated configs and submissions.
|
||||
- OTel `Counter` instrumentation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LeaderboardManager } from "@suaveplan/game-server/enhanced/leaderboard/leaderboard-manager";
|
||||
|
||||
const lm = new LeaderboardManager();
|
||||
lm.createLeaderboard({ id: "global", rankingMode: "score" });
|
||||
lm.submitScore("global", {
|
||||
playerId: "alice",
|
||||
displayName: "Alice",
|
||||
score: 4200,
|
||||
});
|
||||
const top = lm.getTopN("global", 10);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LeaderboardManager } from "@suaveplan/game-server/enhanced/leaderboard/leaderboard-manager";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const lm = new LeaderboardManager<{ region: string }>(
|
||||
{ defaultMaxEntries: 500, defaultEloKFactor: 24, defaultInitialElo: 1500 },
|
||||
{ clock: () => now }
|
||||
);
|
||||
|
||||
lm.createLeaderboard({
|
||||
id: "ranked-1v1",
|
||||
rankingMode: "elo",
|
||||
maxEntries: 1000,
|
||||
eloKFactor: 32,
|
||||
});
|
||||
|
||||
lm.submitScore("ranked-1v1", {
|
||||
playerId: "alice",
|
||||
displayName: "Alice",
|
||||
score: 0,
|
||||
isWin: true,
|
||||
opponentElo: 1450,
|
||||
metadata: { region: "eu-west" },
|
||||
});
|
||||
|
||||
now += 60_000;
|
||||
const entry = lm.getEntry("ranked-1v1", "alice");
|
||||
const stats = lm.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new LeaderboardManager(config?, deps?)` — `deps.clock?: Clock`, `deps.meter?: Meter`.
|
||||
- `createLeaderboard(config: LeaderboardConfig): LeaderboardConfig`
|
||||
- `deleteLeaderboard(id: string): boolean`
|
||||
- `submitScore(id, submission): LeaderboardEntry`
|
||||
- `getTopN(id, n): readonly LeaderboardEntry[]`
|
||||
- `getEntry(id, playerId): LeaderboardEntry | undefined`
|
||||
- `removeEntry(id, playerId): boolean`
|
||||
- `resetLeaderboard(id): boolean`
|
||||
- `listLeaderboards(): readonly LeaderboardConfig[]`
|
||||
- `getStats(): LeaderboardStats`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Ranks are recomputed in full on every submission. For the configured
|
||||
ceiling of 1000 entries this is `O(N log N)` per write and stays well
|
||||
under a millisecond on commodity hardware; if profiles show this becoming
|
||||
a bottleneck, the sort can be swapped for an order-statistic tree without
|
||||
changing the public surface.
|
||||
|
||||
The Elo update follows the standard logistic formulation with a 400-point
|
||||
spread and rounding to the nearest integer rating. K-factor is per-board
|
||||
so high-volatility competitive ladders can run alongside calibration
|
||||
boards with low K. Submissions without an `opponentElo` are treated as
|
||||
unranked sessions and do not alter the Elo rating, which avoids polluting
|
||||
ladders with practice games or AI matches.
|
||||
|
||||
Eviction operates on rank rather than insertion order so the cap reflects
|
||||
the leaderboard's intent ("top 1000 players") rather than churn. The
|
||||
`eviction_total` counter is tagged by `leaderboardId` so per-board churn
|
||||
is observable in production dashboards without code changes.
|
||||
@@ -0,0 +1,100 @@
|
||||
# GamePersistence
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GamePersistence` persists game state to a pluggable key-value backend via
|
||||
the `StorageLike` structural contract from `@suaveplan/types`. State, named
|
||||
snapshots, and a bounded audit-trail history are serialised together as a
|
||||
UTF-8 JSON document under a deterministic key (`game-server:save:<gameId>:<id>`),
|
||||
allowing any conformant `AbstractStorage` adapter — in-memory, SurrealDB,
|
||||
SQLite, S3 — to back the engine without code changes.
|
||||
|
||||
The manager exposes async `save`, `load`, `delete`, `createSnapshot`, and
|
||||
`restoreSnapshot` operations that throw stable `GAME_SERVER.*` errors,
|
||||
plus an OTel save-duration histogram and counter pair so production
|
||||
throughput is observable without bespoke instrumentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Pluggable `StorageLike` backend (in-memory, durable, network).
|
||||
- Named snapshots with rotating `maxSnapshots` cap.
|
||||
- Bounded audit-trail history with `maxHistoryEntries` cap.
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic tests.
|
||||
- Stable error codes: `PERSISTENCE_SAVE_FAILED`, `PERSISTENCE_LOAD_FAILED`,
|
||||
`PERSISTENCE_NOT_FOUND`, `INVALID_SAVE_RECORD`.
|
||||
- OTel counters + duration histogram.
|
||||
- Tracks `currentSaveId` for snapshot context.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GamePersistence } from "@suaveplan/game-server/enhanced/persistence/game-persistence";
|
||||
|
||||
const gp = new GamePersistence<MyState>(
|
||||
{ gameId: "chess" },
|
||||
{ storage: createSurrealStorage() }
|
||||
);
|
||||
|
||||
const result = await gp.save(state, { name: "quicksave" });
|
||||
const loaded = await gp.load(result.id!);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GamePersistence } from "@suaveplan/game-server/enhanced/persistence/game-persistence";
|
||||
|
||||
const gp = new GamePersistence<MyState>(
|
||||
{ gameId: "chess", maxSnapshots: 50, maxHistoryEntries: 200 },
|
||||
{
|
||||
storage,
|
||||
clock: () => now,
|
||||
idGenerator: () => `save-${++counter}`,
|
||||
meter,
|
||||
}
|
||||
);
|
||||
|
||||
const save = await gp.save(state, { name: "checkpoint", tags: ["wave-4"] });
|
||||
const snap = await gp.createSnapshot(state, "before-boss", "auto");
|
||||
const restored = await gp.restoreSnapshot(save.id!, snap.id);
|
||||
const history = await gp.getHistory(save.id!);
|
||||
const stats = gp.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new GamePersistence(config, deps)` — `deps.storage: StorageLike`.
|
||||
- `save(state, input): Promise<SaveResult>`
|
||||
- `load(id): Promise<LoadResult>`
|
||||
- `delete(id): Promise<boolean>`
|
||||
- `exists(id): Promise<boolean>`
|
||||
- `createSnapshot(state, name, reason?): Promise<Snapshot>`
|
||||
- `restoreSnapshot(saveId, snapshotId): Promise<LoadResult>`
|
||||
- `getHistory(saveId): Promise<readonly HistoryEntry[]>`
|
||||
- `getStats(): PersistenceStats`
|
||||
- `getCurrentSaveId(): string | null`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
State is encoded as UTF-8 JSON via `TextEncoder`/`TextDecoder` so the
|
||||
on-disk shape is debuggable with any text viewer; binary-only stores will
|
||||
treat the payload as opaque bytes. For game states with large embedded
|
||||
binaries (images, audio), callers should chunk those out to a separate
|
||||
asset store and reference them by id in the persisted state to avoid
|
||||
inflating the JSON payload.
|
||||
|
||||
Storage keys are namespaced by both `gameId` and the per-save id so a
|
||||
single shared backend can host saves for many concurrent game instances
|
||||
without prefix collisions. The `currentSaveId` cursor is in-memory only —
|
||||
restarting the process requires an explicit `load()` before
|
||||
`createSnapshot()` will succeed.
|
||||
|
||||
JSON decode failures are mapped to `PERSISTENCE_LOAD_FAILED` rather than
|
||||
silently returning `null`, so corrupted storage surfaces as an
|
||||
inspectable error chain instead of an empty save list. Save failures
|
||||
preserve their underlying cause through `GameServerError.cause`, making
|
||||
storage-side diagnostics directly available to the caller.
|
||||
@@ -0,0 +1,104 @@
|
||||
# ReplayPlayer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ReplayPlayer` plays back a `ReplayData<TState, TInput>` document produced
|
||||
by `ReplayRecorder`. It exposes the controls expected of a media player —
|
||||
play, pause, stop, step forward/backward, seek, speed — but is driven by
|
||||
explicit `tick()` calls rather than a timer, so playback is fully
|
||||
deterministic and trivially exercised in tests or headless replay rigs.
|
||||
|
||||
The player delivers replayed inputs through observer subscriptions
|
||||
(`onInput`, `onSnapshotRestore`) so consumer engines stay in control of
|
||||
their own dispatch pipeline. OTel counters track `playback_advance_total`
|
||||
and `playback_seek_total` so dashboards can monitor replay throughput in
|
||||
production replay-analysis workloads.
|
||||
|
||||
## Features
|
||||
|
||||
- Tick-driven, deterministic playback.
|
||||
- Speeds: `0.25 / 0.5 / 1 / 2 / 4` with Zod-enforced enum.
|
||||
- Snapshot-aware seek and stepBackward.
|
||||
- Loop mode for highlight loops or attract screens.
|
||||
- Observer-style input + snapshot listeners (no global emitter).
|
||||
- Stable error codes: `REPLAY_NOT_LOADED`, `REPLAY_PARSE_FAILED`,
|
||||
`REPLAY_INVALID_SPEED`.
|
||||
- OTel counters tagged per advance / seek.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ReplayPlayer } from "@suaveplan/game-server/enhanced/replay/replay-player";
|
||||
|
||||
const player = new ReplayPlayer<GameState, GameInput>();
|
||||
player.load(replay);
|
||||
player.onInput((input) => engine.apply(input.data, input.playerId));
|
||||
player.play();
|
||||
|
||||
setInterval(() => player.tick(), 1000 / 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ReplayPlayer } from "@suaveplan/game-server/enhanced/replay/replay-player";
|
||||
|
||||
const player = new ReplayPlayer<GameState, GameInput>(
|
||||
{ loop: true, initialSpeed: 2 }
|
||||
);
|
||||
player.load(replay);
|
||||
|
||||
const offInput = player.onInput((i) => engine.apply(i.data, i.playerId));
|
||||
const offSnap = player.onSnapshotRestore((state) => engine.replace(state));
|
||||
|
||||
player.play();
|
||||
for (let i = 0; i < replay.metadata.totalTicks * 4; i += 1) player.tick();
|
||||
|
||||
player.seekToTick(replay.metadata.totalTicks / 2);
|
||||
player.setSpeed(0.5);
|
||||
player.stepBackward();
|
||||
|
||||
offInput();
|
||||
offSnap();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ReplayPlayer(config?, deps?)`
|
||||
- `load(replay): void`
|
||||
- `play(): void` / `pause(): void` / `stop(): void`
|
||||
- `tick(): void`
|
||||
- `stepForward(): void` / `stepBackward(): void`
|
||||
- `seekToTick(target): void`
|
||||
- `setSpeed(speed: PlaybackSpeed): void`
|
||||
- `onInput(listener): () => void`
|
||||
- `onSnapshotRestore(listener): () => void`
|
||||
- `getPosition(): PlaybackPosition`
|
||||
- `getMetadata(): ReplayMetadata | null`
|
||||
- `getStatus(): PlaybackStatus` / `getSpeed(): PlaybackSpeed`
|
||||
- `getCurrentState(): TState | null` / `isLoaded(): boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Playback advancement multiplies `speed` into the number of ticks consumed
|
||||
per `tick()` call, which yields exact rather than approximate replay speed
|
||||
on every callable cadence (60Hz, 30Hz, single-step debugging). Speeds
|
||||
outside the canonical set are rejected via Zod-validated config and an
|
||||
explicit `REPLAY_INVALID_SPEED` error at `setSpeed` time.
|
||||
|
||||
Seek walks the recorded snapshots backwards to find the most-recent
|
||||
snapshot at or before the target tick, restores from it, then advances
|
||||
input-by-input to the requested tick. With no snapshots, the player falls
|
||||
back to the initial state — slower but always correct. Engines that need
|
||||
snappy scrubbing should record interval snapshots at a reasonable rate
|
||||
(60–300 ticks).
|
||||
|
||||
End-of-replay handling is split between `loop=true` (rewinds and keeps
|
||||
playing) and `loop=false` (transitions to `ended`). The transition to
|
||||
`ended` is sticky — further `tick()` calls remain a no-op until `play()`
|
||||
or `seekToTick()` rewinds the cursor, which prevents accidental input
|
||||
re-emission from off-by-one tick loops.
|
||||
@@ -0,0 +1,105 @@
|
||||
# ReplayRecorder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ReplayRecorder` captures gameplay into a deterministic, replay-ready
|
||||
`ReplayData<TState, TInput>` document. It records inputs with monotonic
|
||||
sequence numbers, takes periodic state snapshots at a configurable tick
|
||||
interval, and tracks paused intervals so the resulting timeline reflects
|
||||
actual elapsed gameplay rather than wall-clock duration.
|
||||
|
||||
The recorder injects its `Clock` and `IdGenerator` so deterministic tests
|
||||
can produce byte-identical replay files run after run, and emits OTel
|
||||
counters for input and snapshot volume so production replay-capture rates
|
||||
are observable. The output document is the input contract for
|
||||
`ReplayPlayer` — record on one machine, ship the JSON, play back anywhere.
|
||||
|
||||
## Features
|
||||
|
||||
- Status FSM: `idle → recording → paused → stopped`.
|
||||
- Per-input monotonic `sequence` counters.
|
||||
- Auto-snapshot every N ticks plus manual snapshots.
|
||||
- Pause/resume that excludes paused intervals from duration.
|
||||
- Tags and free-form metadata attached at stop time.
|
||||
- Stable error codes: `REPLAY_RECORDING_STATE_INVALID`,
|
||||
`REPLAY_PARSE_FAILED`.
|
||||
- OTel counters tagged by snapshot reason.
|
||||
- Injectable `Clock` + `IdGenerator`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ReplayRecorder } from "@suaveplan/game-server/enhanced/replay/replay-recorder";
|
||||
|
||||
const rec = new ReplayRecorder<GameState, GameInput>();
|
||||
rec.start(initial);
|
||||
rec.recordInput({ dx: 1 }, "alice");
|
||||
rec.tick(current);
|
||||
const replay = rec.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ReplayRecorder } from "@suaveplan/game-server/enhanced/replay/replay-recorder";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
let counter = 0;
|
||||
const rec = new ReplayRecorder<GameState, GameInput>(
|
||||
{ snapshotInterval: 60, maxInputBuffer: 50_000 },
|
||||
{
|
||||
clock: () => now,
|
||||
idGenerator: () => `r-${++counter}`,
|
||||
}
|
||||
);
|
||||
|
||||
rec.addTag("ranked");
|
||||
rec.setMetadata({ matchId: "match-42", seed: 0xdeadbeef });
|
||||
rec.start(initial);
|
||||
|
||||
for (const event of stream) {
|
||||
rec.recordInput(event, event.playerId);
|
||||
rec.tick(currentState);
|
||||
}
|
||||
|
||||
const replay = rec.stop();
|
||||
// replay.version === 1, deterministic if (clock, idGenerator, inputs) match
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ReplayRecorder(config?, deps?)`
|
||||
- `start(initialState): void`
|
||||
- `pause(): void` / `resume(): void`
|
||||
- `recordInput(input, playerId?): void`
|
||||
- `recordSnapshot(state, reason?): RecordedSnapshot`
|
||||
- `tick(currentState?): void`
|
||||
- `addTag(tag): void` / `setMetadata(extra): void`
|
||||
- `stop(): ReplayData`
|
||||
- `reset(): void`
|
||||
- `getStats(): RecordingStats` / `getStatus(): RecordingStatus`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Pause handling is deliberately exclusive: the elapsed-time formula uses
|
||||
`pauseTime` while paused and `clock()` otherwise, so the duration reported
|
||||
during pause never advances. Resumption tallies the paused interval into
|
||||
`totalPausedTime` and is subtracted from all subsequent elapsed lookups,
|
||||
yielding a timeline that matches "active gameplay" not wall clock.
|
||||
|
||||
Snapshot intervals operate on tick count rather than wall-clock time so a
|
||||
60Hz game running at 30Hz under load still produces snapshots at
|
||||
predictable gameplay milestones. The auto-snapshot path skips when no
|
||||
state is provided to `tick()`, allowing pure-input replay capture for
|
||||
deterministic engines where the player can reconstruct state from inputs
|
||||
alone.
|
||||
|
||||
Input buffer overflow surfaces as a `GameServerError` rather than silent
|
||||
truncation so a runaway client can be detected at capture time. Callers
|
||||
that prefer back-pressure can wrap `recordInput` in their own throttle;
|
||||
the default `maxInputBuffer` of 10_000 is enough for ~15 minutes of dense
|
||||
60Hz inputs from a single player.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Registry — internal helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helpers extracted from `GameInstanceRegistry` so the orchestrator
|
||||
class stays inside the per-file budget. The helpers cover the two big
|
||||
chunks of stateless logic: applying every filter dimension to a
|
||||
candidate list, and dispatching one of the four load-balancing
|
||||
strategies.
|
||||
|
||||
## Features
|
||||
|
||||
- `applyFilter` — ten-dimension filter pipeline (health, type, region,
|
||||
mode, map, slot count, max count, privacy, tags, version).
|
||||
- `applyStrategy` — switch over `least-players`, `random`,
|
||||
`region-based`, `round-robin`. The class supplies its `rng`,
|
||||
`regionLatencies`, and `roundRobinIndex` references.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { applyFilter } from "./game-instance-registry.internal.js";
|
||||
const healthy = applyFilter(instances, { healthyOnly: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { applyStrategy } from "./game-instance-registry.internal.js";
|
||||
|
||||
const result = applyStrategy(
|
||||
candidates,
|
||||
"region-based",
|
||||
rng,
|
||||
regionLatencyMap,
|
||||
roundRobinIndex,
|
||||
/* preferredRegion */ "us-east"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyFilter<TCustom>(instances, filter): GameInstance<TCustom>[]`.
|
||||
- `applyStrategy<TCustom>(instances, strategy, rng, regionLatencies, roundRobinIndex, preferredRegion?): { instance, score }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The healthy-instance filter is applied first because in a real
|
||||
deployment most candidates are unhealthy at the moment of an outage,
|
||||
and short-circuiting the rest of the chain saves work on the long
|
||||
tail of subsequent dimension filters.
|
||||
- `random` prefers `Rng.nextIntInRange` when present and falls back to
|
||||
`Math.floor(rng.nextFloat() * len)`. This mirrors the contract spelled
|
||||
out by `@suaveplan/types`'s `Rng`, which marks `nextIntInRange` as
|
||||
optional but encourages CSPRNG callers to provide it for unbiased
|
||||
rejection sampling.
|
||||
- `region-based` falls back to a 100 ms default latency for unknown
|
||||
regions — same as the upstream baseline. The fallback keeps the
|
||||
weighting deterministic when a deployment grows into a new region
|
||||
before the operator updates the latency map.
|
||||
- `round-robin` keys the cursor on a sorted join of instance IDs, so
|
||||
changing the candidate set does not silently reset the rotation.
|
||||
The cursor lives on the orchestrator class instance (not in
|
||||
module-level state) so two registries can run round-robin in
|
||||
parallel without aliasing each other.
|
||||
- Every function is pure: callers thread their own `rng`,
|
||||
`regionLatencies`, and `roundRobinIndex` references in. That is
|
||||
what lets the orchestrator class stay under the file-size budget
|
||||
while keeping the helpers test-friendly in isolation.
|
||||
@@ -0,0 +1,119 @@
|
||||
# `GameInstanceRegistry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameInstanceRegistry` is the in-memory primitive that tracks every live
|
||||
game-server instance in a multi-host deployment. It owns the canonical
|
||||
`{id → instance}` map, evicts stale entries whose last heartbeat is older
|
||||
than `instanceTTL`, and selects one healthy instance per discovery
|
||||
request using one of four load-balancing strategies. Transport façades
|
||||
(HTTP/gRPC) that surface the registry over the network live in sibling
|
||||
packages — this module is the pure data structure plus its lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- TTL-based eviction driven by an injected `Clock` (no implicit `Date.now`).
|
||||
- Four load-balancing strategies: `least-players`, `random`, `region-based`,
|
||||
`round-robin`. The `random` strategy consults an injected `Rng`.
|
||||
- Filter-based `discover()`: by game type, region, mode, map, slot count,
|
||||
privacy, tags, and version.
|
||||
- Lifecycle events streamed through a single fan-in listener:
|
||||
`registered`, `deregistered`, `health-changed`, `capacity-changed`,
|
||||
`full`, `available`.
|
||||
- Zod-validated `register()` boundary; every public mutation routes to a
|
||||
typed `GameServerError` on lookup miss.
|
||||
- One OTel counter (`game_server.registry.instance_lifecycle_total`)
|
||||
tagged with `op ∈ {register, deregister, expire, health, capacity}`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameInstanceRegistry } from "@suaveplan/game-server/enhanced/service-discovery/game-instance-registry";
|
||||
|
||||
const registry = new GameInstanceRegistry({
|
||||
clock: Date.now,
|
||||
rng: { nextUint32: () => 0, nextFloat: Math.random },
|
||||
});
|
||||
|
||||
registry.register({
|
||||
id: "i-001",
|
||||
name: "us-east-shard-1",
|
||||
host: "10.0.0.1",
|
||||
port: 4000,
|
||||
metadata: {
|
||||
gameType: "duel",
|
||||
region: "us-east",
|
||||
playerCount: 0,
|
||||
maxPlayers: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const selected = registry.selectInstance(
|
||||
{ gameType: "duel", minAvailableSlots: 2 },
|
||||
"least-players"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const off = registry.on((event) => {
|
||||
if (event.kind === "full") notifyMatchmaker(event.instance.id);
|
||||
if (event.kind === "health-changed" && event.newStatus === "unhealthy") {
|
||||
drainSessionsFrom(event.instance.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Region-weighted selection — pass a latency map for cold start.
|
||||
const r = new GameInstanceRegistry(
|
||||
{ clock, rng },
|
||||
{
|
||||
instanceTTL: 30_000,
|
||||
defaultStrategy: "region-based",
|
||||
regionLatencies: new Map([
|
||||
["us-east", 25],
|
||||
["eu-west", 95],
|
||||
["ap-southeast", 180],
|
||||
]),
|
||||
autoDeregisterEmpty: true,
|
||||
}
|
||||
);
|
||||
|
||||
setInterval(() => r.sweep(), 5_000);
|
||||
off();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new GameInstanceRegistry(deps, config?)` — `deps: { clock, rng }`,
|
||||
`config: { instanceTTL?, defaultStrategy?, autoDeregisterEmpty?, regionLatencies? }`.
|
||||
- `register(registration)` — validate + insert; emits `registered`.
|
||||
- `deregister(id)` — remove; emits `deregistered`. Throws on unknown id.
|
||||
- `heartbeat(id)` — refresh `lastHeartbeat`.
|
||||
- `updateHealth(id, status)` — transition health; emits `health-changed`.
|
||||
- `updateCapacity({ instanceId, playerCount, maxPlayers? })` — emits
|
||||
`capacity-changed`, plus `full` or `available` at the boundary.
|
||||
- `discover(filter?)` — filter healthy instances.
|
||||
- `selectInstance(filter?, strategy?)` — pick one or `null`.
|
||||
- `getInstance(id)` — direct lookup, or `null`.
|
||||
- `getStats()` — aggregate snapshot.
|
||||
- `sweep()` — force the TTL pass (also invoked by `discover`).
|
||||
- `on(listener)` — subscribe to lifecycle events, returns an unsubscribe.
|
||||
- `dispose()` — clear all internal state.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Eviction runs in two phases: at `age > instanceTTL` the instance is
|
||||
marked `unhealthy`; at `age > instanceTTL * 2` it is removed. This
|
||||
preserves the upstream semantics where downstream consumers had one
|
||||
full TTL window to fail their own probes.
|
||||
- `random` prefers the optional `Rng.nextIntInRange` and falls back to
|
||||
`Math.floor(rng.nextFloat() * len)` when only `nextFloat` is provided.
|
||||
- `round-robin` keys on a sorted join of instance IDs so adding /
|
||||
removing instances does not silently reset the rotation.
|
||||
- The lifecycle counter is registered through the global OTel meter so
|
||||
no SDK wiring is needed in tests.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `BitPackerStateSync`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BitPackerStateSync` is the byte-packed state-synchronisation primitive
|
||||
for low-bandwidth, high-frequency multiplayer updates. It packs game
|
||||
state to `Uint8Array` via a caller-supplied `@suaveplan/bitpacker`
|
||||
`BitPacker`, maintains a bounded client-side prediction history, supports
|
||||
server-authoritative reconciliation with input replay above the
|
||||
server-acknowledged tick, and linearly interpolates between two history
|
||||
frames for smooth rendering. It coexists with the JSON-delta
|
||||
`state/state-sync` from wave B-3 — the JSON path is for human-readable
|
||||
debugging, this path is for the wire.
|
||||
|
||||
## Features
|
||||
|
||||
- Pack / unpack via the caller's pre-configured `BitPacker` (strategies
|
||||
registered by the caller — gives full control over schema dialect).
|
||||
- Client-side prediction with bounded history (`maxPredictionFrames`).
|
||||
- Server reconciliation: replays every pending input with `tick > serverTick`
|
||||
through the caller's `simulate` function, then compares to the server
|
||||
state via a caller-supplied `compare` function. Below
|
||||
`reconciliationThreshold` the result is a no-op.
|
||||
- Linear interpolation between two history frames bracketing the
|
||||
delayed render time. Falls back to the latest frame when no bracket
|
||||
exists.
|
||||
- All pack / unpack failures route through `GameServerError` with
|
||||
`BITPACK_ENCODE_FAILED` / `BITPACK_DECODE_FAILED`.
|
||||
- One counter (`game_server.state.bitpack_delta_total`) and one
|
||||
histogram (`game_server.state.bitpack_bytes`) report wire activity.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BitPacker } from "@suaveplan/bitpacker";
|
||||
import { registerDefaultStrategies } from "@suaveplan/bitpacker-strategies";
|
||||
import { BitPackerStateSync } from "@suaveplan/game-server/enhanced/state-sync/bitpacker-state-sync";
|
||||
|
||||
const packer = new BitPacker();
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
|
||||
const sync = new BitPackerStateSync<{ x: number; y: number }>(
|
||||
{ clock: Date.now },
|
||||
{ packer, schema: { x: 255, y: 255 } }
|
||||
);
|
||||
|
||||
const msg = sync.packState({ x: 100, y: 50 });
|
||||
// transmit msg.payload over WebSocket / WebRTC
|
||||
const restored = sync.unpackState(msg);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Client-side prediction + reconciliation loop
|
||||
const simulate = (s: World, input: Input) => applyInput(s, input);
|
||||
const compare = (a: World, b: World) => squaredDistance(a, b);
|
||||
|
||||
sync.tick();
|
||||
const predicted = sync.predict(currentWorld, localInput, simulate);
|
||||
renderWorld(predicted);
|
||||
|
||||
onServerSnapshot((snapshot, serverTick) => {
|
||||
const result = sync.reconcile(snapshot.state, serverTick, simulate, compare);
|
||||
if (result.needed) snapToServerCorrected(result.state, result.framesResimulated);
|
||||
});
|
||||
|
||||
// Interpolation for remote entities (lerp between two server frames)
|
||||
const visible = sync.interpolate(serverHistory, performance.now(), lerpWorld);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new BitPackerStateSync({ clock }, config)` —
|
||||
`config: { packer, schema?, enablePrediction?, enableInterpolation?, enableReconciliation?, interpolationDelayTicks?, tickDurationMs?, maxPredictionFrames?, reconciliationThreshold? }`.
|
||||
- `tick() / getTick() / setTick(value)` — logical tick management.
|
||||
- `packState(state) → StateSyncMessage` — kind `"state:delta"`.
|
||||
- `packFullSnapshot(state) → StateSyncMessage` — kind `"state:full"`.
|
||||
- `unpackState(message) → TState`.
|
||||
- `predict(state, input, simulate) → TState`.
|
||||
- `reconcile(serverState, serverTick, simulate, compare) → ReconciliationResult`.
|
||||
- `interpolate(states, renderTime, lerp) → TState | null`.
|
||||
- `getStats() / resetStats()`.
|
||||
- `getHistory() / getPendingInputs() / getLastServerState()`.
|
||||
- `dispose()` — release internal buffers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `BitPacker` instance is supplied by the caller because strategy
|
||||
registration is application-specific — different games pack
|
||||
different field types, and the strategies package owns the registry.
|
||||
- History is trimmed to `maxPredictionFrames` (not 2×) once it exceeds
|
||||
the `2 × maxPredictionFrames` threshold; this is the upstream rule
|
||||
(small grace before trim).
|
||||
- `interpolate` derives its delay as `interpolationDelayTicks * tickDurationMs`,
|
||||
which lets callers configure the visual smoothness independent of
|
||||
the network sync interval.
|
||||
- Counters and histograms are registered against the global OTel meter
|
||||
named `@suaveplan/game-server` at version `0.1.0` — consistent with
|
||||
every other game-server instrument.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Vector-clock sync — internal helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
This module factors out the pure helpers that drive
|
||||
`VectorClockSync` — happens-before / concurrent predicates, conflict
|
||||
resolution, causality sort, concurrent-group detection, and
|
||||
missing-action computation. Pulling them out of the class keeps
|
||||
`vector-clock-sync.ts` under the per-file budget and lets the helpers be
|
||||
exercised directly by tests without standing the full class up.
|
||||
|
||||
## Features
|
||||
|
||||
- `happenedBefore` / `areConcurrent` — vector-clock predicates that
|
||||
delegate to `vectorClockCompare` from `@suaveplan/timing`.
|
||||
- `resolveConflict` — strategy selection (`custom` → `merge` →
|
||||
`last-write-wins`) with the same precedence as the upstream sync.
|
||||
- `sortByCausality` — stable happens-before sort with timestamp tie
|
||||
breaker.
|
||||
- `findConcurrentGroups` — dedup by group identity (set equality).
|
||||
- `findMissingActions` — pool filter against a remote clock state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { happenedBefore } from "./vector-clock-sync.internal.js";
|
||||
const earlier = happenedBefore(a, b);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
findMissingActions,
|
||||
resolveConflict,
|
||||
} from "./vector-clock-sync.internal.js";
|
||||
|
||||
const conflict = resolveConflict(
|
||||
incoming,
|
||||
[conflictingLocal],
|
||||
/* conflictResolver */ undefined,
|
||||
/* eventMerger */ (a, b) => ({ ...a, ...b })
|
||||
);
|
||||
const missing = findMissingActions(pool, theirClock);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `happenedBefore<T>(a, b): boolean`
|
||||
- `areConcurrent<T>(a, b): boolean`
|
||||
- `resolveConflict<T>(action, conflicts, conflictResolver?, eventMerger?): ConflictResolution<T>`
|
||||
- `sortByCausality<T>(actions): GameAction<T>[]`
|
||||
- `findConcurrentGroups<T>(actions): GameAction<T>[][]`
|
||||
- `findMissingActions<T>(pool, theirClock): GameAction<T>[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `resolveConflict` returns `conflictDetected: true` whenever the
|
||||
`conflicts` array is non-empty, even if the chosen winner is the
|
||||
original action (this matches upstream semantics and is what the
|
||||
outer class uses to decide whether to emit metrics).
|
||||
- `sortByCausality` is stable for concurrent actions because the
|
||||
comparator falls through to the wall-clock timestamp difference.
|
||||
- `findConcurrentGroups` deduplicates by membership equality, not
|
||||
reference equality, so two iterations that reach the same group
|
||||
through different seed actions collapse to one entry.
|
||||
@@ -0,0 +1,101 @@
|
||||
# `VectorClockSync`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`VectorClockSync` is the distributed event-ordering primitive used by
|
||||
multiplayer game sessions to decide whether two actions are causally
|
||||
ordered or concurrent — the basis for deterministic replay, conflict
|
||||
resolution and resync after partition. It builds on
|
||||
`@suaveplan/timing`'s `createVectorClock` so all clock arithmetic lives
|
||||
in one place and the game-server module just records actions, tags
|
||||
them with the current snapshot, and decides what to do when two clocks
|
||||
are incomparable.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-session bookkeeping (`pendingActions`, `confirmedActions`,
|
||||
participants, session vector clock).
|
||||
- Three conflict-resolution strategies, dispatched in this order:
|
||||
`custom` (resolver supplied) → `merge` (merger supplied AND a single
|
||||
concurrent conflict) → `last-write-wins`.
|
||||
- Happens-before and concurrent predicates exposed both as instance
|
||||
methods and as standalone helpers in `vector-clock-sync.internal.ts`.
|
||||
- `requestSync` / `handleSyncRequest` / `applySyncResponse` cover the
|
||||
three legs of a pull-style sync round-trip.
|
||||
- Action history bounded by `maxActionHistory`, with FIFO eviction.
|
||||
- Injectable `Clock` and `IdGenerator` everywhere a timestamp / id is
|
||||
minted (no implicit `Date.now` / `Math.random`).
|
||||
- Two OTel counters: `game_server.vector_clock.actions_total` and
|
||||
`game_server.vector_clock.conflicts_total`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VectorClockSync } from "@suaveplan/game-server/enhanced/timing/vector-clock-sync";
|
||||
|
||||
const sync = new VectorClockSync(
|
||||
{ clock: Date.now, idGenerator: () => crypto.randomUUID() },
|
||||
{ nodeId: "player-1" }
|
||||
);
|
||||
|
||||
const action = sync.recordAction("session-1", "move", { x: 10 }, /* tick */ 4);
|
||||
broadcast(action);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// On the receiving side
|
||||
const result = sync.receiveAction(remoteAction);
|
||||
if (result?.conflictDetected) {
|
||||
metrics.recordConflict(result.strategy);
|
||||
applyChosen(result.resolvedAction);
|
||||
}
|
||||
|
||||
// Pull-based sync round-trip after reconnect
|
||||
const request = sync.requestSync("player-2", "session-1");
|
||||
sendOverWire(request);
|
||||
|
||||
// On the peer
|
||||
const response = peerSync.handleSyncRequest(request);
|
||||
|
||||
// Back on the requester
|
||||
const newCount = sync.applySyncResponse(response);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new VectorClockSync({ clock, idGenerator }, config)` —
|
||||
`config: { nodeId, maxActionHistory?, autoResolveConflicts?, conflictResolver?, eventMerger? }`.
|
||||
- `recordAction(sessionId, actionType, data, tick) → GameAction`.
|
||||
- `receiveAction(action) → ConflictResolution | null`.
|
||||
- `acknowledgeAction(id) → boolean`.
|
||||
- `getCausalityChain(sessionId) → CausalityChain`.
|
||||
- `happenedBefore(a, b) / areConcurrent(a, b)` — predicates.
|
||||
- `requestSync(toNode, sessionId) → SyncRequest`.
|
||||
- `handleSyncRequest(request) → SyncResponse`.
|
||||
- `applySyncResponse(response) → number` (new actions count).
|
||||
- `addParticipant / removeParticipant`.
|
||||
- `getSessionState / getStats / dispose`.
|
||||
- `createVectorClockSync(deps, config)` — convenience factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `receiveAction` always advances this node's clock by `update(merge)`
|
||||
before resolution; this is what guarantees the resulting causality
|
||||
chain stays consistent even if the application discards the
|
||||
conflict winner.
|
||||
- Action IDs are generated by the injected `IdGenerator` rather than
|
||||
derived from `${nodeId}-${seq}-${timestamp}` so callers can plug in
|
||||
UUID v7 or any other source — the original derivation was lossy
|
||||
when two actions raced inside the same millisecond.
|
||||
- The action-history map is capped at `maxActionHistory` and evicts in
|
||||
insertion order. Beyond the cap, replays only work for actions
|
||||
inside the window.
|
||||
- `findConcurrentGroups` dedupes by membership equality (set equality)
|
||||
so two iterations that reach the same group through different seed
|
||||
actions collapse to one entry.
|
||||
@@ -0,0 +1,81 @@
|
||||
# tournament-bracket.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure bracket-generation helpers used by `TournamentManager`. Extracted to
|
||||
a sibling internal module to keep `tournament-manager.ts` under the
|
||||
project's 300-line hard limit while preserving full coverage of the
|
||||
generation paths through the manager's public surface.
|
||||
|
||||
The helpers are deliberately framework-free — they accept an injectable
|
||||
`createMatch` factory so the manager retains exclusive ownership of match
|
||||
ids, OTel counters, and other side-effecting machinery, leaving these
|
||||
helpers as deterministic pure functions over the participant + match
|
||||
type domain.
|
||||
|
||||
## Features
|
||||
|
||||
- `roundName(round, total)` — human-readable round labels.
|
||||
- `shuffleWithRng(arr, rng)` — in-place Fisher-Yates via injectable `Rng`.
|
||||
- `padToPowerOfTwo(participants)` — null-padding for single-elim.
|
||||
- `buildSingleElim(...)` — bracket with first-round byes.
|
||||
- `buildRoundRobin(...)` — circle-method scheduling.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildSingleElim } from "./tournament-bracket.internal.js";
|
||||
const rounds = buildSingleElim(participants, (round, position) =>
|
||||
createMatch(round, position)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildRoundRobin,
|
||||
shuffleWithRng,
|
||||
} from "./tournament-bracket.internal.js";
|
||||
|
||||
shuffleWithRng(participants, rng);
|
||||
const rounds = buildRoundRobin(participants, (round, position) => ({
|
||||
id: nextId(),
|
||||
round,
|
||||
position,
|
||||
participant1: null,
|
||||
participant2: null,
|
||||
status: "pending",
|
||||
result: null,
|
||||
nextMatchId: null,
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `roundName(round: number, total: number): string`
|
||||
- `shuffleWithRng<T>(arr: T[], rng: Rng | undefined): void`
|
||||
- `padToPowerOfTwo<T>(participants: Participant<T>[]): (Participant<T> | null)[]`
|
||||
- `buildSingleElim<TP, TR>(participants, createMatch): Round<TP, TR>[]`
|
||||
- `buildRoundRobin<TP, TR>(participants, createMatch): Round<TP, TR>[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildSingleElim` throws `INVALID_BRACKET_SIZE` if the padded size is not
|
||||
a power of two — this should be unreachable in practice since
|
||||
`padToPowerOfTwo` always pads to a power of two, but the guard catches
|
||||
caller misuse (e.g. handing in a pre-padded list) without silent
|
||||
corruption of the bracket shape.
|
||||
|
||||
`buildRoundRobin` uses the standard circle method: one participant is
|
||||
fixed and the others rotate around it. For an odd participant count a
|
||||
`null` slot is injected so each round contains exactly `floor(n/2)`
|
||||
matches; pairs with `null` are filtered out before emitting matches so
|
||||
they don't appear in the bracket. This yields `n - 1` rounds for `n`
|
||||
even and `n` rounds for `n` odd, matching the conventional schedule.
|
||||
|
||||
Shuffle is unbiased Fisher-Yates and uses `Rng.nextIntInRange` —
|
||||
implementations of `Rng` that do not provide it (it is optional in the
|
||||
core contract) leave the order untouched, which is the conservative
|
||||
default for any tournament harness that prefers explicit error over silent
|
||||
non-determinism.
|
||||
@@ -0,0 +1,129 @@
|
||||
# TournamentManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TournamentManager` orchestrates single-elimination and round-robin
|
||||
tournaments. Participants register during the `pending` phase, then a
|
||||
single `start()` locks the field, applies seeding (or RNG-driven
|
||||
shuffling), and constructs the bracket. From there the manager tracks
|
||||
match results, propagates winners through `nextMatchId` links, updates
|
||||
standings, and emits stable error codes whenever the caller would
|
||||
otherwise corrupt the bracket.
|
||||
|
||||
The manager injects its `Clock`, `IdGenerator`, and `Rng` so deterministic
|
||||
test rigs and seeded replays produce byte-identical brackets each run.
|
||||
OTel counters record per-match creation and completion so production
|
||||
operators can monitor tournament throughput without bespoke
|
||||
instrumentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Two bracket types: single-elimination, round-robin.
|
||||
- Automatic first-round bye propagation for non-power-of-two fields.
|
||||
- Deterministic seeded shuffling via injectable `Rng`.
|
||||
- Standings table with stable points/wins/losses tie-break.
|
||||
- Stable `GAME_SERVER.*` error codes (`INVALID_BRACKET_SIZE`,
|
||||
`TOURNAMENT_INVALID_STATE`, `TOURNAMENT_FULL`,
|
||||
`TOURNAMENT_DUPLICATE_PARTICIPANT`,
|
||||
`TOURNAMENT_INSUFFICIENT_PARTICIPANTS`, `TOURNAMENT_MATCH_NOT_FOUND`,
|
||||
`TOURNAMENT_INVALID_RESULT`).
|
||||
- Zod-validated config.
|
||||
- OTel match counter tagged by `op` (`created`, `completed`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TournamentManager } from "@suaveplan/game-server/enhanced/tournament/tournament-manager";
|
||||
|
||||
const tm = new TournamentManager({
|
||||
id: "weekly-cup",
|
||||
name: "Weekly Cup",
|
||||
bracketType: "single-elimination",
|
||||
maxParticipants: 16,
|
||||
});
|
||||
tm.addParticipant("alice");
|
||||
tm.addParticipant("bob");
|
||||
tm.start();
|
||||
const firstMatch = tm.getTournament().rounds[0]!.matches[0]!;
|
||||
tm.recordResult(firstMatch.id, "alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TournamentManager } from "@suaveplan/game-server/enhanced/tournament/tournament-manager";
|
||||
|
||||
const tm = new TournamentManager<PlayerProfile, MatchTelemetry>(
|
||||
{
|
||||
id: "monthly",
|
||||
name: "Monthly Championship",
|
||||
bracketType: "round-robin",
|
||||
maxParticipants: 8,
|
||||
shuffleSeeds: true,
|
||||
winPoints: 3,
|
||||
drawPoints: 1,
|
||||
lossPoints: 0,
|
||||
},
|
||||
{
|
||||
clock: () => now,
|
||||
rng: seededRng,
|
||||
idGenerator: () => `m-${++counter}`,
|
||||
}
|
||||
);
|
||||
|
||||
for (const player of players) {
|
||||
tm.addParticipant(player.id, player.profile);
|
||||
}
|
||||
tm.start();
|
||||
|
||||
for (const round of tm.getTournament().rounds) {
|
||||
for (const match of round.matches) {
|
||||
const winnerId = await playMatch(match);
|
||||
tm.recordResult(match.id, winnerId, { latencyMs: 27 });
|
||||
}
|
||||
}
|
||||
|
||||
const champion = tm.getWinner();
|
||||
const standings = tm.getStandings();
|
||||
const stats = tm.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new TournamentManager(config, deps?)`
|
||||
- `addParticipant(id, data?, seed?): Participant`
|
||||
- `start(): void`
|
||||
- `getMatch(id): Match`
|
||||
- `recordResult(matchId, winnerId, data?): Match`
|
||||
- `cancel(): void`
|
||||
- `getTournament(): Tournament`
|
||||
- `getWinner(): Participant | null`
|
||||
- `getStandings(): readonly Standing[]`
|
||||
- `getStats(): TournamentStats`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Bracket generation is split into `tournament-bracket.internal.ts` and
|
||||
standings/match utilities into `tournament-standings.internal.ts` to keep
|
||||
this file under the project's 300-line hard limit while preserving full
|
||||
behaviour and 100% coverage through the public surface. Internal modules
|
||||
are not exported through the package barrel — their tests run via the
|
||||
manager.
|
||||
|
||||
Single-elimination brackets pad participants to the next power of two
|
||||
with `null` slots that become first-round byes. Byes auto-advance their
|
||||
opponent into the next round during `start()`, so the bracket is
|
||||
immediately consistent before the first `recordResult()` call. Tournament
|
||||
completion is detected when exactly one participant remains active
|
||||
(single-elim) or every round has completed (round-robin).
|
||||
|
||||
Seeding has two paths: explicit `useSeeding=true` sorts by the
|
||||
caller-supplied `seed` value, while `shuffleSeeds=true` overrides that
|
||||
ordering by Fisher-Yates shuffling with the injected `Rng`. An `Rng`
|
||||
implementation that does not provide `nextIntInRange` is treated as a
|
||||
no-op shuffle — the conservative default for tournament harnesses that
|
||||
prefer explicit ordering errors over silent non-determinism.
|
||||
@@ -0,0 +1,71 @@
|
||||
# tournament-standings.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helper utilities for the standings table inside a tournament:
|
||||
applying a win/loss outcome, marking participants eliminated, locating a
|
||||
match by id, and flipping a round to completed. Extracted from
|
||||
`tournament-manager.ts` to keep that file under the project's 300-line
|
||||
hard limit while preserving full behaviour through the public API.
|
||||
|
||||
The helpers are stateless and take their targets by reference so the
|
||||
manager retains exclusive ownership of mutability. They are covered
|
||||
indirectly via the manager's public surface tests rather than directly,
|
||||
which is the standard pattern for `.internal.ts` modules in this repo.
|
||||
|
||||
## Features
|
||||
|
||||
- `applyStanding` — write outcome + resort + re-rank in one call.
|
||||
- `markEliminated` — set `eliminated = true` by participant id.
|
||||
- `findMatch` — linear scan across rounds (small N).
|
||||
- `markRoundCompleteIfDone` — flip round completion idempotently.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { applyStanding } from "./tournament-standings.internal.js";
|
||||
applyStanding(standings, winner, loser, 3, 0);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
applyStanding,
|
||||
findMatch,
|
||||
markEliminated,
|
||||
markRoundCompleteIfDone,
|
||||
} from "./tournament-standings.internal.js";
|
||||
|
||||
applyStanding(tournament.standings, winner, loser, winPoints, lossPoints);
|
||||
markEliminated(tournament.standings, loser.id);
|
||||
const next = findMatch(tournament.rounds, currentMatch.nextMatchId!);
|
||||
markRoundCompleteIfDone(tournament.rounds, currentMatch);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyStanding(standings, winner, loser, winPoints, lossPoints): void`
|
||||
- `markEliminated(standings, id): void`
|
||||
- `findMatch(rounds, matchId): Match | null`
|
||||
- `markRoundCompleteIfDone(rounds, match): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The standings sort is stable on points first, then wins, then losses
|
||||
ascending — the conventional Swiss-style ordering. Re-sorting after every
|
||||
outcome is `O(N log N)` per result; for typical bracket sizes (≤ 256
|
||||
participants) this is well under a millisecond and avoids the
|
||||
bookkeeping complexity of an incremental order-statistic structure.
|
||||
|
||||
`findMatch` is a linear scan across all rounds. Bracket-size rounds grow
|
||||
logarithmically with participant count in single-elimination and
|
||||
linearly in round-robin, so the total match count stays small relative to
|
||||
typical scan budgets. A hot-path id-to-match index would be premature
|
||||
optimisation here and would force the manager to keep two structures in
|
||||
sync.
|
||||
|
||||
`markRoundCompleteIfDone` treats `bye` and `completed` as equivalent
|
||||
terminal states so a round consisting entirely of byes is reported as
|
||||
done immediately after `start()`, which keeps `getStats().currentRound`
|
||||
advancing through automatic walkovers without manual intervention.
|
||||
@@ -0,0 +1,140 @@
|
||||
# GameServerError
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameServerError` is the single typed-exception class for the `@suaveplan/game-server` package. It extends `SuaveplanError<GameServerCode>` from `@suaveplan/error`, anchoring every failure raised by the server framework to a stable, lint-checkable code string in the `GAME_SERVER.*` namespace. Callers can branch on `error.code` without depending on `instanceof` semantics that fail across module realms.
|
||||
|
||||
The companion `GAME_SERVER_CODES` constant is produced by `defineCodes("GAME_SERVER", [...])` so the registry is verified at compile time and contributes its codes to the global suaveplan error catalog. The registry only enumerates codes that an actual production code path throws today — follow-up migrations (engine, state, matchmaking, telemetry, network) extend the array as their throw sites land, in line with `AGENTS.md` §13.
|
||||
|
||||
## Features
|
||||
|
||||
- Single typed error class for the entire package.
|
||||
- Codes resolved through `@suaveplan/error`'s `defineCodes` registry.
|
||||
- Optional `cause` and `context` for chained diagnostics.
|
||||
- Compile-time exhaustiveness on the `GameServerCode` union.
|
||||
- Code namespace `GAME_SERVER.*` is reserved for this package across the workspace.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import {
|
||||
GAME_SERVER_CODES,
|
||||
GameServerError,
|
||||
} from "@suaveplan/game-server/errors";
|
||||
|
||||
try {
|
||||
container.resolve("missing");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.SERVICE_NOT_REGISTERED
|
||||
) {
|
||||
// recover by registering a fallback
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { GameServerError, GAME_SERVER_CODES } from "@suaveplan/game-server/errors";
|
||||
|
||||
function loadConfig(input: unknown) {
|
||||
const parsed = GameConfigSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.INVALID_GAME_CONFIG,
|
||||
"Game config failed Zod validation",
|
||||
{ cause: parsed.error, context: { input } },
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
The optional `context` bag is logged by the standard `@suaveplan/logger`
|
||||
formatter and forwarded as OpenTelemetry span attributes by
|
||||
`@suaveplan/opentelemetry`, so diagnostic data flows without bespoke wiring.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `GAME_SERVER_CODES`
|
||||
|
||||
A frozen object of code constants:
|
||||
|
||||
- `SERVICE_NOT_REGISTERED` — thrown by `DIContainer.resolve` on an unknown identifier.
|
||||
- `INVALID_GAME_CONFIG` / `INVALID_SESSION_METADATA` / `INVALID_PLAYER_INFO` — thrown by the corresponding `parse*` boundary functions in `./types`.
|
||||
- `INVALID_MOVE` — `AbstractGameLogic.processMove` validation failure.
|
||||
- `PLAYER_ALREADY_EXISTS` / `PLAYER_NOT_FOUND` — `AbstractGameLogic.{addPlayer,removePlayer}`.
|
||||
- `LISTENER_LIMIT_EXCEEDED` / `INVALID_LISTENER_COUNT` / `EVENT_WAIT_TIMEOUT` — `GameEventEmitter`.
|
||||
- `STATE_DIFF_FAILED` / `STATE_PATCH_FAILED` / `STATE_RESTORE_FAILED` / `INVALID_SNAPSHOT` / `STATE_DECOMPRESS_FAILED` / `STATE_SYNC_TRANSPORT_FAILED` — state-manager / differ / compressor / sync.
|
||||
- `TRANSPORT_ALREADY_INITIALIZED` / `TRANSPORT_NOT_INITIALIZED` / `TRANSPORT_NOT_CONNECTED` / `INVALID_MESSAGE_FORMAT` / `RECONNECT_EXHAUSTED` / `RECONNECT_IN_PROGRESS` — network adapter + reconnection manager + transports.
|
||||
- `ENGINE_NOT_STARTED` — engine method called before `start()`.
|
||||
- `ENGINE_ALREADY_STARTED` — second `start()` call on a live engine.
|
||||
- `ENGINE_DISPOSED` — engine method called after `dispose()`.
|
||||
- `INVALID_ENGINE_OPTIONS` — Zod failure on `GameEngineOptions`.
|
||||
- `INVALID_GAME_LOOP_OPTIONS` — Zod failure on `GameLoopOptions`.
|
||||
- `GAME_LOOP_NOT_RUNNING` — `pause`/`resume`/`stop` called on an idle loop.
|
||||
- `GAME_LOOP_ALREADY_RUNNING` — second `start()` call on a running loop.
|
||||
- `PLUGIN_NOT_FOUND` — `uninstall`, `enable`, `disable`, `get` on an unknown plugin name.
|
||||
- `DUPLICATE_PLUGIN` — `install` called with an already-registered plugin name.
|
||||
- `PLUGIN_LIFECYCLE_FAILED` — wraps any error thrown from a plugin lifecycle hook.
|
||||
- `MATCHMAKING_QUEUE_FULL` — `QueueManager.addToQueue` rejected because the per-key queue is at the configured cap.
|
||||
- `INVALID_QUEUE_REQUEST` — Zod failure on a `QueueRequest` arriving at `QueueManager` / `MatchmakingEngine`.
|
||||
- `INVALID_MATCH_CRITERIA` — Zod failure on a `MatchCriteria` arriving at `MatchmakingEngine.createMatch`.
|
||||
- `NO_MATCH_FOUND` — `MatchmakingEngine.createMatch` returned `null` when the caller requested `throwOnNoMatch`.
|
||||
- `INVALID_SKILL_SCORE` — `SkillMatcher` received a non-finite rating or seeker missing a positive `waitingSince`.
|
||||
- `SPECTATOR_LIMIT_REACHED` — `SpectatorManager.addSpectator` rejected because the session is at its spectator cap.
|
||||
- `INVALID_SPECTATOR_REQUEST` — Zod failure on `SpectatorPermissions` or duplicate spectator id.
|
||||
- `UNKNOWN_MATCHING_ALGORITHM` — `MatchmakingEngine.createMatch` was passed an algorithm string outside the registered union.
|
||||
- `POOL_EXHAUSTED` — `ObjectPool.acquire` rejected because the pool's `maxSize` is reached and no slots are free.
|
||||
- `POOL_DISPOSED` — `ObjectPool` method called after `dispose()`.
|
||||
- `RELEASE_FOREIGN_OBJECT` — `ObjectPool.release` received an object that did not originate from this pool.
|
||||
- `INVALID_POOL_CONFIG` — `ObjectPool` constructor received a non-positive `initialSize` / `maxSize`, or `maxSize` smaller than `initialSize`.
|
||||
- `INVALID_TIER_CONFIG` — `NetworkTierAdapter` constructor received an empty / malformed tier list or a node-capability with a non-finite score.
|
||||
- `UNKNOWN_TIER` — `NetworkTierAdapter` queried for a tier index / node-type that is not registered.
|
||||
|
||||
Each value is the fully-qualified string e.g. `"GAME_SERVER.SERVICE_NOT_REGISTERED"`.
|
||||
The latter six codes are introduced by Wave A (core foundation): `INVALID_MOVE`
|
||||
fires from `AbstractGameLogic.processMove` when validation fails;
|
||||
`PLAYER_ALREADY_EXISTS` / `PLAYER_NOT_FOUND` from `addPlayer` / `removePlayer`;
|
||||
`LISTENER_LIMIT_EXCEEDED` / `INVALID_LISTENER_COUNT` / `EVENT_WAIT_TIMEOUT`
|
||||
from `GameEventEmitter`.
|
||||
|
||||
The state-domain codes added in Wave B-3 (`STATE_*`, `INVALID_SNAPSHOT`)
|
||||
are thrown by the `state-manager`, `state-differ`, `delta-compressor`,
|
||||
and `state-sync` modules. `STATE_DIFF_FAILED` and `STATE_PATCH_FAILED`
|
||||
wrap unexpected exceptions raised while walking deeply-nested structures;
|
||||
`STATE_RESTORE_FAILED` and `INVALID_SNAPSHOT` surface when callers try to
|
||||
restore a snapshot whose shape disagrees with the manager's state schema;
|
||||
`STATE_DECOMPRESS_FAILED` wraps malformed wire packets; and
|
||||
`STATE_SYNC_TRANSPORT_FAILED` flags transport-layer send/receive faults.
|
||||
|
||||
### `type GameServerCode`
|
||||
|
||||
Union of all fully-qualified code strings in the registry.
|
||||
|
||||
### `new GameServerError(code, message, options?)`
|
||||
|
||||
- `code`: `GameServerCode`
|
||||
- `message`: `string`
|
||||
- `options?`: `{ cause?: unknown; context?: Record<string, unknown> }`
|
||||
|
||||
Instances expose `.code`, `.message`, `.cause` (or `undefined`),
|
||||
`.context` (or `undefined` when not supplied), and
|
||||
`.name === "GameServerError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError` is parameterised over a code union, so removing a code from
|
||||
the `defineCodes` literal array produces a TypeScript error at every throw
|
||||
site — this is the lint-checkable contract that keeps the registry minimal
|
||||
and honest. Codes are flat strings (no nesting) because `@suaveplan/error`'s
|
||||
canonical form is a single dot-delimited identifier. The class explicitly
|
||||
sets `this.name` because subclasses of `Error` inherit the parent name in
|
||||
some runtimes; downstream log scrapers key on the exact string
|
||||
`"GameServerError"`.
|
||||
@@ -0,0 +1,125 @@
|
||||
# EnhancedGameServer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EnhancedGameServer` is the top-of-stack integration layer for
|
||||
`@suaveplan/game-server`. Waves B and C-1 shipped the engine, plugin
|
||||
manager, and eight concrete plugins, but consumers still had to wire the
|
||||
loop, manager, engine, and every plugin by hand on each boot. This module
|
||||
collapses that boilerplate behind a single `bootstrap(options)` entry
|
||||
point and returns a typed handle exposing both the wired `GameEngine`
|
||||
and the bundle of installed plugins for direct access.
|
||||
|
||||
The integration is deliberately universal — zero browser, React, or
|
||||
DOM dependencies. The upstream React lobby shell is explicitly deferred
|
||||
to a future `react-game-server` package. All collaborators are injected
|
||||
through the structural contracts in `@suaveplan/types`
|
||||
(`Clock`, `IdGenerator`, `LoggerLike`, `TimersLike`, `EventBusLike`,
|
||||
`Rng`, `StorageLike`) plus the public APIs of `@suaveplan/feature-flags`,
|
||||
`@suaveplan/rules`, and `@suaveplan/workflow`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `bootstrap(options)` entry point — no manual wiring.
|
||||
- Pre-installs every built-in plugin (`logging`, `analytics`,
|
||||
`persistence`, `feature-flags`, `rules`, `workflow`, `ab-test`) via
|
||||
`PluginManager.installAll` so dependency order is honoured.
|
||||
- Enables every plugin after install so callers can opt-out by disabling
|
||||
individual plugins post-bootstrap rather than skipping them entirely.
|
||||
- Caller-supplied `extraPlugins` are installed last and enabled in the
|
||||
same pass.
|
||||
- `BOOTSTRAP_FAILED` wraps any plugin install failure with the original
|
||||
`cause` chain intact.
|
||||
- `ALREADY_BOOTSTRAPPED` prevents accidental double-wiring.
|
||||
- `start()` / `tick()` / `stop()` / `dispose()` delegate to the
|
||||
underlying `GameEngine` so existing engine codes (`ENGINE_NOT_STARTED`,
|
||||
`ENGINE_DISPOSED`) propagate unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EnhancedGameServer } from "@suaveplan/game-server/integrations/enhanced-server";
|
||||
|
||||
const server = new EnhancedGameServer({
|
||||
clock: () => Date.now(),
|
||||
timers,
|
||||
idGenerator,
|
||||
logger,
|
||||
events,
|
||||
emitter,
|
||||
flagManager,
|
||||
ruleEngine,
|
||||
rng,
|
||||
storage,
|
||||
analyticsSink: (event) => console.log(event),
|
||||
});
|
||||
|
||||
const { engine, plugins } = await server.bootstrap();
|
||||
server.start();
|
||||
await server.tick();
|
||||
server.stop();
|
||||
await server.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Install extra plugins after the canonical seven and configure tick rate:
|
||||
|
||||
```ts
|
||||
class TelemetryPlugin extends BasePlugin {
|
||||
public readonly name = "telemetry";
|
||||
public readonly version = "1.0.0";
|
||||
}
|
||||
|
||||
const handle = await server.bootstrap({
|
||||
autoStartLoop: false,
|
||||
tickRate: 30,
|
||||
extraPlugins: [new TelemetryPlugin()],
|
||||
});
|
||||
|
||||
// Disable analytics without uninstalling the rest.
|
||||
await handle.engine.getPlugin("analytics");
|
||||
// Direct typed access for per-plugin configuration:
|
||||
handle.plugins.featureFlag.evaluate("dark-mode", "player-1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class EnhancedGameServer(deps: EnhancedGameServerDeps)` — constructor.
|
||||
- `bootstrap(options?: EnhancedServerBootstrapOptions)` — returns
|
||||
`Promise<EnhancedServerHandle>`. Throws `ALREADY_BOOTSTRAPPED` on the
|
||||
second call and wraps install failures in `BOOTSTRAP_FAILED`.
|
||||
- `start()` — delegates to `GameEngine.start()`.
|
||||
- `tick()` — delegates to `GameEngine.tick()`.
|
||||
- `stop()` — delegates to `GameEngine.stop()`.
|
||||
- `dispose()` — idempotent; safe before bootstrap.
|
||||
- `isBootstrapped()` — predicate.
|
||||
- `getHandle()` — returns the typed handle; throws if not bootstrapped.
|
||||
|
||||
`EnhancedServerHandle` exposes the wired `engine` and a strongly-typed
|
||||
`plugins` bundle (`{ abTest, analytics, featureFlag, logging,
|
||||
persistence, rules, workflow, extras }`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Plugins are installed in an order that satisfies their declared
|
||||
`dependencies`. The current canonical set declares none, so the order
|
||||
is determined by the integration: log-first so subsequent installs are
|
||||
recorded, analytics next to capture session events, then persistence,
|
||||
feature-flags, rules, workflow, and finally A/B-test.
|
||||
- Bootstrap calls `PluginManager.installAll` (which `topoSort`s its
|
||||
argument) for an atomic batch install. After install, each plugin is
|
||||
individually enabled — `PluginManager.enable` is idempotent so the
|
||||
second-pass loop is safe.
|
||||
- The shared `EventBusLike<EnhancedServerEventMap>` is intersection-typed
|
||||
across the loop, manager, and engine maps so a single bus services all
|
||||
three subsystems without per-subsystem adapters.
|
||||
- All error wrapping preserves the original `cause` so consumers can
|
||||
walk the cause chain via `@suaveplan/error`'s utilities.
|
||||
- The class never holds React or DOM references; every concrete
|
||||
dependency is injected through a structural contract, keeping the
|
||||
package universal.
|
||||
@@ -0,0 +1,117 @@
|
||||
# MatchmakingEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MatchmakingEngine` is the top-level façade for the game-server's
|
||||
matchmaking pipeline. It composes three optional collaborators — a
|
||||
`QueueManager`, a `SpectatorManager`, and a `SkillMatcher` — behind a
|
||||
single `createMatch(players, criteria, algorithm)` API. The engine
|
||||
validates inputs through Zod (`MatchCriteriaSchema`,
|
||||
`MatchingAlgorithmSchema`), runs the chosen algorithm, mints a stable
|
||||
`matchId` via an injected `IdGenerator`, and surfaces every observable
|
||||
event through OTel counters and a synchronous `onMatchFound` listener.
|
||||
|
||||
The engine is also the place where the "pull from queue, run match,
|
||||
restore on shortfall" pattern lives. `createMatchFromQueue(queueKey,
|
||||
criteria, algorithm)` drains the top `criteria.maxPlayers` tickets from
|
||||
the injected `QueueManager`, hands them to `createMatch`, and — if not
|
||||
enough peers were waiting — re-enqueues the drained tickets so no
|
||||
player is silently lost. With `throwOnNoMatch` the shortfall path
|
||||
raises `NO_MATCH_FOUND` instead of returning `null`.
|
||||
|
||||
## Features
|
||||
|
||||
- Two algorithms today: `quick` (FIFO slice) and `skill-based` (largest
|
||||
group inside the configured `skillRange`).
|
||||
- Pluggable algorithm string — adding a new variant requires extending
|
||||
`MatchingAlgorithmSchema` and the dispatch switch.
|
||||
- Optional injected `QueueManager`, `SpectatorManager`, `SkillMatcher`.
|
||||
- Injectable `Clock`, `IdGenerator`, `Rng`.
|
||||
- Three OTel instruments — `match_create_total{algorithm,outcome}`,
|
||||
`match_reject_total{reason}`, `match_create_duration_ms`.
|
||||
- `onMatchFound` listener for observability + downstream wiring.
|
||||
- Stable `matchId` format: `match-<idGenerator-output>`.
|
||||
- `dispose()` clears every tracked match (for graceful shutdown).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { MatchmakingEngine } from "@suaveplan/game-server/matchmaking/matchmaking-engine";
|
||||
|
||||
const engine = new MatchmakingEngine();
|
||||
const match = engine.createMatch(
|
||||
[
|
||||
{ playerId: "alice", joinTime: 0, isPremium: false, queueType: "casual" },
|
||||
{ playerId: "bob", joinTime: 0, isPremium: false, queueType: "casual" },
|
||||
],
|
||||
{ minPlayers: 2, maxPlayers: 4 },
|
||||
);
|
||||
console.log(match?.matchId);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { MatchmakingEngine } from "@suaveplan/game-server/matchmaking/matchmaking-engine";
|
||||
import { QueueManager } from "@suaveplan/game-server/matchmaking/queue-manager";
|
||||
import { SpectatorManager } from "@suaveplan/game-server/matchmaking/spectator-manager";
|
||||
|
||||
const queue = new QueueManager({ maxQueueSize: 10_000 });
|
||||
const spectators = new SpectatorManager({ maxSpectatorsPerSession: 250 });
|
||||
|
||||
const engine = new MatchmakingEngine(
|
||||
{ throwOnNoMatch: false },
|
||||
{
|
||||
queueManager: queue,
|
||||
spectatorManager: spectators,
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
clock: () => Date.now(),
|
||||
onMatchFound: (match) => log.info({ event: "match.found", match }),
|
||||
},
|
||||
);
|
||||
|
||||
queue.addToQueue("ranked", { playerId: "alice", joinTime: Date.now(), isPremium: false, queueType: "ranked", skillRating: 1480 });
|
||||
queue.addToQueue("ranked", { playerId: "bob", joinTime: Date.now(), isPremium: false, queueType: "ranked", skillRating: 1505 });
|
||||
queue.addToQueue("ranked", { playerId: "carol", joinTime: Date.now(), isPremium: false, queueType: "ranked", skillRating: 1490 });
|
||||
|
||||
const match = engine.createMatchFromQueue(
|
||||
"ranked",
|
||||
{ minPlayers: 2, maxPlayers: 3, skillRange: 100 },
|
||||
"skill-based",
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new MatchmakingEngine(options?, deps?)`.
|
||||
- `createMatch(players, criteria, algorithm?)` — runs the algorithm,
|
||||
returns the freshly created `Match` or `null`/`NO_MATCH_FOUND`.
|
||||
- `createMatchFromQueue(queueKey, criteria, algorithm?)` — drains the
|
||||
queue, hands to `createMatch`, restores on shortfall.
|
||||
- `getMatch(matchId)` / `removeMatch(matchId)` / `getActiveMatchCount()`.
|
||||
- `dispose()` — clears every tracked match.
|
||||
- Getters: `queue`, `spectators` (the injected collaborators or
|
||||
`undefined`).
|
||||
- Algorithms: `"quick"`, `"skill-based"`, `"casual"` (current alias of
|
||||
`quick`). Invalid values raise `UNKNOWN_MATCHING_ALGORITHM`.
|
||||
- Errors: `INVALID_MATCH_CRITERIA`, `UNKNOWN_MATCHING_ALGORITHM`,
|
||||
`NO_MATCH_FOUND`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The skill-based algorithm tries each candidate as an anchor and grows
|
||||
the group greedily until either `maxPlayers` is reached or no further
|
||||
peers fit inside the `skillRange`. The match returned is the *largest*
|
||||
viable group; on ties the first-found anchor wins, matching the
|
||||
upstream behaviour. The engine deliberately does not own the
|
||||
`QueueManager`'s "remove on match" semantics — `createMatchFromQueue`
|
||||
drains via `QueueManager.findMatch` which already pops, and only
|
||||
re-inserts on shortfall to keep matchmaking idempotent under failure.
|
||||
The match object is `Object.freeze`d before storage so accidental
|
||||
mutation by listeners or downstream callers cannot poison the
|
||||
internal map. The matchId carries a stable `match-<uuid>` prefix so
|
||||
log scrapers and OTel attributes can filter cleanly without parsing.
|
||||
@@ -0,0 +1,118 @@
|
||||
# QueueManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`QueueManager` is the in-memory priority queue at the heart of the
|
||||
matchmaking pipeline. Each call to `addToQueue` inserts a `QueueRequest`
|
||||
into a per-key bucket, re-sorts the bucket by an effective priority
|
||||
composed of premium bonus, wait-time bonus, and an explicit caller
|
||||
offset, and increments observability counters. `findMatch(key, N)`
|
||||
atomically pops the top N tickets, recording wait-time histogram
|
||||
observations so production dashboards can watch matchmaking pressure
|
||||
without instrumenting callers.
|
||||
|
||||
The implementation is universal and dependency-free apart from
|
||||
`@suaveplan/types`'s `Clock`, the typed `GameServerError` registry,
|
||||
Zod (for input validation), and the OpenTelemetry `metrics` API. Every
|
||||
public mutation flows through Zod boundary validation; structural
|
||||
violations surface as `INVALID_QUEUE_REQUEST` and capacity violations
|
||||
surface as `MATCHMAKING_QUEUE_FULL`.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-key priority queues with premium-bonus, wait-time-bonus, and
|
||||
caller-supplied offsets.
|
||||
- Hard `maxQueueSize` cap with typed-error rejection.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
- Four OTel instruments — `queue_enqueue_total`, `queue_dequeue_total`,
|
||||
`queue_reject_total`, `queue_wait_duration_ms`.
|
||||
- `getStats()` snapshot of every active queue (size + oldest/newest
|
||||
join times).
|
||||
- `getQueuePosition()` for live UI ("you are 3rd in line").
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { QueueManager } from "@suaveplan/game-server/matchmaking/queue-manager";
|
||||
|
||||
const qm = new QueueManager();
|
||||
qm.addToQueue("ranked", {
|
||||
playerId: "alice",
|
||||
joinTime: Date.now(),
|
||||
isPremium: false,
|
||||
queueType: "ranked",
|
||||
});
|
||||
|
||||
const match = qm.findMatch("ranked", 4);
|
||||
if (match.length === 4) {
|
||||
console.log("found four:", match.map((p) => p.playerId));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { QueueManager } from "@suaveplan/game-server/matchmaking/queue-manager";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const qm = new QueueManager(
|
||||
{
|
||||
priorityMultiplier: 1.0,
|
||||
premiumBonus: 2_000_000,
|
||||
waitTimeBonus: 0.5,
|
||||
maxQueueSize: 5_000,
|
||||
},
|
||||
{
|
||||
clock: () => now,
|
||||
meter: metrics.getMeter("game-server.matchmaking"),
|
||||
},
|
||||
);
|
||||
|
||||
qm.addToQueue("ranked", {
|
||||
playerId: "alice",
|
||||
joinTime: now,
|
||||
isPremium: true,
|
||||
skillRating: 1480,
|
||||
queueType: "ranked",
|
||||
priority: 100,
|
||||
metadata: { region: "eu-west-1" },
|
||||
});
|
||||
|
||||
now += 30_000;
|
||||
const matched = qm.findMatch("ranked", 1);
|
||||
const stats = qm.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new QueueManager(config?: QueueConfig, deps?: QueueManagerDeps)` —
|
||||
Zod-validates the config; throws `INVALID_MATCH_CRITERIA` on a
|
||||
malformed shape.
|
||||
- `addToQueue(queueKey, request): void` — throws
|
||||
`INVALID_QUEUE_REQUEST` on malformed input,
|
||||
`MATCHMAKING_QUEUE_FULL` when the per-key cap is reached.
|
||||
- `removeFromQueue(queueKey, playerId): boolean` — `true` on hit.
|
||||
- `findMatch(queueKey, playersNeeded): readonly QueueRequest[]` —
|
||||
returns `[]` if not enough tickets are waiting; throws
|
||||
`INVALID_MATCH_CRITERIA` on a non-positive size.
|
||||
- `getQueueSize(queueKey): number` / `getQueuePosition(queueKey, playerId)`.
|
||||
- `getStats(): readonly QueueStats[]` — one entry per *known* queue key
|
||||
(preserved even after the queue empties, so dashboards see history).
|
||||
- `clear(): void` — drops every queue.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `QueueEntry` carries the precomputed priority so the
|
||||
post-insert sort runs over precomputed numbers; this keeps the worst-
|
||||
case insert at `O(n log n)` but lets `findMatch` run at `O(n)` simply
|
||||
by slicing the front. Wait-time bonus is clamped at zero when a caller
|
||||
submits a `joinTime` in the future (e.g. a clock-skew window during
|
||||
testing), which keeps the histogram well-formed and avoids a negative
|
||||
priority kick. The `getStats` snapshot retains empty queues so dashboards
|
||||
keep a row even after a churn-induced drain, mirroring how the upstream
|
||||
behaviour propagated dashboard state through Prometheus scrapes.
|
||||
@@ -0,0 +1,111 @@
|
||||
# SkillMatcher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SkillMatcher` picks the closest-rated viable opponent for a queue
|
||||
seeker out of a candidate pool, widening its acceptance band as the
|
||||
seeker waits. It is the matchmaking primitive that powers
|
||||
`MatchmakingEngine`'s `"skill-based"` algorithm but is also useable in
|
||||
isolation when callers want fine-grained control over rating math, such
|
||||
as during round-by-round tournament seeding or AI bot pairing.
|
||||
|
||||
Two pieces of math drive the matcher: the *effective range* (base
|
||||
`ratingRange` plus `waitTimeBonus * waitMinutes`, capped at `maxRange`)
|
||||
and the *score* (by default `Math.abs(seeker.rating - candidate.rating)`,
|
||||
overridable via the `score` dep so callers can plug in Glicko or
|
||||
TrueSkill). Tie-breaks across candidates with identical scores can be
|
||||
made deterministic by injecting an `Rng`.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable base range, per-minute wait-time bonus, and hard ceiling.
|
||||
- Pluggable scoring function (`SkillScoringFn`) for non-Elo metrics.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
- Injectable `Rng` for deterministic tie-breaks.
|
||||
- Two OTel instruments: a `skill_find_total{outcome}` counter and a
|
||||
`skill_search_duration_ms` histogram tagged by outcome.
|
||||
- All inputs validated through `SkillEntrySchema` and
|
||||
`SkillMatcherConfigSchema`; failures surface as typed
|
||||
`GameServerError(INVALID_SKILL_SCORE | INVALID_MATCH_CRITERIA)`.
|
||||
- Self-matches filtered automatically.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SkillMatcher } from "@suaveplan/game-server/matchmaking/skill-matcher";
|
||||
|
||||
const matcher = new SkillMatcher({ ratingRange: 200 });
|
||||
const opponent = matcher.findMatch(
|
||||
{ id: "seek", rating: 1500, waitingSince: 0 },
|
||||
[
|
||||
{ id: "a", rating: 1400, waitingSince: 0 },
|
||||
{ id: "b", rating: 1510, waitingSince: 0 },
|
||||
],
|
||||
);
|
||||
console.log(opponent?.id); // "b"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SkillMatcher } from "@suaveplan/game-server/matchmaking/skill-matcher";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
const seededRng: Rng = {
|
||||
nextUint32: () => 0xdeadbeef,
|
||||
nextFloat: () => 0.42,
|
||||
};
|
||||
const matcher = new SkillMatcher(
|
||||
{ ratingRange: 100, waitTimeBonus: 50, maxRange: 800 },
|
||||
{
|
||||
clock: () => 1_000_000,
|
||||
rng: seededRng,
|
||||
score: (seeker, c) =>
|
||||
Math.abs(seeker.rating - c.rating) +
|
||||
(c.id.startsWith("bot:") ? 75 : 0), // prefer humans
|
||||
},
|
||||
);
|
||||
|
||||
const match = matcher.findMatch(
|
||||
{ id: "seek", rating: 1500, waitingSince: 1_000_000 - 90_000 },
|
||||
[
|
||||
{ id: "bot:1", rating: 1505, waitingSince: 0 },
|
||||
{ id: "human:1", rating: 1520, waitingSince: 0 },
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SkillMatcher(config?: SkillMatcherConfig, deps?: SkillMatcherDeps)` —
|
||||
validates the config via Zod; throws `INVALID_MATCH_CRITERIA` on a
|
||||
malformed shape.
|
||||
- `findMatch(seeker, candidates): SkillEntry | null` — returns the best
|
||||
candidate by score, or `null` when no candidate falls inside the
|
||||
effective range. Throws `INVALID_SKILL_SCORE` on a non-finite rating
|
||||
or empty id.
|
||||
- `effectiveRange(seeker): number` — exposes the live tolerance for the
|
||||
given seeker, useful for diagnostic overlays.
|
||||
- Config: `ratingRange` (default 200), `waitTimeBonus` (default 50),
|
||||
`maxRange` (default `+Infinity`).
|
||||
- Deps: `clock` (default `Date.now`), `rng` (optional), `meter`
|
||||
(optional), `score` (optional override).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The matcher iterates the candidate pool exactly once, scoring eligible
|
||||
entries inline. Ineligible candidates are dropped before sorting so the
|
||||
sort never sees more elements than fit inside `effectiveRange`. Tie
|
||||
breaking is opt-in: without an `Rng` the first tied candidate wins
|
||||
(matching the upstream behaviour); with an `Rng` the matcher samples a
|
||||
uniform integer in `[0, tied.length)` so test runs are reproducible
|
||||
when callers seed the generator. The default scoring function is
|
||||
absolute-delta; a `score` override is the seam for ranked-pool
|
||||
modifiers (faction balancing, anti-smurf, region preference). The
|
||||
histogram captures the full method body's duration including pool
|
||||
iteration plus sort, so the upper percentiles are useful for spotting
|
||||
hot-loop regressions.
|
||||
@@ -0,0 +1,115 @@
|
||||
# SpectatorManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpectatorManager` owns the live spectator roster for every active game
|
||||
session. Spectators join a session through `addSpectator(sessionId,
|
||||
spectatorId, permissions)`, which enforces a per-session cap, validates
|
||||
permissions via Zod, and notifies caller-supplied listeners
|
||||
synchronously. The manager is universal and does not couple to a
|
||||
specific event bus — callers wire `onSpectatorJoined` and
|
||||
`onSpectatorLeft` callbacks through the deps bag, so the same component
|
||||
can feed an `EventBusLike`, a WebSocket fan-out, or analytics
|
||||
instrumentation without code changes.
|
||||
|
||||
The module's data shape mirrors the upstream behaviour (per-session
|
||||
`Map<spectatorId, Spectator>`) but tightens every boundary: empty ids
|
||||
and malformed permission objects fail with a typed
|
||||
`INVALID_SPECTATOR_REQUEST`, hitting the cap fails with
|
||||
`SPECTATOR_LIMIT_REACHED`, and three OTel counters track joins, leaves,
|
||||
and rejections (with reason attributes) so production dashboards can
|
||||
distinguish "session is at capacity" from "client sent garbage".
|
||||
|
||||
## Features
|
||||
|
||||
- Per-session spectator roster with a configurable cap (default 100).
|
||||
- Synchronous join/leave listener hooks, optional.
|
||||
- Zod-validated `SpectatorPermissions` boundary.
|
||||
- Three OTel counters — `spectator_join_total`, `spectator_leave_total`,
|
||||
`spectator_reject_total{reason}`.
|
||||
- Bulk `removeAllSpectators` for session shutdown.
|
||||
- Injectable `Clock` for deterministic `joinedAt` timestamps.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SpectatorManager } from "@suaveplan/game-server/matchmaking/spectator-manager";
|
||||
|
||||
const sm = new SpectatorManager();
|
||||
sm.addSpectator("session-42", "viewer-alice", {
|
||||
canChat: true,
|
||||
canViewPrivateInfo: false,
|
||||
isVIP: false,
|
||||
});
|
||||
console.log(sm.getSpectatorCount("session-42")); // 1
|
||||
sm.removeSpectator("session-42", "viewer-alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SpectatorManager } from "@suaveplan/game-server/matchmaking/spectator-manager";
|
||||
|
||||
const sm = new SpectatorManager(
|
||||
{ maxSpectatorsPerSession: 500 },
|
||||
{
|
||||
clock: () => Date.now(),
|
||||
onSpectatorJoined: (sessionId, spectator) =>
|
||||
networkBus.publish(`session:${sessionId}:roster`, {
|
||||
kind: "join",
|
||||
spectatorId: spectator.spectatorId,
|
||||
permissions: spectator.permissions,
|
||||
}),
|
||||
onSpectatorLeft: (sessionId, spectatorId) =>
|
||||
networkBus.publish(`session:${sessionId}:roster`, {
|
||||
kind: "leave",
|
||||
spectatorId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
sm.addSpectator("session-42", "viewer-vip-001", {
|
||||
canChat: true,
|
||||
canViewPrivateInfo: true,
|
||||
isVIP: true,
|
||||
});
|
||||
|
||||
// On session shutdown:
|
||||
sm.removeAllSpectators("session-42");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SpectatorManager(config?, deps?)` — Zod-validates config; throws
|
||||
`INVALID_MATCH_CRITERIA` on a malformed shape.
|
||||
- `addSpectator(sessionId, spectatorId, permissions): Spectator` —
|
||||
throws `INVALID_SPECTATOR_REQUEST` on empty ids, malformed
|
||||
permissions, or a duplicate spectator; throws
|
||||
`SPECTATOR_LIMIT_REACHED` when the cap is hit. Returns the created
|
||||
`Spectator` record.
|
||||
- `removeSpectator(sessionId, spectatorId): boolean` — `true` on hit.
|
||||
- `removeAllSpectators(sessionId): number` — returns the count removed.
|
||||
- `getSpectators(sessionId): readonly Spectator[]`.
|
||||
- `getSpectatorCount(sessionId): number` /
|
||||
`isSpectator(sessionId, spectatorId): boolean`.
|
||||
- `getSpectatorPermissions(sessionId, spectatorId)` — `null` on miss.
|
||||
- `getSessionCount(): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`removeAllSpectators` increments the `spectator_leave_total` counter
|
||||
once per spectator removed and fires the listener for each one before
|
||||
deleting the bucket, so consumers get a clean wave of leave
|
||||
notifications during session shutdown. The internal data structure
|
||||
deliberately exposes spectators through `Array.from(map.values())`
|
||||
rather than a live `MapIterator` to keep callers from mutating the
|
||||
internal map by accident. The `onSpectatorJoined` callback receives the
|
||||
fully-constructed `Spectator` (id + `joinedAt` + permissions) so a
|
||||
network fan-out caller does not need a second lookup to broadcast the
|
||||
join. Duplicate-spectator detection runs *before* cap enforcement so an
|
||||
already-attached viewer never accidentally fails with
|
||||
`SPECTATOR_LIMIT_REACHED` when the session happens to be full.
|
||||
@@ -0,0 +1,105 @@
|
||||
# WinnerStaysOnManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WinnerStaysOnManager` models the classic arcade format where the round
|
||||
winner keeps their seat while the next challenger steps up. It owns three
|
||||
pieces of state: the current champion id (or `null` when no result has
|
||||
been recorded), the champion's consecutive win streak, and a FIFO queue
|
||||
of pending challenger ids. Match results are streamed in via
|
||||
`recordResult(winnerId, loserId)`; the manager tracks streak growth,
|
||||
detects throne transfers, and exposes `isChampionRotationDue` once the
|
||||
streak hits the configured `maxWinStreak` cap.
|
||||
|
||||
The module is universal and dependency-free apart from the typed
|
||||
`GameServerError` registry and the OpenTelemetry `metrics` API. It is
|
||||
designed to plug into `MatchmakingEngine` when the caller wants
|
||||
champion-style flow rather than batch matchmaking, but it works equally
|
||||
well as a standalone primitive for split-screen tournaments, party-game
|
||||
session loops, or daily-leaderboard rotations.
|
||||
|
||||
## Features
|
||||
|
||||
- Tracks the current champion plus a deduplicated challenger queue.
|
||||
- Distinguishes "retain" vs "takeover" outcomes when recording results.
|
||||
- Surfaces a rotation-due flag once the win streak hits a configurable
|
||||
cap (default 5); the caller decides when to actually rotate.
|
||||
- `forceRotateChampion()` cleanly dethrones the current champion and
|
||||
promotes the next challenger.
|
||||
- Two OTel counters (`winner_result_total{outcome}` and
|
||||
`winner_rotation_total`) for production observability.
|
||||
- Zod-validated configuration via the typed
|
||||
`GameServerError(INVALID_MATCH_CRITERIA)` path.
|
||||
- Reset path for replay tests / scenario harnesses.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { WinnerStaysOnManager } from "@suaveplan/game-server/matchmaking/winner-stays-on";
|
||||
|
||||
const champ = new WinnerStaysOnManager();
|
||||
champ.recordResult("alice", "bob");
|
||||
champ.recordResult("alice", "carol");
|
||||
console.log(champ.champion); // "alice"
|
||||
console.log(champ.winStreak); // 2
|
||||
console.log(champ.challengerCount); // 2 (bob, carol)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { WinnerStaysOnManager } from "@suaveplan/game-server/matchmaking/winner-stays-on";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const wso = new WinnerStaysOnManager(
|
||||
{ maxWinStreak: 3 },
|
||||
metrics.getMeter("party-game.tournament")
|
||||
);
|
||||
|
||||
wso.addChallenger("dave");
|
||||
wso.recordResult("alice", "bob");
|
||||
wso.recordResult("alice", "carol");
|
||||
wso.recordResult("alice", "dave");
|
||||
if (wso.isChampionRotationDue) {
|
||||
const next = wso.forceRotateChampion();
|
||||
console.log(`alice rotated out, next champion: ${next}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new WinnerStaysOnManager(config?: WinnerStaysOnConfig, meter?: Meter)` —
|
||||
validates the config via Zod. Throws
|
||||
`GameServerError(INVALID_MATCH_CRITERIA)` on a non-positive
|
||||
`maxWinStreak`.
|
||||
- `recordResult(winnerId: string, loserId: string): void` — throws
|
||||
`INVALID_QUEUE_REQUEST` if `winnerId` is empty.
|
||||
- `addChallenger(playerId: string): void` — throws
|
||||
`INVALID_QUEUE_REQUEST` if `playerId` is empty.
|
||||
- `nextChallenger(): string | null` — pops the front of the queue.
|
||||
- `forceRotateChampion(): string | null` — pops a challenger into the
|
||||
champion slot, returns it (or `null` if queue empty); increments the
|
||||
`winner_rotation_total` counter.
|
||||
- `reset(): void` — drops all state.
|
||||
- `getStats(): WinnerStaysOnStats` — snapshot of counters.
|
||||
- Getters: `champion`, `winStreak`, `challengerCount`,
|
||||
`isChampionRotationDue`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class deliberately couples nothing to wall-clock time; the only
|
||||
external surface is the OpenTelemetry meter, which falls back to
|
||||
`metrics.getMeter("@suaveplan/game-server")` when the caller does not
|
||||
inject one. The challenger queue is a plain `string[]` rather than a
|
||||
`Set` because order matters: a deduplicating filter on insert keeps
|
||||
both behaviours correct without paying the iteration cost on every
|
||||
read. The `outcome` attribute on `winner_result_total` is `"retain"`
|
||||
for streak growth and `"takeover"` for throne transfer; analytics
|
||||
dashboards can therefore plot churn-rate without parsing log lines.
|
||||
`forceRotateChampion()` resets `winStreak` to zero so the very next
|
||||
`recordResult` re-establishes a baseline rather than appearing to be a
|
||||
second consecutive win.
|
||||
@@ -0,0 +1,124 @@
|
||||
# NetworkAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`NetworkAdapter` is the unified bridge between the `@suaveplan/game-server`
|
||||
framework and any concrete network transport (WebSocket, WebRTC,
|
||||
long-polling, …). It owns the transport-binding lifecycle, validates every
|
||||
outgoing frame against a Zod schema, fans inbound frames out to multiple
|
||||
subscribed handlers without letting a single faulty listener poison the
|
||||
delivery loop, and publishes OpenTelemetry counters for sent / received /
|
||||
error volumes.
|
||||
|
||||
The adapter is dependency-injected through a structural `Transport`
|
||||
contract so test code can drive it with an in-process fake — no `vi.mock`
|
||||
needed and no entanglement with a specific platform API such as
|
||||
`globalThis.WebSocket`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-initialisation guard with the typed code
|
||||
`GAME_SERVER.TRANSPORT_ALREADY_INITIALIZED`.
|
||||
- Per-send guards (`TRANSPORT_NOT_INITIALIZED`, `TRANSPORT_NOT_CONNECTED`,
|
||||
`INVALID_MESSAGE_FORMAT`).
|
||||
- Zod `NetworkMessageSchema` validates outbound and inbound frames.
|
||||
- Multi-subscriber `onMessage` with isolated error capture per handler.
|
||||
- OTel counters: `game_server.network.messages_sent_total`,
|
||||
`…messages_received_total`, `…errors_total` (attribute `kind` ∈
|
||||
`{transport, parse, handler}`).
|
||||
- Optional injectable `Clock`, `AdapterLogger`, and `AdapterEventBus`.
|
||||
- Stats snapshot (`getStats`) for monitoring dashboards and tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NetworkAdapter,
|
||||
type Transport,
|
||||
} from "@suaveplan/game-server/network/network-adapter";
|
||||
|
||||
const adapter = new NetworkAdapter();
|
||||
await adapter.initialize("websocket", myTransport, "wss://game.example/");
|
||||
|
||||
const off = adapter.onMessage((msg) => {
|
||||
if (msg.type === "move") {
|
||||
applyMove(msg.payload);
|
||||
}
|
||||
});
|
||||
|
||||
await adapter.send({ type: "ping", payload: {}, timestamp: 0 });
|
||||
|
||||
off();
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { NetworkAdapter } from "@suaveplan/game-server/network/network-adapter";
|
||||
import type { FakeClock } from "@suaveplan/testing";
|
||||
|
||||
const fake: FakeClock = createFakeClock();
|
||||
const adapter = new NetworkAdapter({
|
||||
clock: () => fake.now(),
|
||||
logger: {
|
||||
debug: (m, ctx) => log("debug", m, ctx),
|
||||
info: (m, ctx) => log("info", m, ctx),
|
||||
warn: (m, ctx) => log("warn", m, ctx),
|
||||
error: (m, ctx) => log("error", m, ctx),
|
||||
},
|
||||
events: {
|
||||
emit: (type, payload) => bus.publish(type, payload),
|
||||
},
|
||||
});
|
||||
|
||||
const handler = (msg: NetworkMessage) => {
|
||||
throw new Error("rogue listener");
|
||||
};
|
||||
adapter.onMessage(handler);
|
||||
// Subsequent inbound frames still reach every other handler. The error
|
||||
// is counted under `game_server.network.errors_total{kind="handler"}`.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class NetworkAdapter`
|
||||
|
||||
- `constructor(deps?: NetworkAdapterDeps)` — optional `clock`, `logger`,
|
||||
`events`.
|
||||
- `initialize(transportType, transport, url, options?): Promise<void>`
|
||||
- `send(message: NetworkMessage): Promise<void>` — fills `timestamp` from
|
||||
the injected clock when omitted.
|
||||
- `onMessage(handler): () => void` — returns unsubscribe thunk.
|
||||
- `disconnect(): Promise<void>`
|
||||
- `dispose(): Promise<void>` — disconnect and clear handlers.
|
||||
- `isConnected(): boolean`
|
||||
- `getTransportType(): TransportType | null`
|
||||
- `getStats(): NetworkAdapterStats`
|
||||
|
||||
### `interface Transport`
|
||||
|
||||
Structural contract: `connect`, `disconnect`, `send`, `onMessage`,
|
||||
`onError`, `onConnect`, `onDisconnect`, `isConnected`.
|
||||
|
||||
### `NetworkMessageSchema`
|
||||
|
||||
`z.object({ type, payload, timestamp, sessionId?, playerId? })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Transport` callbacks are registered in `initialize` before `connect`, so
|
||||
synchronous fire-on-open transports cannot miss the first event. The
|
||||
inbound delivery loop re-validates every frame so that malformed wire
|
||||
data from any transport surfaces as a single typed counter increment
|
||||
rather than escaping into application code. Per-handler error capture
|
||||
uses a synchronous `try/catch` because the structural contract is
|
||||
synchronous — async handlers are still supported but their rejection is
|
||||
the application's responsibility. The OTel counters are module-level
|
||||
exports so multiple adapter instances share the same metric stream;
|
||||
attributes (`type`, `transportType`, `kind`) provide per-instance
|
||||
breakdowns at query time.
|
||||
@@ -0,0 +1,119 @@
|
||||
# ReconnectionManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ReconnectionManager` drives transport reconnection with exponential
|
||||
backoff and configurable jitter for the `@suaveplan/game-server` package.
|
||||
It is transport-agnostic — the application supplies a `reconnect`
|
||||
callback and the manager schedules calls into it through an injectable
|
||||
`TimersLike`, advancing the backoff envelope on each failure and resetting
|
||||
on each success.
|
||||
|
||||
The implementation delegates the backoff calculation to
|
||||
`@suaveplan/retry`'s `createExponentialBackoff` + `applyJitter` so it
|
||||
shares the canonical jitter modes (`none`, `full`, `equal`,
|
||||
`decorrelated`) with every other retrying component in the workspace.
|
||||
State is fully observable through `getState()`, OTel counters
|
||||
(`game_server.network.reconnect_attempts_total`), and a histogram
|
||||
(`reconnect_delay_ms`).
|
||||
|
||||
## Features
|
||||
|
||||
- Exponential backoff with cap and jitter via `@suaveplan/retry`.
|
||||
- Injectable `Clock` and `TimersLike` for deterministic tests.
|
||||
- Configurable `maxAttempts` (0 = unlimited), `initialDelayMs`,
|
||||
`maxDelayMs`, `backoffBase`, `jitter` mode.
|
||||
- Typed errors: `RECONNECT_IN_PROGRESS`, `RECONNECT_EXHAUSTED`.
|
||||
- OTel counter with `outcome ∈ {success, failure, exhausted}`; histogram
|
||||
of scheduled delays.
|
||||
- Optional logger and event bus.
|
||||
- Hot config updates via `updateConfig`.
|
||||
- Idempotent `stop`, `reset`, `dispose`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { ReconnectionManager } from "@suaveplan/game-server/network/reconnection-manager";
|
||||
|
||||
const manager = new ReconnectionManager({
|
||||
reconnect: () => adapter.initialize("websocket", transport, url),
|
||||
clock: () => Date.now(),
|
||||
timers: {
|
||||
setTimer: (cb, ms) =>
|
||||
globalThis.setTimeout(cb, ms) as unknown as number,
|
||||
clearTimer: (h) => globalThis.clearTimeout(h as unknown as ReturnType<typeof setTimeout>),
|
||||
},
|
||||
});
|
||||
|
||||
manager.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { ReconnectionManager } from "@suaveplan/game-server/network/reconnection-manager";
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
|
||||
const fake = new FakeClock();
|
||||
const manager = new ReconnectionManager({
|
||||
reconnect: async () => {
|
||||
if (attemptsBeforeSuccess > 0) {
|
||||
attemptsBeforeSuccess -= 1;
|
||||
throw new Error("transient");
|
||||
}
|
||||
},
|
||||
clock: () => fake.now(),
|
||||
timers: fake.timers(),
|
||||
config: {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 2_000,
|
||||
backoffBase: 2,
|
||||
jitter: "none",
|
||||
},
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await fake.advance(5_000);
|
||||
expect(manager.getState().isReconnecting).toBe(false);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class ReconnectionManager`
|
||||
|
||||
- `constructor(deps: ReconnectionDeps)`
|
||||
- `start(): void` — begin reconnection.
|
||||
- `stop(): void` — cancel pending timer.
|
||||
- `forceReconnect(): void` — reset delay to initial and restart.
|
||||
- `reset(): void` — stop and clear counters.
|
||||
- `updateConfig(partial): void` — merge new options through Zod.
|
||||
- `getState(): ReconnectionSnapshot`
|
||||
- `dispose(): void`
|
||||
|
||||
### `ReconnectionConfigSchema`
|
||||
|
||||
`z.object({ maxAttempts, initialDelayMs, maxDelayMs, backoffBase, jitter })`
|
||||
with defaults `10 / 1000 / 30000 / 2 / "equal"`.
|
||||
|
||||
### `interface ReconnectionDeps`
|
||||
|
||||
`{ reconnect, clock, timers, logger?, events?, config? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`createExponentialBackoff` is configured with `jitter: "none"` so the
|
||||
function returns the raw envelope; jitter is applied at scheduling time
|
||||
through `applyJitter` to keep the envelope in `nextDelayMs` predictable
|
||||
across `getState` snapshots. Failed attempts advance `nextDelayMs` by
|
||||
calling the strategy with `attempts + 1`, matching the "next attempt"
|
||||
semantics expected by callers reading `getState().nextDelayMs` between
|
||||
attempts. The manager checks `isReconnecting` after each `await
|
||||
reconnect()` so a `stop()` invoked during an in-flight retry takes
|
||||
effect without an extra scheduling cycle. OTel instruments are
|
||||
module-level so multiple instances share the same metric stream,
|
||||
identified by the `outcome` attribute.
|
||||
@@ -0,0 +1,100 @@
|
||||
# WebRTCTransport
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebRTCTransport` is the game-server-tier `Transport` adapter for WebRTC
|
||||
data channels. It wraps an underlying client conforming to the
|
||||
structural `UnderlyingWebRTC` interface and exposes the unified
|
||||
`Transport` contract consumed by `NetworkAdapter`.
|
||||
|
||||
In production the underlying client is satisfied by a thin factory
|
||||
around `@suaveplan/network-transport-webrtc` (the WebRTC client
|
||||
with peer-connection lifecycle, ICE candidate handling, and STUN
|
||||
configuration). In tests the same contract is satisfied by an in-process
|
||||
fake double — no `vi.mock`, no real RTCPeerConnection.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural `UnderlyingWebRTC` boundary keeps the wrapper transport-
|
||||
agnostic.
|
||||
- JSON framing of every `NetworkMessage`.
|
||||
- Typed errors (`TRANSPORT_NOT_CONNECTED`, `TRANSPORT_NOT_INITIALIZED`,
|
||||
`INVALID_MESSAGE_FORMAT`).
|
||||
- Inbound parse failures surface through the `onError` channel.
|
||||
- Pass-through signalling helpers (`addIceCandidate`, `applyAnswer`) so
|
||||
application code can drive signalling without reaching into the
|
||||
underlying client directly.
|
||||
- Optional `options` bag forwarded to the underlying client (ICE servers,
|
||||
signalling-server URL, …).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { WebRTCTransport } from "@suaveplan/game-server/network/webrtc-transport";
|
||||
|
||||
const transport = new WebRTCTransport({ client: makeWebrtcClient() });
|
||||
transport.onMessage((m) => handleFrame(m));
|
||||
transport.onConnect(() => console.log("rtc open"));
|
||||
await transport.connect("signal://room/42", {
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
await transport.send({ type: "join", payload: {}, timestamp: 0 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { WebRTCTransport } from "@suaveplan/game-server/network/webrtc-transport";
|
||||
import type { UnderlyingWebRTC } from "@suaveplan/game-server/network/webrtc-transport";
|
||||
|
||||
const fake: UnderlyingWebRTC = {
|
||||
open: async () => undefined,
|
||||
close: async () => undefined,
|
||||
sendText: async () => undefined,
|
||||
onText: (h) => (textHandler = h),
|
||||
onOpen: (h) => (openHandler = h),
|
||||
onClose: (h) => (closeHandler = h),
|
||||
onError: (h) => (errorHandler = h),
|
||||
isOpen: () => true,
|
||||
addIceCandidate: async (c) => candidates.push(c),
|
||||
applyAnswer: async (a) => (lastAnswer = a),
|
||||
};
|
||||
|
||||
const transport = new WebRTCTransport({ client: fake });
|
||||
await transport.connect("signal://x", {});
|
||||
await transport.addIceCandidate({ candidate: "..." });
|
||||
await transport.applyAnswer({ type: "answer", sdp: "..." });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class WebRTCTransport implements Transport`
|
||||
|
||||
- `constructor(deps: { client: UnderlyingWebRTC })`
|
||||
- `connect(url, options?): Promise<void>` — `options` forwarded.
|
||||
- `disconnect(): Promise<void>`
|
||||
- `send(message): Promise<void>`
|
||||
- `onMessage`, `onError`, `onConnect`, `onDisconnect` — single-slot.
|
||||
- `isConnected(): boolean`
|
||||
- `addIceCandidate(candidate): Promise<void>` — throws if unsupported.
|
||||
- `applyAnswer(answer): Promise<void>` — throws if unsupported.
|
||||
|
||||
### `interface UnderlyingWebRTC`
|
||||
|
||||
`open / close / sendText / onText / onOpen / onClose / onError /
|
||||
isOpen` (+ optional `addIceCandidate`, `applyAnswer`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Signalling lives in the underlying client because it is highly
|
||||
deployment-specific (in-band over WebSocket, out-of-band over HTTP, etc).
|
||||
The wrapper provides typed pass-through helpers for `addIceCandidate`
|
||||
and `applyAnswer` so callers do not need a back-channel reference to the
|
||||
underlying client — keeping the dependency graph one-directional. As
|
||||
with `WebSocketTransport`, parse failures route through `onError`
|
||||
rather than throwing because `Transport` has no inbound back-pressure
|
||||
channel; the adapter's parse counter is where these are observed.
|
||||
@@ -0,0 +1,94 @@
|
||||
# WebSocketTransport
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebSocketTransport` is the game-server-tier `Transport` adapter for
|
||||
WebSocket connections. It wraps an underlying client conforming to the
|
||||
structural `UnderlyingWebSocket` interface and exposes the unified
|
||||
`Transport` contract consumed by `NetworkAdapter`.
|
||||
|
||||
In production the underlying client is satisfied by a thin factory
|
||||
around `@suaveplan/network-transport-websockets` (the WebSocket
|
||||
client with framing, heartbeat, and backpressure). In tests the same
|
||||
contract is satisfied by an in-process fake double, so we never need
|
||||
`vi.mock` or a live socket to exercise every code path.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural `UnderlyingWebSocket` boundary keeps the wrapper transport-
|
||||
agnostic.
|
||||
- JSON framing of every `NetworkMessage`.
|
||||
- Typed errors (`TRANSPORT_NOT_CONNECTED`, `INVALID_MESSAGE_FORMAT`).
|
||||
- Inbound parse failures surface through the `onError` channel rather
|
||||
than escaping into application code.
|
||||
- Optional `protocols` option passed through to the underlying client.
|
||||
- Synchronous handler registration so callers can wire callbacks before
|
||||
awaiting `connect`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { WebSocketTransport } from "@suaveplan/game-server/network/websocket-transport";
|
||||
|
||||
const transport = new WebSocketTransport({ client: makeNativeClient() });
|
||||
transport.onMessage((m) => handleFrame(m));
|
||||
transport.onConnect(() => console.log("ws open"));
|
||||
await transport.connect("wss://game.example/");
|
||||
await transport.send({ type: "join", payload: {}, timestamp: 0 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { WebSocketTransport } from "@suaveplan/game-server/network/websocket-transport";
|
||||
import type { UnderlyingWebSocket } from "@suaveplan/game-server/network/websocket-transport";
|
||||
|
||||
const fake: UnderlyingWebSocket = {
|
||||
open: async () => undefined,
|
||||
close: async () => undefined,
|
||||
sendText: async () => undefined,
|
||||
onText: (h) => (textHandler = h),
|
||||
onOpen: (h) => (openHandler = h),
|
||||
onClose: (h) => (closeHandler = h),
|
||||
onError: (h) => (errorHandler = h),
|
||||
isOpen: () => true,
|
||||
};
|
||||
|
||||
const transport = new WebSocketTransport({ client: fake });
|
||||
transport.onMessage((m) => seen.push(m));
|
||||
await transport.connect("wss://x/", { protocols: ["v1", "v2"] });
|
||||
textHandler('{"type":"ping","payload":{},"timestamp":1}');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class WebSocketTransport implements Transport`
|
||||
|
||||
- `constructor(deps: { client: UnderlyingWebSocket })`
|
||||
- `connect(url, options?): Promise<void>` — `options.protocols` forwarded.
|
||||
- `disconnect(): Promise<void>`
|
||||
- `send(message): Promise<void>` — throws typed error when disconnected.
|
||||
- `onMessage(handler)`, `onError(handler)`, `onConnect(handler)`,
|
||||
`onDisconnect(handler)` — single-slot registration.
|
||||
- `isConnected(): boolean`
|
||||
|
||||
### `interface UnderlyingWebSocket`
|
||||
|
||||
`open / close / sendText / onText / onOpen / onClose / onError /
|
||||
isOpen`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapper installs every callback on the underlying client in the
|
||||
constructor so that frames arriving immediately after `open()` (the
|
||||
common case with a hot reconnect) cannot be lost. Outbound serialisation
|
||||
catches `JSON.stringify` failures (circular references) and rethrows
|
||||
them as `GameServerError(INVALID_MESSAGE_FORMAT)`. Inbound parse
|
||||
failures route through `onError` rather than throwing because the
|
||||
unified `Transport` contract has no inbound back-pressure channel — the
|
||||
adapter's parse counter is the place where these are observed in
|
||||
production.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Observability — game-server OTel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the three OpenTelemetry instruments the `@suaveplan/game-server` engine, loop, and plugin manager emit. Centralising them here keeps meter construction at a single call site so the polyfilled in-memory `MeterProvider` installed by `tests/setup/polyfills.ts` is the only provider that ever services the package, regardless of which feature module imports first.
|
||||
|
||||
The three instruments map one-to-one to the requirements in the game-server specification: lifecycle counters for engine and plugin state machines, and a tick-duration histogram for the deterministic game loop. Each emission carries low-cardinality attributes (operation name, mode, outcome) so dashboards can aggregate without paying cardinality cost.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy `meter` binding via `metrics.getMeter("@suaveplan/game-server")` — resolves the globally registered MeterProvider on first access.
|
||||
- Three `record*` helpers wrapping `counter.add` / `histogram.record` with structured attribute objects.
|
||||
- Outcome tagging on lifecycle counters (`ok` / `error`) so a single counter covers both success and failure paths.
|
||||
- Zero runtime overhead when no MeterProvider is registered — the API returns no-op instruments.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { recordEngineLifecycle } from "../observability/otel.js";
|
||||
|
||||
await engine.start();
|
||||
recordEngineLifecycle("start", "ok");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { recordLoopTickDuration } from "../observability/otel.js";
|
||||
|
||||
const start = clock();
|
||||
await runTickPhases();
|
||||
const elapsed = clock() - start;
|
||||
recordLoopTickDuration(elapsed, "tick-based");
|
||||
```
|
||||
|
||||
Combined with the test polyfill's `flushTestMetrics()`, tests can assert
|
||||
that exactly one sample was emitted with the expected attribute set.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `engineLifecycleCounter: Counter`
|
||||
|
||||
Counter `game_server.engine.lifecycle_total`. Increment via
|
||||
`recordEngineLifecycle(op, outcome?)`.
|
||||
|
||||
### `loopTickDurationHistogram: Histogram`
|
||||
|
||||
Histogram `game_server.loop.tick_duration_ms` (unit `ms`). Record via
|
||||
`recordLoopTickDuration(durationMs, mode)`.
|
||||
|
||||
### `pluginLifecycleCounter: Counter`
|
||||
|
||||
Counter `game_server.plugin.lifecycle_total`. Increment via
|
||||
`recordPluginLifecycle(op, outcome?)`.
|
||||
|
||||
### `recordEngineLifecycle(op, outcome)`
|
||||
|
||||
`op` ∈ `{start, stop, dispose}`; `outcome` ∈ `{ok, error}` (explicit — no default).
|
||||
|
||||
### `recordLoopTickDuration(durationMs, mode)`
|
||||
|
||||
Records one sample tagged with `mode` ∈ `{tick-based, event-driven, hybrid}`.
|
||||
|
||||
### `recordPluginLifecycle(op, outcome)`
|
||||
|
||||
`op` ∈ `{install, uninstall, enable, disable}`; `outcome` ∈ `{ok, error}` (explicit).
|
||||
|
||||
### `sessionLifecycleCounter: Counter`
|
||||
|
||||
Counter `game_server.session.lifecycle_total`. Increment via
|
||||
`recordSessionLifecycle(from, to, outcome)`. Tags carry low-cardinality
|
||||
state literals (`lobby`/`active`/`ended`).
|
||||
|
||||
### `sessionDwellHistogram: Histogram`
|
||||
|
||||
Histogram `game_server.session.dwell_ms` (unit `ms`). Record via
|
||||
`recordSessionDwell(state, durationMs)` whenever a session leaves a state.
|
||||
|
||||
### `playerLifecycleCounter: Counter`
|
||||
|
||||
Counter `game_server.player.lifecycle_total`. Increment via
|
||||
`recordPlayerLifecycle(op, outcome)` where `op` ∈
|
||||
`{connect, disconnect, reconnect, timeout, heartbeat}`.
|
||||
|
||||
### `recordSessionLifecycle(from, to, outcome)`
|
||||
|
||||
Records one transition; both `from` and `to` are emitted as attributes so
|
||||
dashboards can aggregate by either side of the transition.
|
||||
|
||||
### `recordSessionDwell(state, durationMs)`
|
||||
|
||||
Records one sample tagged with the state being left.
|
||||
|
||||
### `recordPlayerLifecycle(op, outcome)`
|
||||
|
||||
`op` ∈ `{connect, disconnect, reconnect, timeout, heartbeat}`; `outcome` ∈
|
||||
`{ok, error}` (explicit — no default).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The OTel meter is resolved at module import — this means the global
|
||||
`MeterProvider` must be installed BEFORE any source module is imported, or
|
||||
the meter binds to the API's default no-op provider and never reattaches.
|
||||
The package's `tests/setup/polyfills.ts` enforces this ordering by installing
|
||||
an `InMemoryMetricExporter`-backed `MeterProvider` at the very top of the
|
||||
file, before any `@suaveplan/game-server` symbol is referenced.
|
||||
|
||||
The counters use low-cardinality attributes only. `op` is a closed string
|
||||
enum, `outcome` is a closed two-value enum, and `mode` is the closed
|
||||
`GameLoopMode` enum. There is no per-plugin-name labelling on the plugin
|
||||
counter — the plugin name lives in the structured-logger event, not the
|
||||
metric attribute set.
|
||||
@@ -0,0 +1,127 @@
|
||||
# NetworkTierAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`NetworkTierAdapter` bridges the game-server's matchmaking + session
|
||||
layer to the `@suaveplan/network` tier model. Each tier (host /
|
||||
sub-host / player / spectator) carries an index, name, node-type, and
|
||||
capacity limits; the adapter ingests those tier definitions at
|
||||
construction time and accepts per-node capability assessments (CPU,
|
||||
memory, bandwidth, composite score) through
|
||||
`registerNodeCapabilities`. It then exposes lookups for optimal-tier
|
||||
selection and load-balancing recommendations that downstream routers
|
||||
or autoscalers can consume.
|
||||
|
||||
The adapter is universal and dependency-free apart from
|
||||
`@suaveplan/error`, Zod (for boundary validation), and the
|
||||
OpenTelemetry `metrics` API. Tier-list construction and capability
|
||||
registration both validate inputs strictly: an empty tier list,
|
||||
malformed entries, duplicate indexes, empty node ids, or out-of-range
|
||||
capability scores all raise `INVALID_TIER_CONFIG`. Unknown-tier
|
||||
queries raise `UNKNOWN_TIER`.
|
||||
|
||||
## Features
|
||||
|
||||
- Tier registry keyed by both `index` and `nodeType`.
|
||||
- Per-node capability registration with a synchronous update listener.
|
||||
- `getOptimalTier(capabilities)` — score-driven tier selection with
|
||||
graceful fallback when a preferred node-type is missing from the
|
||||
registered tier list.
|
||||
- `getLoadBalancingRecommendations()` — per-node load + reason,
|
||||
sorted by score descending.
|
||||
- Capacity check (`validateTierCapacity`) and per-tier max-children.
|
||||
- Stats snapshot (`getStats`) with per-tier histogram + average score.
|
||||
- One OTel counter — `capability_updates_total`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { NetworkTierAdapter } from "@suaveplan/game-server/performance/network-tier-adapter";
|
||||
|
||||
const adapter = new NetworkTierAdapter([
|
||||
{ index: 0, name: "host", nodeType: "host", maxNodes: 4, maxChildrenPerNode: 16 },
|
||||
{ index: 1, name: "sub-host", nodeType: "sub-host", maxNodes: 16, maxChildrenPerNode: 32 },
|
||||
{ index: 2, name: "player", nodeType: "player", maxNodes: 1000, maxChildrenPerNode: 0 },
|
||||
]);
|
||||
|
||||
adapter.registerNodeCapabilities("node-eu-west-1", {
|
||||
cpu: { usage: 35, cores: 8 },
|
||||
memory: { used: 4_000_000, total: 32_000_000, available: 28_000_000 },
|
||||
bandwidth: { upload: 1000, download: 1000, available: 800 },
|
||||
score: 92,
|
||||
});
|
||||
|
||||
const tier = adapter.getOptimalTier({
|
||||
cpu: { usage: 0, cores: 1 },
|
||||
memory: { used: 0, total: 1, available: 1 },
|
||||
bandwidth: { upload: 0, download: 0, available: 0 },
|
||||
score: 92,
|
||||
});
|
||||
console.log(tier.name); // "host"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { NetworkTierAdapter } from "@suaveplan/game-server/performance/network-tier-adapter";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const adapter = new NetworkTierAdapter(tiers, {
|
||||
meter: metrics.getMeter("game-server.network-tier"),
|
||||
onCapabilityUpdated: (nodeId, capabilities) => {
|
||||
bus.publish("node:capability-updated", { nodeId, capabilities });
|
||||
},
|
||||
});
|
||||
|
||||
for (const peer of liveCluster) {
|
||||
adapter.registerNodeCapabilities(peer.id, peer.capabilities);
|
||||
}
|
||||
|
||||
const recs = adapter.getLoadBalancingRecommendations();
|
||||
for (const rec of recs) {
|
||||
router.setTier(rec.nodeId, rec.tier, rec.recommendedLoad);
|
||||
}
|
||||
|
||||
const stats = adapter.getStats();
|
||||
console.log(stats.tierDistribution); // { host: 4, "sub-host": 11, player: 87 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new NetworkTierAdapter(tiers, deps?)` — throws
|
||||
`INVALID_TIER_CONFIG` on empty / malformed / duplicate-index tiers.
|
||||
- `registerNodeCapabilities(nodeId, caps): void` — throws
|
||||
`INVALID_TIER_CONFIG` on empty id or malformed capability shape.
|
||||
- `getTierForNodeType(nodeType): NetworkTier` — throws `UNKNOWN_TIER`
|
||||
on miss.
|
||||
- `getTierByIndex(index): NetworkTier` — throws `UNKNOWN_TIER` on miss.
|
||||
- `getOptimalTier(caps): NetworkTier` — score thresholds: ≥80 host,
|
||||
≥50 sub-host, else player. Falls back when preferred type missing.
|
||||
- `getLoadBalancingRecommendations(): readonly LoadBalancingRecommendation[]`.
|
||||
- `getTierHierarchy(): readonly NetworkTier[]` — sorted by index.
|
||||
- `validateTierCapacity(tierIndex, currentNodeCount): boolean`.
|
||||
- `getMaxChildren(tierIndex): number`.
|
||||
- `getNodeCapabilities(nodeId): NodeCapabilities | null`.
|
||||
- `getStats(): NetworkTierAdapterStats`.
|
||||
- `clear(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter copies validated tier entries into both `byIndex` and
|
||||
`byNodeType` maps so lookups are constant-time without iterating the
|
||||
input array. Multiple tiers sharing a `nodeType` are supported, with
|
||||
first-wins semantics for the `byNodeType` lookup (the upstream model
|
||||
uses one tier per type in practice, but the adapter avoids encoding
|
||||
that constraint). Score-bucket fallbacks deliberately mirror the
|
||||
upstream behaviour: if the host slot is missing the highest-rated
|
||||
tier wins; if sub-host is missing it falls back to `tiers[min(1,
|
||||
len-1)]`; if player is missing the last tier wins. Capability
|
||||
registration runs through Zod's `.finite()`-tightened number checks so
|
||||
non-finite scores cannot leak into recommendation math. The
|
||||
synchronous `onCapabilityUpdated` callback runs after the map has been
|
||||
updated so listeners that re-query through the adapter see consistent
|
||||
state.
|
||||
@@ -0,0 +1,120 @@
|
||||
# ObjectPool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ObjectPool<T>` is a generic typed object pool that dampens GC pressure
|
||||
in hot allocation paths — game-loop frame allocations, network message
|
||||
envelopes, session metadata containers — by recycling typed objects
|
||||
instead of letting them churn through the GC. Each pool is configured
|
||||
with a `factory` (creates a new instance), a `reset` (clears state
|
||||
before an instance returns to the pool), and an optional `dispose`
|
||||
(invoked on cleanup paths so callers can release native handles).
|
||||
|
||||
The pool is dependency-free apart from the typed
|
||||
`GameServerError` registry, Zod (for numeric-config validation), and
|
||||
the OpenTelemetry `metrics` API. Misuse is loud: releasing an object
|
||||
that didn't come from this pool raises `RELEASE_FOREIGN_OBJECT`;
|
||||
acquiring past the configured `maxSize` raises `POOL_EXHAUSTED`; using
|
||||
a pool after `dispose()` raises `POOL_DISPOSED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `initialSize`, `maxSize`, and pre-allocation toggle.
|
||||
- Typed factory + reset + optional dispose hooks.
|
||||
- `acquire`, `release`, and `withObject(callback)` (auto-release with
|
||||
finally semantics).
|
||||
- Hard cap enforcement at acquisition time, with typed-error rejection.
|
||||
- Lifecycle: `clear()` (resets stats + drops pool, fires dispose),
|
||||
`dispose()` (terminal, all subsequent calls throw).
|
||||
- Two OTel counters — `pool.acquire_total{outcome=reused|created}` and
|
||||
`pool.release_total`.
|
||||
- Stats snapshot — size, available, in-use, totals, hit-rate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { ObjectPool } from "@suaveplan/game-server/performance/object-pool";
|
||||
|
||||
interface Vec3 { x: number; y: number; z: number; }
|
||||
const pool = new ObjectPool<Vec3>({
|
||||
factory: () => ({ x: 0, y: 0, z: 0 }),
|
||||
reset: (v) => { v.x = v.y = v.z = 0; },
|
||||
initialSize: 64,
|
||||
});
|
||||
|
||||
const v = pool.acquire();
|
||||
v.x = 1; v.y = 2;
|
||||
pool.release(v);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { ObjectPool } from "@suaveplan/game-server/performance/object-pool";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
interface PacketBuffer { bytes: Uint8Array; offset: number; }
|
||||
const pool = new ObjectPool<PacketBuffer>(
|
||||
{
|
||||
factory: () => ({ bytes: new Uint8Array(2048), offset: 0 }),
|
||||
reset: (b) => {
|
||||
b.bytes.fill(0);
|
||||
b.offset = 0;
|
||||
},
|
||||
dispose: (b) => {
|
||||
// Optionally hand the underlying buffer to an async ring.
|
||||
ringFree.push(b.bytes);
|
||||
},
|
||||
initialSize: 32,
|
||||
maxSize: 512,
|
||||
},
|
||||
{ meter: metrics.getMeter("game-server.network") },
|
||||
);
|
||||
|
||||
// Hot loop: borrow a buffer per packet, auto-release on errors.
|
||||
await pool.withObject(async (buffer) => {
|
||||
await readInto(buffer.bytes);
|
||||
await transport.send(buffer.bytes);
|
||||
});
|
||||
|
||||
const stats = pool.getStats();
|
||||
console.log(`hit rate ${stats.hitRate}`);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ObjectPool<T>(config: ObjectPoolConfig<T>, deps?: ObjectPoolDeps)`
|
||||
— validates the numeric config via Zod; throws
|
||||
`INVALID_POOL_CONFIG` on missing `factory`/`reset` or invalid
|
||||
numeric fields.
|
||||
- `acquire(): T` — returns a recycled or freshly factory-allocated
|
||||
object. Throws `POOL_EXHAUSTED` when the pool is empty AND
|
||||
`pool+inUse >= maxSize`. Throws `POOL_DISPOSED` after dispose.
|
||||
- `release(obj: T): void` — throws `RELEASE_FOREIGN_OBJECT` if the
|
||||
object was not handed out by this pool; throws `POOL_DISPOSED` after
|
||||
dispose.
|
||||
- `withObject(cb): Promise<R>` — borrows + finally-releases.
|
||||
- `clear()` — drops every pooled object and resets stats (fires
|
||||
dispose hook).
|
||||
- `dispose()` — terminal, idempotent. Subsequent acquire/release/clear
|
||||
raise `POOL_DISPOSED`. Fires dispose hook on every pooled object.
|
||||
- `getStats(): PoolStats`.
|
||||
- `disposed: boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cap check at acquire is `pool.length + inUse.size >= maxSize`. When
|
||||
hit AND `pool.length === 0`, acquisition fails with `POOL_EXHAUSTED`.
|
||||
When hit AND `pool.length > 0`, the pool reuses an existing instance —
|
||||
this is the steady-state happy path for a saturated pool. Release
|
||||
always succeeds back into the pool because the cap is enforced at
|
||||
acquire, never letting `pool + inUse` exceed `maxSize`, so there is no
|
||||
unreachable eviction branch to test. The `dispose` hook fires only on
|
||||
`clear()` and `dispose()` paths, where the pool is being torn down or
|
||||
emptied. Pre-allocation runs the factory synchronously inside
|
||||
the constructor, which keeps every `acquire` constant-time in steady
|
||||
state.
|
||||
@@ -0,0 +1,118 @@
|
||||
# SessionPool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionPool` is a session-specific wrapper around `ObjectPool<T>` that
|
||||
recycles `PooledSession` records — plain-data session containers
|
||||
(`id`, `type`, `metadata`, `children[]`) — between game-loop frames.
|
||||
The wrapper presents a "fresh session per call" API
|
||||
(`acquireSession(data) → PooledSession`) while internally amortising
|
||||
the underlying allocation across borrows, which keeps GC pressure low
|
||||
in workloads that churn through thousands of short-lived game sessions
|
||||
per minute (matchmaking server, tournament orchestrator, tutorial sim).
|
||||
|
||||
The pool is universal. It owns no logic beyond what `ObjectPool`
|
||||
already provides; the wrapper exists so the factory + reset semantics
|
||||
stay close to the session shape and so callers can inject a `Clock` for
|
||||
deterministic placeholder timestamps without poking at the underlying
|
||||
pool's `factory`.
|
||||
|
||||
## Features
|
||||
|
||||
- Typed wrapper over `ObjectPool<PooledSession>`.
|
||||
- Configurable `initialSize` (default 20), `maxSize` (default 100), and
|
||||
`preAllocate` toggle.
|
||||
- Injectable `Clock` for deterministic placeholder timestamps.
|
||||
- `acquireSession(data)`, `releaseSession(session)`, and auto-release
|
||||
`withSession(data, callback)`.
|
||||
- `getStats()` forwards `ObjectPool`'s `PoolStats` for dashboard reuse.
|
||||
- Forwarded lifecycle: `clear()`, `dispose()`, `disposed` getter.
|
||||
- Default session type configurable via `defaultSessionType`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionPool } from "@suaveplan/game-server/performance/session-pool";
|
||||
|
||||
const pool = new SessionPool();
|
||||
const session = pool.acquireSession({
|
||||
id: "match-42",
|
||||
type: "game",
|
||||
metadata: { createdAt: Date.now(), updatedAt: Date.now() },
|
||||
});
|
||||
session.children.push("player-alice", "player-bob");
|
||||
|
||||
pool.releaseSession(session);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SessionPool } from "@suaveplan/game-server/performance/session-pool";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const pool = new SessionPool(
|
||||
{
|
||||
initialSize: 100,
|
||||
maxSize: 2_000,
|
||||
defaultSessionType: "queue",
|
||||
},
|
||||
{
|
||||
clock: () => Date.now(),
|
||||
meter: metrics.getMeter("game-server.sessions"),
|
||||
},
|
||||
);
|
||||
|
||||
// Hot loop: every match-found event borrows a session, populates it,
|
||||
// hands it to a downstream coordinator, and releases it on cleanup.
|
||||
await pool.withSession(
|
||||
{
|
||||
id: `match-${crypto.randomUUID()}`,
|
||||
type: "game",
|
||||
metadata: {
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
queueKey: "ranked",
|
||||
},
|
||||
},
|
||||
async (session) => {
|
||||
for (const playerId of matchedPlayers) {
|
||||
session.children.push(playerId);
|
||||
}
|
||||
await coordinator.start(session);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SessionPool(config?: SessionPoolConfig, deps?: SessionPoolDeps)`.
|
||||
- `acquireSession(data: PooledSessionData): PooledSession` — stamps the
|
||||
borrowed record with caller fields, clears `children`, returns.
|
||||
- `releaseSession(session: PooledSession): void` — forwards to
|
||||
`ObjectPool.release`; throws `RELEASE_FOREIGN_OBJECT` on a foreign
|
||||
object, `POOL_DISPOSED` after dispose.
|
||||
- `withSession(data, callback): Promise<R>` — borrows + finally-releases.
|
||||
- `getStats(): PoolStats`.
|
||||
- `clear()` / `dispose()` / `disposed: boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`acquireSession` always overwrites the record's identity and metadata
|
||||
fields before returning it, so callers never observe stale placeholder
|
||||
data left over from a previous borrow. The `children` array is
|
||||
emptied via `length = 0` rather than reassigned to a new array, which
|
||||
keeps the underlying buffer (and its hidden-class shape) alive — a
|
||||
small but measurable win in V8 hot loops. The factory's placeholder id
|
||||
counter increments monotonically across both factory + reset paths so
|
||||
production logs that capture a borrowed session before the caller
|
||||
overwrote its id can be debugged unambiguously. The wrapper does not
|
||||
re-export `ObjectPool`'s underlying counters — instead the OTel meter
|
||||
is forwarded into the underlying pool so the same `pool.acquire_total`
|
||||
/ `pool.release_total` instruments carry the session pool's
|
||||
contribution. Dashboards can disambiguate by attaching pool-name
|
||||
attributes on a thin wrapper if needed.
|
||||
@@ -0,0 +1,103 @@
|
||||
# ABTestPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ABTestPlugin` registers weighted experiments and assigns players to variants
|
||||
deterministically via an injected `Rng`. It records conversion events per
|
||||
(experiment, player) pair and exposes per-variant summary statistics so the
|
||||
game engine can drive data-informed balance decisions without taking a
|
||||
runtime dep on a separate experiments service.
|
||||
|
||||
The plugin is intentionally self-contained: it owns the experiment catalog,
|
||||
the assignment map, and the conversion log in memory. Persistence and
|
||||
statistical tests (z-test, chi-squared) are out of scope — callers compose
|
||||
the plugin with their preferred storage and analysis tooling.
|
||||
|
||||
## Features
|
||||
|
||||
- Weighted variant bucketing via `Rng.nextFloat()` (deterministic with a
|
||||
seeded RNG).
|
||||
- Idempotent assignment: re-calling `assignPlayer` returns the same variant.
|
||||
- Per-experiment conversion log with arbitrary `goalId` and numeric value.
|
||||
- Per-variant summary: assignments, conversions, total value, conversion rate.
|
||||
- Zod-validated experiment input (id, name, ≥1 variant with positive weight).
|
||||
- Lifecycle-safe: clears every experiment on `onUninstall`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ABTestPlugin } from "@suaveplan/game-server/plugins/ab-test-plugin";
|
||||
|
||||
const rng = { nextFloat: () => Math.random(), nextUint32: () => 0 };
|
||||
const plugin = new ABTestPlugin({ rng });
|
||||
plugin.createExperiment({
|
||||
id: "exp-1",
|
||||
name: "Drop rate test",
|
||||
variants: [
|
||||
{ id: "control", weight: 1 },
|
||||
{ id: "treatment", weight: 1 },
|
||||
],
|
||||
});
|
||||
const a = plugin.assignPlayer("exp-1", "player-1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ABTestPlugin } from "@suaveplan/game-server/plugins/ab-test-plugin";
|
||||
|
||||
// Seeded RNG for deterministic test runs.
|
||||
let s = 1;
|
||||
const rng = {
|
||||
nextUint32: () => (s = (s * 9301 + 49297) % 233280),
|
||||
nextFloat: () => {
|
||||
s = (s * 9301 + 49297) % 233280;
|
||||
return s / 233280;
|
||||
},
|
||||
};
|
||||
|
||||
const plugin = new ABTestPlugin({ rng });
|
||||
plugin.createExperiment({
|
||||
id: "drop",
|
||||
name: "Drop rate test",
|
||||
variants: [
|
||||
{ id: "low", weight: 3 },
|
||||
{ id: "high", weight: 1 },
|
||||
],
|
||||
});
|
||||
plugin.assignPlayer("drop", "p1");
|
||||
plugin.recordConversion("drop", "p1", "first-kill", 1);
|
||||
const summary = plugin.getSummary("drop");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ABTestPlugin extends BasePlugin`
|
||||
- `constructor(options: ABTestPluginOptions)`
|
||||
- `createExperiment(input: ExperimentInput): void`
|
||||
- `hasExperiment(id: string): boolean`
|
||||
- `assignPlayer(experimentId: string, playerId: string): Assignment`
|
||||
- `getAssignment(experimentId: string, playerId: string): Assignment | undefined`
|
||||
- `recordConversion(experimentId: string, playerId: string, goalId: string, value?: number): Conversion`
|
||||
- `getSummary(experimentId: string): readonly VariantSummary[]`
|
||||
- `listExperiments(): readonly string[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Bucketing accumulates variant weights and compares against
|
||||
`nextFloat() * totalWeight`. The implementation falls back to the last
|
||||
variant if the accumulator never exceeds the threshold (a defensive
|
||||
guard against floating-point ulp drift when weights are tiny).
|
||||
- Recording a conversion for an unassigned player throws
|
||||
`GAME_SERVER.AB_TEST_INVALID_VARIANT` so callers can route the error to
|
||||
their analytics dead-letter queue rather than corrupting the report.
|
||||
- The summary recomputes from raw records on each call. The data sets are
|
||||
small (tens of variants, thousands of players); pre-aggregation would
|
||||
add latency on a rare path. Callers needing high-frequency dashboards
|
||||
should snapshot the summary at a checkpoint instead of polling.
|
||||
- The plugin uses `BasePlugin.assertLive` on every public method so a
|
||||
destroyed plugin cannot accidentally serve stale data.
|
||||
@@ -0,0 +1,97 @@
|
||||
# AnalyticsPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AnalyticsPlugin` tracks per-session counters and per-event activity for a
|
||||
`@suaveplan/game-server` engine. It subscribes to a `GameEventEmitter` at
|
||||
install time and routes structured `AnalyticsEvent` payloads through an
|
||||
injectable sink so callers can ship them anywhere — OTel, a queue, an HTTP
|
||||
endpoint, or stdout in tests.
|
||||
|
||||
The plugin keeps the sink decoupled from any concrete observability stack:
|
||||
the engine emits domain events; the plugin shapes them into a stable
|
||||
analytics envelope; the sink decides what to do next. That separation means
|
||||
the same plugin works in unit tests, local development, and production
|
||||
without a behaviour fork.
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to `session:created`, `session:destroyed`, `game:started`,
|
||||
`game:ended`, `player:joined`, `player:left`, and `move:processed`.
|
||||
- Per-session row with `startTime`, `endTime`, `playerIds`, `moveCount`,
|
||||
`completed`.
|
||||
- Injectable `Clock` (defaults to `Date.now`) for deterministic timestamps.
|
||||
- Injectable `AnalyticsSink` — receives `AnalyticsEvent` payloads.
|
||||
- Aggregate `GameMetrics` snapshot (`getMetrics`) including average session
|
||||
duration, abandonment count, and total player count.
|
||||
- Per-session snapshot (`getSession`, `exportSessions`).
|
||||
- Zod-validated `abandonAfterMs` option with a 10-minute default.
|
||||
- Lifecycle-safe: unsubscribes from the emitter on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
import { AnalyticsPlugin } from "@suaveplan/game-server/plugins/analytics-plugin";
|
||||
|
||||
const emitter = new GameEventEmitter();
|
||||
const sink = (event) => console.log(event);
|
||||
|
||||
const plugin = new AnalyticsPlugin({ emitter, sink });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
import { AnalyticsPlugin } from "@suaveplan/game-server/plugins/analytics-plugin";
|
||||
|
||||
const emitter = new GameEventEmitter();
|
||||
const events: unknown[] = [];
|
||||
|
||||
const plugin = new AnalyticsPlugin({
|
||||
emitter,
|
||||
sink: (e) => events.push(e),
|
||||
clock: () => 1_700_000_000_000,
|
||||
abandonAfterMs: 300_000,
|
||||
});
|
||||
|
||||
await plugin.onInstall(ctx);
|
||||
emitter.emit("game:started", { sessionId: "s1" });
|
||||
emitter.emit("player:joined", { playerId: "p1", sessionId: "s1" });
|
||||
emitter.emit("move:processed", { playerId: "p1", move: {}, newState: {} });
|
||||
emitter.emit("game:ended", {
|
||||
sessionId: "s1",
|
||||
result: { winnerId: "p1", scores: {} },
|
||||
});
|
||||
|
||||
const metrics = plugin.getMetrics();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class AnalyticsPlugin extends BasePlugin`
|
||||
- `constructor(options: AnalyticsPluginOptions)`
|
||||
- `getMetrics(): GameMetrics`
|
||||
- `getSession(sessionId): SessionAnalyticsSnapshot | undefined`
|
||||
- `exportSessions(): readonly SessionAnalyticsSnapshot[]`
|
||||
- `reset(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `session:created` from the engine surfaces a `SessionNode`; the plugin
|
||||
reads only `session.id` and never retains a reference to the node.
|
||||
- `game:started` is treated as session-create as well; back-to-back
|
||||
emissions of the same session ID are idempotent.
|
||||
- A session counts as abandoned once `now - startTime > abandonAfterMs`
|
||||
AND it is still active (not completed). The default 10-minute window
|
||||
matches the upstream behaviour but can be tightened via `abandonAfterMs`.
|
||||
- The plugin unsubscribes from every event on `onUninstall`, so a
|
||||
re-installable engine cannot accumulate dangling listeners.
|
||||
- The sink call is synchronous; callers that need async fan-out should
|
||||
wrap their async logic in their own queue. This keeps the engine's hot
|
||||
event path off the microtask queue.
|
||||
@@ -0,0 +1,106 @@
|
||||
# BasePlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BasePlugin` is the abstract base class every concrete `@suaveplan/game-server`
|
||||
plugin extends. It implements the `EnginePlugin` contract that the
|
||||
`PluginManager` consumes (`onInstall`, `onUninstall`, `onEnable`, `onDisable`)
|
||||
and forwards each call to a `protected` template-method hook
|
||||
(`onInstallHook` etc.) that subclasses override.
|
||||
|
||||
The base also owns a small lifecycle state machine
|
||||
(`created → installed → enabled → disabled → destroyed`) and refuses to run
|
||||
any hook once the plugin has reached `destroyed`. This catches the common
|
||||
"resurrect a torn-down plugin" bug at the boundary instead of letting it
|
||||
corrupt downstream state.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `EnginePlugin` so the `PluginManager` can install/enable/disable
|
||||
any subclass without per-plugin glue.
|
||||
- Template-method hooks (`onInstallHook`, `onUninstallHook`,
|
||||
`onEnableHook`, `onDisableHook`) — subclasses override only what they need.
|
||||
- Injectable `Clock` for deterministic timestamping; defaults to `Date.now`.
|
||||
- Injectable `LoggerLike` (defaults to no-op until installed; install
|
||||
promotes the manager-supplied logger).
|
||||
- Public `state` getter and `snapshot()` accessor for observability.
|
||||
- `assertLive(op)` helper subclasses call from public methods to fail fast
|
||||
on use-after-destroy with `GAME_SERVER.PLUGIN_BASE_DESTROYED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BasePlugin } from "@suaveplan/game-server/plugins/base-plugin";
|
||||
|
||||
class HelloPlugin extends BasePlugin {
|
||||
public readonly name = "hello";
|
||||
public readonly version = "1.0.0";
|
||||
|
||||
protected async onInstallHook(): Promise<void> {
|
||||
this.logger.info("hello plugin installed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BasePlugin } from "@suaveplan/game-server/plugins/base-plugin";
|
||||
import type { EnginePluginContext } from "@suaveplan/game-server/plugins/base-plugin";
|
||||
|
||||
class CountingPlugin extends BasePlugin {
|
||||
public readonly name = "counter";
|
||||
public readonly version = "1.0.0";
|
||||
public readonly dependencies = ["hello"] as const;
|
||||
|
||||
private installs = 0;
|
||||
|
||||
public constructor() {
|
||||
super({ clock: () => 1_700_000_000_000 });
|
||||
}
|
||||
|
||||
protected async onInstallHook(_ctx: EnginePluginContext): Promise<void> {
|
||||
this.installs += 1;
|
||||
}
|
||||
|
||||
public count(): number {
|
||||
this.assertLive("count");
|
||||
return this.installs;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BasePlugin implements EnginePlugin`
|
||||
- `abstract readonly name: string`
|
||||
- `abstract readonly version: string`
|
||||
- `readonly dependencies?: readonly string[]`
|
||||
- `get state(): BasePluginState`
|
||||
- `snapshot(): BasePluginSnapshot`
|
||||
- `onInstall(ctx): Promise<void>`
|
||||
- `onUninstall(ctx): Promise<void>`
|
||||
- `onEnable(ctx): Promise<void>`
|
||||
- `onDisable(ctx): Promise<void>`
|
||||
- `protected onInstallHook(ctx): Promise<void>` (override)
|
||||
- `protected onUninstallHook(ctx): Promise<void>` (override)
|
||||
- `protected onEnableHook(ctx): Promise<void>` (override)
|
||||
- `protected onDisableHook(ctx): Promise<void>` (override)
|
||||
- `protected assertLive(op: string): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The state machine is deliberately minimal: every hook except `onUninstall`
|
||||
throws `PLUGIN_BASE_DESTROYED` when the plugin has already been torn down.
|
||||
`onUninstall` itself is also rejected after destroy because the
|
||||
`PluginManager` removes the entry from its map on the first uninstall.
|
||||
- The logger field starts as a no-op so subclass constructors can log
|
||||
defensively without crashing tests that omit a logger; once
|
||||
`onInstall` runs, the context logger from `PluginManager` is adopted
|
||||
unless the caller supplied an explicit override via `BasePluginOptions`.
|
||||
- The clock is captured at construction time so callers can substitute a
|
||||
`FakeClock` from `@suaveplan/testing` for deterministic snapshots.
|
||||
@@ -0,0 +1,100 @@
|
||||
# FeatureFlagPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FeatureFlagPlugin` wraps a `@suaveplan/feature-flags` `FlagManager` and
|
||||
exposes its evaluation surface as a lifecycle-managed game-server plugin.
|
||||
It folds a per-player `userId` into the evaluation context automatically
|
||||
and records every evaluation to a rotating log so callers can replay or
|
||||
audit flag decisions without instrumenting every callsite by hand.
|
||||
|
||||
The plugin is intentionally a thin adapter: it does not own the
|
||||
provider chain, override semantics, or persistence story — those remain
|
||||
in `@suaveplan/feature-flags`. Errors raised by the underlying manager
|
||||
(notably `FLAG_NOT_FOUND`) propagate unchanged so callers can catch the
|
||||
canonical `FeatureFlagsError` discriminator.
|
||||
|
||||
## Features
|
||||
|
||||
- Lifecycle-managed via `BasePlugin` — clears the evaluation log on
|
||||
uninstall.
|
||||
- Per-player `EvaluationContext` building from `defaultContext + playerId`.
|
||||
- `isEnabled` / `evaluate` / `getString` / `getNumber` helpers wrapping
|
||||
the underlying manager API.
|
||||
- Rotating evaluation log (FIFO; default cap 1 000) for downstream
|
||||
analytics.
|
||||
- Zod-validated `maxLogEntries` option.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
FlagManager,
|
||||
FlagRegistry,
|
||||
Evaluator,
|
||||
MemoryProvider,
|
||||
} from "@suaveplan/feature-flags";
|
||||
import { FeatureFlagPlugin } from "@suaveplan/game-server/plugins/feature-flag-plugin";
|
||||
|
||||
const provider = new MemoryProvider({
|
||||
flags: [
|
||||
{ key: "boss-rush", type: "boolean", defaultValue: false },
|
||||
],
|
||||
});
|
||||
const registry = new FlagRegistry({ provider });
|
||||
await registry.load();
|
||||
const evaluator = new Evaluator();
|
||||
const flagManager = new FlagManager({ registry, evaluator });
|
||||
|
||||
const plugin = new FeatureFlagPlugin({ flagManager });
|
||||
plugin.isEnabled("boss-rush", "player-1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FeatureFlagPlugin } from "@suaveplan/game-server/plugins/feature-flag-plugin";
|
||||
|
||||
const plugin = new FeatureFlagPlugin({
|
||||
flagManager,
|
||||
defaultContext: { attributes: { region: "us-west" } },
|
||||
maxLogEntries: 50,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
await plugin.onInstall(ctx);
|
||||
plugin.evaluate("boss-rush", "p1");
|
||||
plugin.getString("hero-skin", "p1");
|
||||
plugin.getNumber("drop-rate-multiplier", "p1");
|
||||
const log = plugin.getEvaluationLog();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FeatureFlagPlugin extends BasePlugin`
|
||||
- `constructor(options: FeatureFlagPluginOptions)`
|
||||
- `isEnabled(key, playerId?): boolean`
|
||||
- `evaluate<T>(key, playerId?): FlagEvaluationResult<T>`
|
||||
- `getString(key, playerId?): string`
|
||||
- `getNumber(key, playerId?): number`
|
||||
- `getEvaluationLog(): readonly EvaluationLogEntry[]`
|
||||
- `clearLog(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The evaluation log is bounded; once `evaluationLog.length` exceeds
|
||||
`maxLogEntries`, the oldest entries are pruned via `slice(-max)`.
|
||||
- `buildContext` merges `defaultContext` with the per-call player id.
|
||||
`userId` from the default context is preserved when the caller does
|
||||
not supply a `playerId`.
|
||||
- Errors thrown by `FlagManager` (e.g. `FLAG_NOT_FOUND`) bubble up as
|
||||
`FeatureFlagsError`. The plugin does not translate them so callers
|
||||
retain access to the underlying error code without an extra cause
|
||||
unwrap.
|
||||
- The plugin holds the `flagManager` by reference and never disposes it.
|
||||
Disposal is the caller's responsibility because the manager may be
|
||||
shared across plugins.
|
||||
@@ -0,0 +1,89 @@
|
||||
# LoggingPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LoggingPlugin` subscribes to a `GameEventEmitter` at install time and
|
||||
forwards every observed event to an injected `LoggerLike` sink. In addition,
|
||||
it retains a bounded ring buffer of structured `LogEntry` records that the
|
||||
caller can inspect by recency, level, or event name. The buffer is meant for
|
||||
local diagnostics — production deployments rely on the `LoggerLike` sink
|
||||
plus a separate log-aggregation backend.
|
||||
|
||||
The plugin is composable: subscriptions for player, game, and session events
|
||||
are individually toggleable so callers can opt out of high-volume topics
|
||||
(`move:processed` is `debug`-level by default and easy to silence by
|
||||
disabling game logging).
|
||||
|
||||
## Features
|
||||
|
||||
- Structured `LogEntry` ring buffer with FIFO eviction at `maxEntries`
|
||||
(default 5 000).
|
||||
- Per-event level mapping: `info` for player/game/session lifecycle,
|
||||
`debug` for `move:processed`, `warn` for `move:rejected` and
|
||||
`player:disconnected`.
|
||||
- Injectable `LoggerLike` sink; the plugin forwards every entry verbatim.
|
||||
- Granular toggles: `enablePlayerLogging`, `enableGameLogging`,
|
||||
`enableSessionLogging`.
|
||||
- Read-only inspection: `getRecent`, `getByLevel`, `getByEvent`, `getStats`.
|
||||
- Lifecycle-safe: unsubscribes from the emitter on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
import { LoggingPlugin } from "@suaveplan/game-server/plugins/logging-plugin";
|
||||
|
||||
const emitter = new GameEventEmitter();
|
||||
const logger = console;
|
||||
const plugin = new LoggingPlugin({ emitter, logger });
|
||||
await plugin.onInstall(ctx);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LoggingPlugin } from "@suaveplan/game-server/plugins/logging-plugin";
|
||||
|
||||
const plugin = new LoggingPlugin({
|
||||
emitter,
|
||||
logger,
|
||||
maxEntries: 100,
|
||||
enableGameLogging: false,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
await plugin.onInstall(ctx);
|
||||
emitter.emit("player:joined", { playerId: "p1", sessionId: "s1" });
|
||||
emitter.emit("player:disconnected", { playerId: "p1", reason: "timeout" });
|
||||
const warns = plugin.getByLevel("warn");
|
||||
const stats = plugin.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class LoggingPlugin extends BasePlugin`
|
||||
- `constructor(options: LoggingPluginOptions)`
|
||||
- `getRecent(count?: number): readonly LogEntry[]`
|
||||
- `getByLevel(level: LogLevel): readonly LogEntry[]`
|
||||
- `getByEvent(name: string): readonly LogEntry[]`
|
||||
- `getStats(): { total: number; byLevel: Record<LogLevel, number> }`
|
||||
- `clear(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `LoggerLike` sink is invoked for every entry, regardless of whether
|
||||
the buffer is at capacity. Buffer trimming happens after the sink call
|
||||
so the underlying sink sees the full firehose.
|
||||
- Event subscriptions are stored as an array of unsubscribers; uninstall
|
||||
iterates and calls each one so the emitter is left exactly as it was
|
||||
before install. The plugin therefore composes with `installAll`/
|
||||
`disposeAll` cycles on the `PluginManager` without leaking handlers.
|
||||
- The `session:created` payload includes the full `SessionNode`; the
|
||||
plugin extracts only `session.id` for logging to keep entries small
|
||||
and avoid retaining a reference to mutable engine state.
|
||||
- `getRecent(count)` uses `Array.prototype.slice(-count)`; passing a
|
||||
count larger than the buffer length returns the full buffer.
|
||||
@@ -0,0 +1,92 @@
|
||||
# PersistencePlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PersistencePlugin` tracks per-session game state via a `GameEventEmitter`
|
||||
and persists it to an injectable `StorageLike` backend. It subscribes to
|
||||
`session:created`, `game:started`, `game:state:changed`, `player:joined`,
|
||||
`player:left`, and `game:ended` so engine state is captured without
|
||||
intrusive instrumentation.
|
||||
|
||||
The plugin saves on `game:ended` automatically (toggleable) and exposes
|
||||
explicit `save`, `load`, and `delete` operations so callers can drive
|
||||
persistence from outside the event stream too — for example, during an
|
||||
emergency shutdown hook.
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to the game emitter for transparent state capture.
|
||||
- Accumulates `gameState` from `game:state:changed` events.
|
||||
- Tracks per-session player membership.
|
||||
- Save record is Zod-validated on load — corrupt files throw
|
||||
`GAME_SERVER.PERSISTENCE_LOAD_FAILED`.
|
||||
- Saves on `game:ended` (configurable via `saveOnGameEnd`).
|
||||
- Customisable key prefix (`prefix`, default `"game:save:"`).
|
||||
- Lifecycle-safe: unsubscribes and clears tracked sessions on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
import { PersistencePlugin } from "@suaveplan/game-server/plugins/persistence-plugin";
|
||||
|
||||
const emitter = new GameEventEmitter();
|
||||
const storage = createMyKvAdapter();
|
||||
const plugin = new PersistencePlugin({ emitter, storage });
|
||||
await plugin.onInstall(ctx);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PersistencePlugin } from "@suaveplan/game-server/plugins/persistence-plugin";
|
||||
|
||||
const plugin = new PersistencePlugin({
|
||||
emitter,
|
||||
storage,
|
||||
prefix: "tournament:",
|
||||
saveOnGameEnd: false,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
await plugin.onInstall(ctx);
|
||||
emitter.emit("game:started", { sessionId: "s1" });
|
||||
emitter.emit("game:state:changed", {
|
||||
sessionId: "s1",
|
||||
newState: { score: 42 },
|
||||
});
|
||||
const saved = await plugin.save("s1");
|
||||
const loaded = await plugin.load("s1");
|
||||
await plugin.delete("s1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PersistencePlugin extends BasePlugin`
|
||||
- `constructor(options: PersistencePluginOptions)`
|
||||
- `save(sessionId): Promise<SavedGameState>`
|
||||
- `load(sessionId): Promise<SavedGameState | null>`
|
||||
- `delete(sessionId): Promise<boolean>`
|
||||
- `peek(sessionId): SavedGameState | undefined`
|
||||
- `mergeState(sessionId, fragment): void`
|
||||
- `size(): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `game:state:changed` carries `newState: unknown`. The plugin merges
|
||||
object payloads into the tracked map and wraps non-object payloads as
|
||||
`{ value: <payload> }` so the saved record always has a stable shape.
|
||||
- `load` performs three checks: storage `get`, JSON parse, and Zod
|
||||
schema validation. Any failure surfaces as
|
||||
`PERSISTENCE_LOAD_FAILED` with the original error preserved as the
|
||||
cause; `null` is reserved for "nothing stored at this key".
|
||||
- Errors in the auto-save path (`saveOnGameEnd`) are caught and
|
||||
swallowed after a logger.error so a storage outage cannot stall the
|
||||
emitter. Manual `save` calls still surface the error to the caller.
|
||||
- `mergeState` is a public hook for callers that want to capture state
|
||||
from a non-event source (e.g. an external scheduler). It writes
|
||||
through to the in-memory row only; persistence still requires `save`.
|
||||
@@ -0,0 +1,89 @@
|
||||
# RulesPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`RulesPlugin` integrates `@suaveplan/rules` with the game-server engine. It
|
||||
maintains three rule sets — move validation, game start, and game end — and
|
||||
exposes `evaluateMove`, `evaluateGameStart`, and `evaluateGameEnd` so the
|
||||
engine can apply declarative checks without leaking the underlying engine
|
||||
abstraction into game logic.
|
||||
|
||||
The plugin's role is integration glue: it does not interpret rules itself,
|
||||
it just hands them to the injected `RuleEngine` and records the pass/fail
|
||||
outcomes in a rolling history. Callers compose rule sets at construction
|
||||
time or replace them at runtime via `setMoveRules` / `addMoveRules`.
|
||||
|
||||
## Features
|
||||
|
||||
- Three named rule slots (move / start / end) plus an ad-hoc `evaluate`
|
||||
helper.
|
||||
- Empty rule sets are treated as a "passed" no-op (recorded in history).
|
||||
- Rolling evaluation history with a configurable cap (default 1 000).
|
||||
- Mutable move rule set: append (`addMoveRules`) or replace
|
||||
(`setMoveRules`) at runtime.
|
||||
- Zod-validated `historyCap` option.
|
||||
- Lifecycle-safe: clears rule sets and history on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RuleEngine } from "@suaveplan/rules";
|
||||
import { RulesPlugin } from "@suaveplan/game-server/plugins/rules-plugin";
|
||||
|
||||
const engine = new RuleEngine();
|
||||
const plugin = new RulesPlugin({ engine });
|
||||
await plugin.evaluateMove("p1", { kind: "attack" }, { hp: 10 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RuleEngine, type Rule } from "@suaveplan/rules";
|
||||
import { RulesPlugin } from "@suaveplan/game-server/plugins/rules-plugin";
|
||||
|
||||
const engine = new RuleEngine();
|
||||
const damageCap: Rule = /* ... discriminated rule ... */ ({} as Rule);
|
||||
|
||||
const plugin = new RulesPlugin({
|
||||
engine,
|
||||
moveValidationRules: [damageCap],
|
||||
historyCap: 50,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
plugin.addMoveRules(/* another rule */);
|
||||
const results = await plugin.evaluateMove("p1", { dmg: 5 }, { hp: 10 });
|
||||
const history = plugin.getHistory();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RulesPlugin extends BasePlugin`
|
||||
- `constructor(options: RulesPluginOptions)`
|
||||
- `evaluateMove(playerId, move, gameState): Promise<readonly EvaluationResult[]>`
|
||||
- `evaluateGameStart(sessionId): Promise<readonly EvaluationResult[]>`
|
||||
- `evaluateGameEnd(sessionId): Promise<readonly EvaluationResult[]>`
|
||||
- `evaluate(rules, context): Promise<readonly EvaluationResult[]>`
|
||||
- `addMoveRules(...rules): void`
|
||||
- `setMoveRules(rules): void`
|
||||
- `getHistory(): readonly EvaluationHistoryEntry[]`
|
||||
- `clearHistory(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The engine API used here is `evaluateAll(rules, context)`, which
|
||||
returns an array of `EvaluationResult` (one per rule). The plugin
|
||||
records a single history entry per evaluation, with `passed` reflecting
|
||||
the conjunction over the result set.
|
||||
- `evaluate(rules, ctx)` rejects non-object contexts with
|
||||
`GAME_SERVER.INVALID_RULE_CONTEXT` so callers cannot silently pass
|
||||
primitives through to the rule engine.
|
||||
- Empty rule sets short-circuit before hitting the engine — they record
|
||||
a `passed: true, resultCount: 0` history entry so callers still see
|
||||
the checkpoint was reached.
|
||||
- The history is bounded; once the length exceeds `historyCap`, the
|
||||
oldest entries are dropped via `slice(-historyCap)`.
|
||||
@@ -0,0 +1,102 @@
|
||||
# WorkflowPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WorkflowPlugin` lets the game-server orchestrate multi-step `@suaveplan/workflow`
|
||||
engines under a stable id. Callers register a `WorkflowEngine` once, then
|
||||
`execute(id, initialData)` to run it; the plugin records each attempt in a
|
||||
rolling history and tracks in-flight executions so callers can issue
|
||||
cancellation.
|
||||
|
||||
Typical uses include tournament-bracket progression, boss-phase saga flows
|
||||
(deal → fight → loot), or post-match payout pipelines. The plugin keeps the
|
||||
workflow surface intentionally thin: it does not build engines for you and
|
||||
it does not serialize state — those remain in `@suaveplan/workflow`.
|
||||
|
||||
## Features
|
||||
|
||||
- Id-keyed workflow registry; duplicate registrations throw
|
||||
`GAME_SERVER.DUPLICATE_WORKFLOW`.
|
||||
- `execute` wraps `engine.run` and records start/complete timestamps plus
|
||||
status/error.
|
||||
- `cancel(id)` defers to `engine.cancel()` for in-flight runs.
|
||||
- Active-execution count via `activeCount()` for back-pressure logic.
|
||||
- Rolling history with `getHistory()` and `clearHistory()`.
|
||||
- Lifecycle-safe: cancels every active engine on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createWorkflowEngine } from "@suaveplan/workflow";
|
||||
import { WorkflowPlugin } from "@suaveplan/game-server/plugins/workflow-plugin";
|
||||
|
||||
const engine = createWorkflowEngine({
|
||||
id: "payout",
|
||||
name: "Payout",
|
||||
steps: [
|
||||
{ id: "credit", name: "Credit", execute: () => ({ ok: true }) },
|
||||
],
|
||||
});
|
||||
const plugin = new WorkflowPlugin();
|
||||
plugin.registerWorkflow("payout", "Payout", engine);
|
||||
const result = await plugin.execute("payout", { matchId: "m1" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createWorkflowEngine } from "@suaveplan/workflow";
|
||||
import { WorkflowPlugin } from "@suaveplan/game-server/plugins/workflow-plugin";
|
||||
|
||||
const plugin = new WorkflowPlugin({ clock: () => 1_700_000_000_000 });
|
||||
|
||||
const slow = createWorkflowEngine({
|
||||
id: "slow",
|
||||
name: "Slow",
|
||||
steps: [
|
||||
{
|
||||
id: "wait",
|
||||
name: "Wait",
|
||||
execute: async () => new Promise((r) => setTimeout(r, 1000)),
|
||||
},
|
||||
],
|
||||
});
|
||||
plugin.registerWorkflow("slow", "Slow", slow);
|
||||
|
||||
const pending = plugin.execute("slow", { x: 1 });
|
||||
plugin.cancel("slow");
|
||||
await pending.catch(() => undefined);
|
||||
|
||||
const history = plugin.getHistory();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class WorkflowPlugin extends BasePlugin`
|
||||
- `constructor(options?: WorkflowPluginOptions)`
|
||||
- `registerWorkflow<TData>(id, name, engine): void`
|
||||
- `has(id): boolean`
|
||||
- `list(): readonly string[]`
|
||||
- `activeCount(): number`
|
||||
- `execute<TData>(id, initialData): Promise<WorkflowResult<TData>>`
|
||||
- `cancel(id): boolean`
|
||||
- `getHistory(): readonly WorkflowExecutionRecord[]`
|
||||
- `clearHistory(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry stores engines under the widest possible generic
|
||||
(`WorkflowEngine<Record<string, unknown>>`). `execute` casts back to
|
||||
the caller-supplied `TData` so the public API stays type-safe while
|
||||
internal storage uses a single map.
|
||||
- A history record is appended at the start of every execution and
|
||||
mutated in place on completion via `replaceLast`. This keeps the
|
||||
invariant that `history.length` equals the number of attempts.
|
||||
- `cancel` returns `false` when there is no active execution for the id;
|
||||
this lets callers retry idempotently from a shutdown hook.
|
||||
- Uninstall cancels every active engine via its own `cancel()` method
|
||||
and clears all in-memory tables.
|
||||
@@ -0,0 +1,122 @@
|
||||
# DeltaCompressor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DeltaCompressor` is the wire-format layer that sits between `StateDiffer`
|
||||
and `StateSync`. It computes a `StateDelta` between two snapshots,
|
||||
JSON-encodes the operation list as UTF-8 bytes, attaches versioning and
|
||||
compression statistics, and produces a `CompressedDelta` packet suitable
|
||||
for any binary transport — WebSocket frames, WebRTC data channels, or
|
||||
HTTP/2 streams. On the receiving side it reverses the pipeline: parse,
|
||||
Zod-validate, apply, and increment OTel decompression counters.
|
||||
|
||||
The Wave B-3 port adds Zod-validated payload boundaries, an injectable
|
||||
OTel `Meter` for instrumentation, and typed errors (`STATE_DECOMPRESS_FAILED`)
|
||||
wrapping every wire-parse fault. The upstream's `try/catch` plus
|
||||
`logger.error` is replaced by structured `GameServerError`s carrying the
|
||||
parse cause, so observability and recovery happen through the same channel
|
||||
as every other game-server failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Versioned wire format (`COMPRESSED_DELTA_VERSION`); incompatible version → typed error.
|
||||
- Zod-validated payload schema — malformed peers cannot inject arbitrary operations.
|
||||
- Three OTel instruments: `delta_compressed_total`, `delta_decompressed_total`, `compression_ratio` histogram.
|
||||
- `compressSelective` filters operations by path prefix for partial sync.
|
||||
- `batchCompress` packs multiple pair-deltas in one call.
|
||||
- `estimateSavings` returns the byte budget delta-vs-full-state for adaptive sync.
|
||||
- Empty deltas short-circuit to a zero-byte packet with no allocation.
|
||||
- Injectable `Clock` and `StateDiffer` for deterministic tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { DeltaCompressor } from "@suaveplan/game-server/state/delta-compressor";
|
||||
|
||||
const compressor = new DeltaCompressor();
|
||||
const packet = compressor.compress(
|
||||
{ score: 0, lives: 3 },
|
||||
{ score: 5, lives: 3 }
|
||||
);
|
||||
|
||||
// Send packet.data over the wire …
|
||||
|
||||
const restored = compressor.decompress({ score: 0, lives: 3 }, packet);
|
||||
// { score: 5, lives: 3 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { DeltaCompressor } from "@suaveplan/game-server/state/delta-compressor";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const meter = metrics.getMeter("my-game", "1.0.0");
|
||||
const compressor = new DeltaCompressor({
|
||||
clock: () => 1_700_000_000_000,
|
||||
meter,
|
||||
});
|
||||
|
||||
// Only sync the `players[*]` subtree to spectators.
|
||||
const spectatorPacket = compressor.compressSelective(
|
||||
oldWorld,
|
||||
newWorld,
|
||||
["players"]
|
||||
);
|
||||
|
||||
// Budget-aware sync: skip if savings < 25%.
|
||||
const { savingsPercent } = compressor.estimateSavings(oldWorld, newWorld);
|
||||
if (savingsPercent > 25) {
|
||||
transport.send(spectatorPacket.data);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DeltaCompressor`
|
||||
|
||||
#### `constructor(options?: DeltaCompressorOptions)`
|
||||
|
||||
- `clock?: Clock` — default `Date.now`.
|
||||
- `differ?: StateDiffer` — injected differ instance.
|
||||
- `meter?: Meter` — OTel meter; defaults to the global meter for `@suaveplan/game-server`.
|
||||
|
||||
#### `compress<T>(oldState, newState): CompressedDelta`
|
||||
|
||||
#### `compressSelective<T>(oldState, newState, paths): CompressedDelta`
|
||||
|
||||
#### `decompress<T>(state, packet): T`
|
||||
|
||||
Throws `GameServerError(GAME_SERVER.STATE_DECOMPRESS_FAILED)` on version
|
||||
mismatch or malformed payload.
|
||||
|
||||
#### `batchCompress<T>(pairs): CompressedDelta[]`
|
||||
|
||||
#### `estimateSavings<T>(oldState, newState): { fullStateSize, deltaSize, savings, savingsPercent }`
|
||||
|
||||
### Types
|
||||
|
||||
- `CompressedDelta` — `{ version, timestamp, data: Uint8Array, stats }`.
|
||||
- `CompressionStats` — `{ originalSize, compressedSize, compressionRatio, operationCount }`.
|
||||
- `COMPRESSED_DELTA_VERSION` — current wire-format version (`1`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The payload is a JSON-encoded array of `DiffOperation`s rather than a
|
||||
custom binary format because typical game deltas are small (≤ 200 bytes)
|
||||
and the per-packet UTF-8 overhead is negligible compared to the TLS
|
||||
record framing that always wraps the transport. A future binary codec
|
||||
can ship behind a `version === 2` branch without breaking callers — the
|
||||
`CompressedDelta.version` field is the upgrade hatch.
|
||||
|
||||
Selective compression uses `startsWith` matching on `${path}.` and
|
||||
`${path}[` to avoid matching `playerScore` when the caller wanted only
|
||||
`player`. Exact-equality fallback covers the leaf-replace case.
|
||||
|
||||
`emptyStats` returns `compressionRatio = 1` for empty packets so consumers
|
||||
graphing the ratio histogram see a meaningful baseline rather than a
|
||||
divide-by-zero NaN.
|
||||
@@ -0,0 +1,113 @@
|
||||
# StateDiffer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StateDiffer` is a pure structural diff and patch utility for plain-object
|
||||
game state. It produces a compact, deterministic `StateDelta` between two
|
||||
snapshots and applies that delta back onto a snapshot to reconstruct the
|
||||
target — the symmetric pair `patch(a, diff(a, b)) ≡ b` is the round-trip
|
||||
invariant on which `DeltaCompressor` and `StateSync` depend. Wave B-3
|
||||
ships this as a leaf utility under `@suaveplan/game-server/state/state-differ`.
|
||||
|
||||
The migration replaces the upstream's `"unchanged"` operation type — which
|
||||
inflated wire size for no benefit — with a delta that records only the
|
||||
operations that actually change something. Arrays use index-aligned diffing
|
||||
(positional), objects use key-set diffing, and primitives fall through to a
|
||||
single `replace`. Every traversal failure is wrapped in
|
||||
`GameServerError` with code `GAME_SERVER.STATE_DIFF_FAILED` or
|
||||
`STATE_PATCH_FAILED`, so callers never see a bare runtime exception.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-op grammar: `add`, `remove`, `replace` (no `unchanged` noise).
|
||||
- Object diff via key-set comparison; array diff via positional alignment.
|
||||
- Injectable `Clock` from `@suaveplan/types` for deterministic timestamps.
|
||||
- `mergeDeltas` collapses a sequence of deltas into a last-writer-wins set.
|
||||
- `isEmpty` predicate for skipping zero-change sync ticks.
|
||||
- `parsePath` handles nested arrays (`outer[0][1].inner`).
|
||||
- Plain-object guard rejects class instances (preserves identity semantics).
|
||||
- Typed errors via `GameServerError` — `STATE_DIFF_FAILED`, `STATE_PATCH_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { StateDiffer } from "@suaveplan/game-server/state/state-differ";
|
||||
|
||||
const differ = new StateDiffer();
|
||||
const delta = differ.diff(
|
||||
{ score: 0, players: ["a"] },
|
||||
{ score: 1, players: ["a", "b"] }
|
||||
);
|
||||
|
||||
console.log(delta.operations);
|
||||
// [{ type: "replace", path: "score", value: 1, oldValue: 0 },
|
||||
// { type: "add", path: "players[1]", value: "b" }]
|
||||
|
||||
const restored = differ.patch({ score: 0, players: ["a"] }, delta);
|
||||
// { score: 1, players: ["a", "b"] }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { StateDiffer } from "@suaveplan/game-server/state/state-differ";
|
||||
|
||||
const clock = () => 1_700_000_000_000;
|
||||
const differ = new StateDiffer({ clock });
|
||||
|
||||
// Compose multiple ticks into one wire packet.
|
||||
const a = differ.diff({ x: 0 }, { x: 1 });
|
||||
const b = differ.diff({ x: 1 }, { x: 2 });
|
||||
const c = differ.diff({ x: 2 }, { x: 3 });
|
||||
const merged = differ.mergeDeltas([a, b, c]);
|
||||
// merged.operations === [{ type: "replace", path: "x", value: 3, oldValue: 2 }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StateDiffer`
|
||||
|
||||
#### `constructor(options?: StateDifferOptions)`
|
||||
|
||||
`options.clock` — `Clock` from `@suaveplan/types`. Defaults to `Date.now`.
|
||||
|
||||
#### `diff<T>(oldState: T, newState: T): StateDelta`
|
||||
|
||||
Throws `GameServerError(GAME_SERVER.STATE_DIFF_FAILED)` on traversal failure.
|
||||
|
||||
#### `patch<T>(state: T, delta: StateDelta): T`
|
||||
|
||||
Returns a deeply-cloned, patched copy. Throws
|
||||
`GameServerError(GAME_SERVER.STATE_PATCH_FAILED)` on apply failure.
|
||||
|
||||
#### `isEmpty(delta: StateDelta): boolean`
|
||||
|
||||
#### `mergeDeltas(deltas: StateDelta[]): StateDelta`
|
||||
|
||||
### Types
|
||||
|
||||
- `DiffOperation` — discriminated union of `add | remove | replace`.
|
||||
- `StateDelta` — `{ timestamp, operations, size }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The omission of an `unchanged` operation is deliberate: the upstream emitted
|
||||
one for every untouched leaf, ballooning a single-field update into a delta
|
||||
proportional to the snapshot size. Removing it shrinks typical 1-KB
|
||||
snapshots to 30-byte deltas without losing round-trip fidelity, because
|
||||
`patch` only walks the operations that exist.
|
||||
|
||||
`Object.is` (rather than `===`) is used for equality so `NaN` and `-0`
|
||||
diff correctly. The plain-object guard rejects class instances — game
|
||||
states routinely carry `Date`, `Map`, `Set`, or domain classes whose
|
||||
internal slots would not survive a structural diff; treating them as
|
||||
opaque `replace` values is safer than producing a half-mutated clone.
|
||||
|
||||
`deepClone` is a hand-rolled recursive walker rather than
|
||||
`structuredClone` because the diffed values are constrained to plain JSON
|
||||
shapes and the recursion limit on game state (depth ≤ 16 in practice) is
|
||||
far below the V8 stack budget.
|
||||
@@ -0,0 +1,145 @@
|
||||
# StateManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StateManager` is the canonical, observable container for game-session
|
||||
state. It holds a single typed value, exposes read/write APIs (`get`,
|
||||
`update`, `updateMany`, `setState`, `reset`, `restore`), and emits a
|
||||
`StateChangeEvent` on every mutation so downstream consumers — UI
|
||||
binders, sync engines, persistence layers, replay recorders — can react
|
||||
without polling. Snapshots are returned deep-frozen so reckless callers
|
||||
can't reach into the manager's internal state and break its invariants.
|
||||
|
||||
The Wave B-3 port replaces the upstream's `Logger` and `GameEventEmitter`
|
||||
dependencies with injectable `Clock` and `IdGenerator` from
|
||||
`@suaveplan/types` plus OTel-native instrumentation. Listener faults are
|
||||
counted and absorbed — a misbehaving subscriber can never destabilise
|
||||
the state machine. `restore` accepts an optional validator predicate and
|
||||
throws `GAME_SERVER.INVALID_SNAPSHOT` when the snapshot fails it; clone
|
||||
failures surface as `GAME_SERVER.STATE_RESTORE_FAILED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-value typed store with deep-frozen reads.
|
||||
- Six mutation paths (`update`, `updateMany`, `setState`, `reset`, `restore`, plus `subscribe`).
|
||||
- Bounded change history (default 100 entries, configurable).
|
||||
- Synchronous listener notification with isolated error handling.
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic test scenarios.
|
||||
- Three OTel instruments: `mutation_total`, `listener_error_total`, `mutation_duration_ms`.
|
||||
- Snapshot validation via predicate; typed errors on rejection.
|
||||
- Discriminated `StateChangeEvent.kind` for downstream switch logic.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { StateManager } from "@suaveplan/game-server/state/state-manager";
|
||||
|
||||
interface World { score: number; alive: boolean }
|
||||
const world = new StateManager<World>({ score: 0, alive: true });
|
||||
|
||||
const unsub = world.subscribe((event) => {
|
||||
console.log(event.kind, event.currentState.score);
|
||||
});
|
||||
|
||||
world.update("score", 1);
|
||||
world.update("score", 2);
|
||||
world.setState({ score: 5, alive: false });
|
||||
|
||||
unsub();
|
||||
world.reset(); // back to initial { score: 0, alive: true }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { StateManager } from "@suaveplan/game-server/state/state-manager";
|
||||
import { GAME_SERVER_CODES, GameServerError } from "@suaveplan/game-server/errors";
|
||||
|
||||
interface Session { round: number; players: string[] }
|
||||
|
||||
const isSession = (v: unknown): v is Session =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
typeof (v as Session).round === "number" &&
|
||||
Array.isArray((v as Session).players);
|
||||
|
||||
const manager = new StateManager<Session>(
|
||||
{ round: 0, players: [] },
|
||||
{ maxHistorySize: 500, clock: () => 1700000000000, idGenerator: () => "test" }
|
||||
);
|
||||
|
||||
try {
|
||||
manager.restore({ wrong: true }, isSession);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.INVALID_SNAPSHOT
|
||||
) {
|
||||
// Surface a typed diagnostic; state is unchanged.
|
||||
}
|
||||
}
|
||||
|
||||
manager.restore({ round: 3, players: ["a", "b"] }, isSession);
|
||||
console.log(manager.getStats()); // { updateCount, listenerCount, ... }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StateManager<TState extends object>`
|
||||
|
||||
#### `constructor(initialState, options?)`
|
||||
|
||||
`options` — `{ maxHistorySize?, clock?, idGenerator?, meter? }`.
|
||||
|
||||
#### `getState(): DeepReadonly<TState>`
|
||||
|
||||
#### `get<K>(key): DeepReadonly<TState[K]>`
|
||||
|
||||
#### `has(key): boolean` / `keys(): Array<keyof TState>`
|
||||
|
||||
#### `update(key, value): void` / `updateMany(partial): void`
|
||||
|
||||
#### `setState(next): void` / `reset(): void`
|
||||
|
||||
#### `restore(snapshot, validator?): void`
|
||||
|
||||
Throws `GAME_SERVER.INVALID_SNAPSHOT` (validator) or
|
||||
`GAME_SERVER.STATE_RESTORE_FAILED` (clone).
|
||||
|
||||
#### `subscribe(listener): () => void`
|
||||
|
||||
#### `getHistory()` / `getRecentHistory(count)` / `clearHistory()`
|
||||
|
||||
#### `getStats(): StateManagerStats` / `dispose(): void`
|
||||
|
||||
### Types
|
||||
|
||||
- `StateChangeEvent<TState>` — `{ id, timestamp, kind, previousState, currentState }`.
|
||||
- `StateChangeListener<TState>`, `StateManagerStats`, `SnapshotValidator<TState>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getState` deep-clones then deep-freezes the snapshot so callers cannot
|
||||
mutate internal state, and so two consecutive `getState` calls return
|
||||
distinct frozen objects — important for downstream code that uses
|
||||
referential equality to detect change. The clone is recursive and pure
|
||||
(plain objects + arrays + primitives) because the manager is constrained
|
||||
to JSON-serialisable shapes; class instances are treated as opaque
|
||||
values and would survive clone by reference. Game code that wants
|
||||
identity-preserving state should split such objects into a sibling
|
||||
registry rather than embedding them in the state tree.
|
||||
|
||||
`update` short-circuits via `Object.is` when the value is unchanged,
|
||||
avoiding spurious history entries and listener invocations. The
|
||||
listener loop catches every thrown exception, counts it on
|
||||
`listener_error_total`, and continues — a misbehaving subscriber
|
||||
cannot block other subscribers or corrupt the state machine.
|
||||
|
||||
`maxHistorySize` is enforced by `slice(-max)` after every push so the
|
||||
array is never more than `max + 1` long; this keeps memory bounded on
|
||||
long-running sessions where a million mutations would otherwise pin the
|
||||
entire history in RAM.
|
||||
@@ -0,0 +1,121 @@
|
||||
# StateSync
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StateSync` is the orchestration layer that turns `StateManager` mutations
|
||||
into wire packets and applies received packets back into the manager. It
|
||||
brokers between a single `StateManager<TState>` and a pluggable
|
||||
`StateSyncTransport` — WebSocket, WebRTC data channel, in-process pipe —
|
||||
deciding per call whether to send a delta, a full snapshot, or honour an
|
||||
inbound request. The state-sync algorithm is otherwise transport-agnostic.
|
||||
|
||||
The Wave B-3 port replaces the upstream's hard dependency on
|
||||
`NetworkAdapter`, `GameEventEmitter`, and `Logger` with a single
|
||||
`StateSyncTransport` interface and OTel-native instrumentation. Push
|
||||
cadence is left to callers — there is no internal `setInterval` —
|
||||
because game loops, dirty-flag policies, and bandwidth limiters belong
|
||||
to the application layer, not the sync engine.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-message protocol (`delta`, `full`, `request`) over an abstract transport.
|
||||
- Automatic fallback to full snapshot when the delta exceeds `maxDeltaSize`.
|
||||
- Initial-sync handshake: when no previous state has been synced, sends a full snapshot.
|
||||
- Inbound `request` triggers an outbound full snapshot.
|
||||
- Per-kind OTel counters + bytes histogram.
|
||||
- Typed errors: transport faults surface as `STATE_SYNC_TRANSPORT_FAILED`.
|
||||
- Statistics snapshot and reset.
|
||||
- Disposal removes the transport listener.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { StateManager } from "@suaveplan/game-server/state/state-manager";
|
||||
import { StateSync } from "@suaveplan/game-server/state/state-sync";
|
||||
|
||||
interface World { tick: number }
|
||||
|
||||
const manager = new StateManager<World>({ tick: 0 });
|
||||
const sync = new StateSync(manager, transport);
|
||||
|
||||
// On each game tick:
|
||||
manager.update("tick", manager.get("tick") + 1);
|
||||
await sync.sync();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import type { StateSyncTransport, StateSyncMessage } from "@suaveplan/game-server/state/state-sync";
|
||||
|
||||
// In-process pipe for tests / single-process play
|
||||
class InProcessTransport implements StateSyncTransport {
|
||||
private readonly handlers = new Set<(m: StateSyncMessage) => void>();
|
||||
public peer?: InProcessTransport;
|
||||
isConnected() { return this.peer !== undefined; }
|
||||
onMessage(h: (m: StateSyncMessage) => void) {
|
||||
this.handlers.add(h);
|
||||
return () => { this.handlers.delete(h); };
|
||||
}
|
||||
async send(m: StateSyncMessage) {
|
||||
for (const h of this.peer?.handlers ?? []) h(m);
|
||||
}
|
||||
}
|
||||
|
||||
const t1 = new InProcessTransport();
|
||||
const t2 = new InProcessTransport();
|
||||
t1.peer = t2; t2.peer = t1;
|
||||
|
||||
const sync = new StateSync(manager, t1, {
|
||||
config: { maxDeltaSize: 4096 },
|
||||
});
|
||||
|
||||
await sync.requestFullSync(); // ask peer for full state
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StateSync<TState extends object>`
|
||||
|
||||
#### `constructor(manager, transport, options?)`
|
||||
|
||||
`options` — `{ compressor?, clock?, meter?, config?: { maxDeltaSize? } }`.
|
||||
|
||||
#### `sync(): Promise<void>`
|
||||
|
||||
Sends a delta or, on first call or when the delta exceeds `maxDeltaSize`,
|
||||
a full snapshot.
|
||||
|
||||
#### `syncFull(): Promise<void>` / `requestFullSync(): Promise<void>`
|
||||
|
||||
#### `getStats(): SyncStats` / `resetStats(): void`
|
||||
|
||||
#### `dispose(): void`
|
||||
|
||||
### Types
|
||||
|
||||
- `StateSyncTransport` — `{ isConnected, send, onMessage }`.
|
||||
- `StateSyncMessage` — `delta | full | request` discriminated union.
|
||||
- `SyncStats` — counters for deltas/fulls/requests sent + received, bytes, ratio, last timestamp.
|
||||
- `StateSyncConfig` — `{ maxDeltaSize? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `snapshotCurrent` step explicitly deep-clones via `JSON.parse(JSON.stringify(...))`
|
||||
so the `lastSyncedState` does not retain references to the manager's
|
||||
deep-frozen snapshots — any subsequent attempt to compute a delta would
|
||||
otherwise compare two snapshots whose primitive leaves are identical
|
||||
but whose container references differ, producing spurious replace ops.
|
||||
JSON-clone is acceptable because the sync protocol already constrains
|
||||
the state to JSON-serialisable shapes.
|
||||
|
||||
Transport errors are wrapped in `GameServerError(STATE_SYNC_TRANSPORT_FAILED)`
|
||||
with the failing message kind on `context` so structured logs and
|
||||
observability dashboards can pinpoint the misbehaving leg of the
|
||||
connection. Inbound message handling does not catch transport-side
|
||||
errors — those are the transport's problem; the `StateSync` contract is
|
||||
that `onMessage` only delivers well-formed `StateSyncMessage` values.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Core types and schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines the runtime Zod schemas and validator helpers for the
|
||||
core boundary types of `@suaveplan/game-server`: `GameConfig`,
|
||||
`SessionMetadata`, `PlayerInfo`, `MoveValidationResult`, and `GameResult`.
|
||||
The TypeScript interfaces live in `./common.types.ts` so they can be
|
||||
imported in zero-runtime contexts; this file wires real Zod parsers that
|
||||
turn untrusted input into discriminated, typed values or throw a typed
|
||||
`GameServerError` with a stable `GAME_SERVER.*` code.
|
||||
|
||||
Every public boundary of the package — session creation, player registration,
|
||||
move validation, game result emission — is expected to either call
|
||||
`parseGameConfig`/`parseSessionMetadata`/`parsePlayerInfo` directly or
|
||||
inline-validate against the same schema instances re-exported from
|
||||
`@suaveplan/game-server/types`.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed-union `SessionTypeSchema` over the six recognised session roles.
|
||||
- `GameConfigSchema` with cross-field invariant `maxPlayers >= minPlayers`.
|
||||
- `SessionMetadataSchema` uses `.passthrough()` to accept caller-defined extras.
|
||||
- Throwing validators (`parseGameConfig`, `parseSessionMetadata`, `parsePlayerInfo`).
|
||||
- Non-throwing probes (`tryParseMoveValidationResult`, `tryParseGameResult`).
|
||||
- Each thrower attaches the original Zod error as `cause` and the raw input as `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { parseGameConfig } from "@suaveplan/game-server/types";
|
||||
|
||||
const cfg = parseGameConfig({
|
||||
gameType: "tic-tac-toe",
|
||||
maxPlayers: 2,
|
||||
minPlayers: 2,
|
||||
rules: { allowDraws: true },
|
||||
});
|
||||
// cfg is typed as GameConfig and guaranteed schema-valid.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import {
|
||||
GameConfigSchema,
|
||||
parseGameConfig,
|
||||
tryParseMoveValidationResult,
|
||||
} from "@suaveplan/game-server/types";
|
||||
import { GameServerError } from "@suaveplan/game-server/errors";
|
||||
|
||||
// Compose the schema into a larger request schema:
|
||||
const CreateSessionRequest = GameConfigSchema.extend({
|
||||
requestedBy: z.string().min(1),
|
||||
});
|
||||
|
||||
try {
|
||||
parseGameConfig({ gameType: "chess", maxPlayers: 1, minPlayers: 2, rules: {} });
|
||||
} catch (err) {
|
||||
if (err instanceof GameServerError) {
|
||||
// err.code === "GAME_SERVER.INVALID_GAME_CONFIG"
|
||||
// err.cause is the underlying ZodError with rich path/issue info.
|
||||
}
|
||||
}
|
||||
|
||||
const probe = tryParseMoveValidationResult(maybeResult);
|
||||
if (probe === null) {
|
||||
// host-supplied logic returned an off-spec value; reject the move.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Schemas
|
||||
|
||||
- `SessionTypeSchema` — `z.ZodEnum<[...]>` over six session-role strings.
|
||||
- `PlayerInfoSchema` — strict object; `metadata` is `Record<string, unknown>`.
|
||||
- `SessionMetadataSchema` — passthrough object; `createdAt`/`updatedAt` are
|
||||
non-negative integers.
|
||||
- `GameConfigSchema` — passthrough object; cross-field invariant enforced.
|
||||
- `MoveValidationResultSchema` — strict object; `reason` only on the failure shape.
|
||||
- `GameResultSchema` — strict object; `winner` is `string | null`.
|
||||
|
||||
### Throwing validators
|
||||
|
||||
- `parseGameConfig(input): GameConfig` — throws `GAME_SERVER.INVALID_GAME_CONFIG`.
|
||||
- `parseSessionMetadata(input): SessionMetadata` — throws `GAME_SERVER.INVALID_SESSION_METADATA`.
|
||||
- `parsePlayerInfo(input): PlayerInfo` — throws `GAME_SERVER.INVALID_PLAYER_INFO`.
|
||||
|
||||
### Non-throwing probes
|
||||
|
||||
- `tryParseMoveValidationResult(input): MoveValidationResult | null`.
|
||||
- `tryParseGameResult(input): GameResult | null`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas tighten the upstream `Record<string, any>` slots to
|
||||
`Record<string, unknown>` because `any` defeats the lint profile.
|
||||
`GameConfigSchema` uses `.passthrough()` so callers can attach
|
||||
game-specific keys without the framework owning their shape — the
|
||||
`refine` cross-field check is still enforced. `parseGameConfig` etc. cast
|
||||
the parsed value to the public interface so the readonly modifiers in
|
||||
`./common.types.ts` flow to callers; the Zod output type is structurally
|
||||
compatible, and the cast is the canonical pattern for Zod ↔ TS interface
|
||||
bridges across the suaveplan codebase.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Core type interfaces
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines the pure TypeScript interfaces for the public
|
||||
boundary of `@suaveplan/game-server`: `GameConfig`, `SessionMetadata`,
|
||||
`PlayerInfo`, `MoveValidationResult`, `GameResult`, plus the
|
||||
`SessionType` string-union and the `DeepReadonly` mapped-type helper.
|
||||
Keeping the type definitions in a separate `.types.ts` file lets
|
||||
zero-runtime consumers (other type-only barrels, doc-generators,
|
||||
`tsc --emitDeclarationOnly` integrations) import these shapes without
|
||||
pulling Zod into the bundle.
|
||||
|
||||
The runtime Zod schemas and validators that align with these shapes
|
||||
live alongside in `./common.ts`. The pair is the canonical pattern: one
|
||||
file for the static types, one for the runtime parsers, and a tiny
|
||||
barrel (`./index.ts`) that re-exports both. This package sits this file at
|
||||
`src/types/` depth 2 because the docs-check rule (see
|
||||
`scripts/docs-check.ts::isExcluded`) treats the shared `src/types/`
|
||||
directory as documentation-bearing even for `.types.ts` files. The
|
||||
co-located sibling `common.types.md` (this file) keeps the rule green.
|
||||
|
||||
## Features
|
||||
|
||||
- `DeepReadonly<T>` mapped type — used wherever a snapshot of an
|
||||
internal structure is exposed to callers.
|
||||
- `SessionType` — closed union over the six recognised session-role
|
||||
strings (`venue`, `table`, `queue`, `game`, `player`, `spectator`).
|
||||
- `PlayerInfo` — opaque player record with stable id, optional
|
||||
session-binding, optional display name, and open `metadata` bag.
|
||||
- `SessionMetadata` — open-ended record keyed by string with mandatory
|
||||
`createdAt` / `updatedAt` timestamps and a permissive
|
||||
`[key: string]: unknown` tail.
|
||||
- `GameConfig` — typed game-config envelope with `gameType`,
|
||||
`minPlayers`, `maxPlayers`, `rules`, plus an open-ended tail.
|
||||
- `MoveValidationResult` — `{ valid, reason? }` shape for generic move
|
||||
validators returned by game-logic plugins.
|
||||
- `GameResult` — `{ winner, rankings?, statistics? }` outcome shape with
|
||||
a `winner: string | null` channel (null encodes a draw).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
GameConfig,
|
||||
GameResult,
|
||||
PlayerInfo,
|
||||
} from "@suaveplan/game-server/types";
|
||||
|
||||
function summarise(cfg: GameConfig, players: ReadonlyArray<PlayerInfo>): GameResult {
|
||||
return { winner: null };
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
DeepReadonly,
|
||||
SessionMetadata,
|
||||
SessionType,
|
||||
} from "@suaveplan/game-server/types";
|
||||
|
||||
interface SessionView {
|
||||
readonly id: string;
|
||||
readonly type: SessionType;
|
||||
readonly metadata: DeepReadonly<SessionMetadata>;
|
||||
}
|
||||
|
||||
function expose(meta: SessionMetadata): DeepReadonly<SessionMetadata> {
|
||||
// structurally compatible because every field on SessionMetadata is
|
||||
// already `readonly` and primitive or nested-readonly via the mapper.
|
||||
return meta;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type DeepReadonly<T>`
|
||||
|
||||
Maps every property of `T` to `readonly`, recursing into nested objects.
|
||||
Primitives, functions, and arrays of primitives are passed through.
|
||||
|
||||
### `type SessionType`
|
||||
|
||||
Closed union `"venue" | "table" | "queue" | "game" | "player" | "spectator"`.
|
||||
|
||||
### `interface PlayerInfo`
|
||||
|
||||
- `id: string` — stable identifier.
|
||||
- `sessionId?: string` — optional session binding.
|
||||
- `name?: string` — optional display name.
|
||||
- `metadata: Record<string, unknown>` — open caller-owned bag.
|
||||
|
||||
### `interface SessionMetadata`
|
||||
|
||||
- `createdAt: number` — non-negative integer epoch.
|
||||
- `updatedAt: number` — non-negative integer epoch.
|
||||
- Open tail `[key: string]: unknown`.
|
||||
|
||||
### `interface GameConfig`
|
||||
|
||||
- `gameType: string`
|
||||
- `maxPlayers: number` (must satisfy `maxPlayers >= minPlayers`).
|
||||
- `minPlayers: number`
|
||||
- `rules: Record<string, unknown>` — caller-owned rule bag.
|
||||
- Open tail `[key: string]: unknown`.
|
||||
|
||||
### `interface MoveValidationResult`
|
||||
|
||||
- `valid: boolean`
|
||||
- `reason?: string` — only meaningful on the failure shape.
|
||||
|
||||
### `interface GameResult`
|
||||
|
||||
- `winner: string | null` — `null` encodes a draw.
|
||||
- `rankings?: ReadonlyArray<{ playerId: string; rank: number; score?: number }>`.
|
||||
- `statistics?: Record<string, unknown>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every field is declared `readonly` to match the lint profile,
|
||||
which prefers immutable shapes at boundaries. The open `[key: string]:
|
||||
unknown` tails on `SessionMetadata` and `GameConfig` mirror the
|
||||
upstream `Record<string, any>` slots but tighten the value type from
|
||||
`any` to `unknown`. Callers therefore must narrow before reading
|
||||
caller-owned fields — a deliberate trade-off versus the upstream
|
||||
`any` ergonomics, because `any` defeats every downstream type
|
||||
inference along the consumer chain.
|
||||
Reference in New Issue
Block a user