first commit

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

View File

@@ -0,0 +1,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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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)`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.