Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,403 @@
{
"name": "@suaveplan/game-server",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./engine/di-container": {
"import": {
"types": "./dist/engine/di-container/index.d.ts",
"default": "./dist/engine/di-container/index.js"
}
},
"./core/types": {
"import": {
"types": "./dist/core/types/index.d.ts",
"default": "./dist/core/types/index.js"
}
},
"./core/interfaces/i-game-logic": {
"import": {
"types": "./dist/core/interfaces/i-game-logic/index.d.ts",
"default": "./dist/core/interfaces/i-game-logic/index.js"
}
},
"./core/interfaces/i-player-manager": {
"import": {
"types": "./dist/core/interfaces/i-player-manager/index.d.ts",
"default": "./dist/core/interfaces/i-player-manager/index.js"
}
},
"./core/interfaces/i-session-manager": {
"import": {
"types": "./dist/core/interfaces/i-session-manager/index.d.ts",
"default": "./dist/core/interfaces/i-session-manager/index.js"
}
},
"./core/interfaces/i-state-manager": {
"import": {
"types": "./dist/core/interfaces/i-state-manager/index.d.ts",
"default": "./dist/core/interfaces/i-state-manager/index.js"
}
},
"./core/events/game-event-emitter": {
"import": {
"types": "./dist/core/events/game-event-emitter/index.d.ts",
"default": "./dist/core/events/game-event-emitter/index.js"
}
},
"./core/events/game-events": {
"import": {
"types": "./dist/core/events/game-events/index.d.ts",
"default": "./dist/core/events/game-events/index.js"
}
},
"./core/abstract-game-logic": {
"import": {
"types": "./dist/core/abstract-game-logic/index.d.ts",
"default": "./dist/core/abstract-game-logic/index.js"
}
},
"./state/state-manager": {
"import": {
"types": "./dist/state/state-manager/index.d.ts",
"default": "./dist/state/state-manager/index.js"
}
},
"./state/state-differ": {
"import": {
"types": "./dist/state/state-differ/index.d.ts",
"default": "./dist/state/state-differ/index.js"
}
},
"./state/delta-compressor": {
"import": {
"types": "./dist/state/delta-compressor/index.d.ts",
"default": "./dist/state/delta-compressor/index.js"
}
},
"./state/state-sync": {
"import": {
"types": "./dist/state/state-sync/index.d.ts",
"default": "./dist/state/state-sync/index.js"
}
},
"./network/network-adapter": {
"import": {
"types": "./dist/network/network-adapter/index.d.ts",
"default": "./dist/network/network-adapter/index.js"
}
},
"./network/reconnection-manager": {
"import": {
"types": "./dist/network/reconnection-manager/index.d.ts",
"default": "./dist/network/reconnection-manager/index.js"
}
},
"./network/websocket-transport": {
"import": {
"types": "./dist/network/websocket-transport/index.d.ts",
"default": "./dist/network/websocket-transport/index.js"
}
},
"./network/webrtc-transport": {
"import": {
"types": "./dist/network/webrtc-transport/index.d.ts",
"default": "./dist/network/webrtc-transport/index.js"
}
},
"./engine/game-engine": {
"import": {
"types": "./dist/engine/game-engine/index.d.ts",
"default": "./dist/engine/game-engine/index.js"
}
},
"./engine/game-loop": {
"import": {
"types": "./dist/engine/game-loop/index.d.ts",
"default": "./dist/engine/game-loop/index.js"
}
},
"./engine/plugin-manager": {
"import": {
"types": "./dist/engine/plugin-manager/index.d.ts",
"default": "./dist/engine/plugin-manager/index.js"
}
},
"./engine/session-lifecycle": {
"import": {
"types": "./dist/engine/session-lifecycle/index.d.ts",
"default": "./dist/engine/session-lifecycle/index.js"
}
},
"./engine/session-node": {
"import": {
"types": "./dist/engine/session-node/index.d.ts",
"default": "./dist/engine/session-node/index.js"
}
},
"./engine/session-store": {
"import": {
"types": "./dist/engine/session-store/index.d.ts",
"default": "./dist/engine/session-store/index.js"
}
},
"./engine/session-tree": {
"import": {
"types": "./dist/engine/session-tree/index.d.ts",
"default": "./dist/engine/session-tree/index.js"
}
},
"./engine/player-manager": {
"import": {
"types": "./dist/engine/player-manager/index.d.ts",
"default": "./dist/engine/player-manager/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/index.d.ts",
"default": "./dist/observability/index.js"
}
},
"./matchmaking/queue-manager": {
"import": {
"types": "./dist/matchmaking/queue-manager/index.d.ts",
"default": "./dist/matchmaking/queue-manager/index.js"
}
},
"./matchmaking/skill-matcher": {
"import": {
"types": "./dist/matchmaking/skill-matcher/index.d.ts",
"default": "./dist/matchmaking/skill-matcher/index.js"
}
},
"./matchmaking/spectator-manager": {
"import": {
"types": "./dist/matchmaking/spectator-manager/index.d.ts",
"default": "./dist/matchmaking/spectator-manager/index.js"
}
},
"./matchmaking/winner-stays-on": {
"import": {
"types": "./dist/matchmaking/winner-stays-on/index.d.ts",
"default": "./dist/matchmaking/winner-stays-on/index.js"
}
},
"./matchmaking/matchmaking-engine": {
"import": {
"types": "./dist/matchmaking/matchmaking-engine/index.d.ts",
"default": "./dist/matchmaking/matchmaking-engine/index.js"
}
},
"./performance/object-pool": {
"import": {
"types": "./dist/performance/object-pool/index.d.ts",
"default": "./dist/performance/object-pool/index.js"
}
},
"./performance/session-pool": {
"import": {
"types": "./dist/performance/session-pool/index.d.ts",
"default": "./dist/performance/session-pool/index.js"
}
},
"./performance/network-tier-adapter": {
"import": {
"types": "./dist/performance/network-tier-adapter/index.d.ts",
"default": "./dist/performance/network-tier-adapter/index.js"
}
},
"./plugins/base-plugin": {
"import": {
"types": "./dist/plugins/base-plugin/index.d.ts",
"default": "./dist/plugins/base-plugin/index.js"
}
},
"./plugins/ab-test-plugin": {
"import": {
"types": "./dist/plugins/ab-test-plugin/index.d.ts",
"default": "./dist/plugins/ab-test-plugin/index.js"
}
},
"./plugins/analytics-plugin": {
"import": {
"types": "./dist/plugins/analytics-plugin/index.d.ts",
"default": "./dist/plugins/analytics-plugin/index.js"
}
},
"./plugins/feature-flag-plugin": {
"import": {
"types": "./dist/plugins/feature-flag-plugin/index.d.ts",
"default": "./dist/plugins/feature-flag-plugin/index.js"
}
},
"./plugins/logging-plugin": {
"import": {
"types": "./dist/plugins/logging-plugin/index.d.ts",
"default": "./dist/plugins/logging-plugin/index.js"
}
},
"./plugins/persistence-plugin": {
"import": {
"types": "./dist/plugins/persistence-plugin/index.d.ts",
"default": "./dist/plugins/persistence-plugin/index.js"
}
},
"./plugins/rules-plugin": {
"import": {
"types": "./dist/plugins/rules-plugin/index.d.ts",
"default": "./dist/plugins/rules-plugin/index.js"
}
},
"./plugins/workflow-plugin": {
"import": {
"types": "./dist/plugins/workflow-plugin/index.d.ts",
"default": "./dist/plugins/workflow-plugin/index.js"
}
},
"./enhanced/anti-cheat/anti-cheat-validator": {
"import": {
"types": "./dist/enhanced/anti-cheat/anti-cheat-validator/index.d.ts",
"default": "./dist/enhanced/anti-cheat/anti-cheat-validator/index.js"
}
},
"./enhanced/interest-management/interest-manager": {
"import": {
"types": "./dist/enhanced/interest-management/interest-manager/index.d.ts",
"default": "./dist/enhanced/interest-management/interest-manager/index.js"
}
},
"./enhanced/lag-compensation/lag-compensator": {
"import": {
"types": "./dist/enhanced/lag-compensation/lag-compensator/index.d.ts",
"default": "./dist/enhanced/lag-compensation/lag-compensator/index.js"
}
},
"./enhanced/leaderboard/leaderboard-manager": {
"import": {
"types": "./dist/enhanced/leaderboard/leaderboard-manager/index.d.ts",
"default": "./dist/enhanced/leaderboard/leaderboard-manager/index.js"
}
},
"./enhanced/persistence/game-persistence": {
"import": {
"types": "./dist/enhanced/persistence/game-persistence/index.d.ts",
"default": "./dist/enhanced/persistence/game-persistence/index.js"
}
},
"./enhanced/replay/replay-recorder": {
"import": {
"types": "./dist/enhanced/replay/replay-recorder/index.d.ts",
"default": "./dist/enhanced/replay/replay-recorder/index.js"
}
},
"./enhanced/replay/replay-player": {
"import": {
"types": "./dist/enhanced/replay/replay-player/index.d.ts",
"default": "./dist/enhanced/replay/replay-player/index.js"
}
},
"./enhanced/tournament/tournament-manager": {
"import": {
"types": "./dist/enhanced/tournament/tournament-manager/index.d.ts",
"default": "./dist/enhanced/tournament/tournament-manager/index.js"
}
},
"./enhanced/service-discovery/game-instance-registry": {
"import": {
"types": "./dist/enhanced/service-discovery/game-instance-registry/index.d.ts",
"default": "./dist/enhanced/service-discovery/game-instance-registry/index.js"
}
},
"./enhanced/state-sync/bitpacker-state-sync": {
"import": {
"types": "./dist/enhanced/state-sync/bitpacker-state-sync/index.d.ts",
"default": "./dist/enhanced/state-sync/bitpacker-state-sync/index.js"
}
},
"./enhanced/timing/vector-clock-sync": {
"import": {
"types": "./dist/enhanced/timing/vector-clock-sync/index.d.ts",
"default": "./dist/enhanced/timing/vector-clock-sync/index.js"
}
},
"./integrations/enhanced-server": {
"import": {
"types": "./dist/integrations/enhanced-server/index.d.ts",
"default": "./dist/integrations/enhanced-server/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/feature-flags": "workspace:^",
"@suaveplan/network-transport-webrtc": "workspace:^",
"@suaveplan/network-transport-websockets": "workspace:^",
"@suaveplan/retry": "workspace:^",
"@suaveplan/rules": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/workflow": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/bitpacker-strategies": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

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,402 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../../tests/setup/polyfills.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import type {
GameResult,
MoveValidationResult,
} from "../../types/common.types.js";
import {
__resetInstrumentsForTesting,
AbstractGameLogic,
} from "./abstract-game-logic.js";
interface BoardState {
readonly count: number;
}
interface BoardMove {
readonly delta: number;
}
class FakeClock {
private t: number;
public constructor(start = 0) {
this.t = start;
}
public now = (): number => {
const v = this.t;
this.t += 1;
return v;
};
}
let beforeCount = 0;
let afterCount = 0;
let playerAddedCount = 0;
let playerRemovedCount = 0;
let disposeCount = 0;
class BoardLogic extends AbstractGameLogic<BoardState, BoardMove> {
public validateMove(
_id: string,
move: BoardMove,
_state: BoardState
): MoveValidationResult {
if (move.delta < 0) return this.invalid("negative delta");
return this.valid();
}
protected applyMove(
_id: string,
move: BoardMove,
state: BoardState
): BoardState {
return { count: state.count + move.delta };
}
public isGameOver(state: BoardState): boolean {
return state.count >= 5;
}
public getGameResult(state: BoardState): GameResult {
return { winner: state.count >= 5 ? "p1" : null };
}
public initializeGame(config: Record<string, unknown>): BoardState {
const c = typeof config.start === "number" ? config.start : 0;
this.state = { count: c };
return this.state;
}
protected override beforeMoveProcessing(): void {
beforeCount += 1;
}
protected override afterMoveProcessing(): void {
afterCount += 1;
}
protected override onPlayerAdded(): void {
playerAddedCount += 1;
}
protected override onPlayerRemoved(): void {
playerRemovedCount += 1;
}
protected override onDispose(): void {
disposeCount += 1;
}
// Expose protected helpers for direct testing.
public callCloneState(s: BoardState): BoardState {
return this.cloneState(s);
}
public callCanPlayerMove(id: string): boolean {
return this.canPlayerMove(id);
}
public callNextId(): string {
return this.nextId();
}
}
describe("AbstractGameLogic", () => {
let logic: BoardLogic;
let clock: FakeClock;
beforeEach(() => {
beforeCount = 0;
afterCount = 0;
playerAddedCount = 0;
playerRemovedCount = 0;
disposeCount = 0;
clock = new FakeClock(0);
__resetInstrumentsForTesting();
logic = new BoardLogic({
initialState: { count: 0 },
clock: clock.now,
idGenerator: () => "fixed-id",
});
});
afterEach(async () => {
await flushTestMetrics();
});
it("returns the initial state from getGameState", { timeout: 5000 }, () => {
expect(logic.getGameState()).toEqual({ count: 0 });
});
it("initializeGame replaces state from config", { timeout: 5000 }, () => {
const s = logic.initializeGame({ start: 3 });
expect(s).toEqual({ count: 3 });
expect(logic.getGameState()).toEqual({ count: 3 });
});
it("initializeGame falls back to 0 when config lacks start", {
timeout: 5000,
}, () => {
const s = logic.initializeGame({});
expect(s).toEqual({ count: 0 });
});
it("processMove validates, applies, mutates state, runs hooks", {
timeout: 5000,
}, () => {
const next = logic.processMove("p1", { delta: 2 }, { count: 1 });
expect(next).toEqual({ count: 3 });
expect(logic.getGameState()).toEqual({ count: 3 });
expect(beforeCount).toBe(1);
expect(afterCount).toBe(1);
});
it("processMove records OTel instruments", { timeout: 5000 }, async () => {
logic.processMove("p1", { delta: 1 }, { count: 0 });
const collected = await flushTestMetrics();
const flat = collected.flatMap((batch) =>
batch.scopeMetrics.flatMap((sm) => sm.metrics)
);
const names = flat.map((m) => m.descriptor.name);
expect(names).toContain("game_server.logic.tick_duration_ms");
expect(names).toContain("game_server.logic.moves_total");
});
it("processMove throws GameServerError(INVALID_MOVE) on rejection", {
timeout: 5000,
}, async () => {
try {
logic.processMove("p1", { delta: -1 }, { count: 0 });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GameServerError);
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_MOVE
);
expect((err as GameServerError).context).toEqual({
playerId: "p1",
reason: "negative delta",
});
}
const collected = await flushTestMetrics();
const flat = collected.flatMap((batch) =>
batch.scopeMetrics.flatMap((sm) => sm.metrics)
);
expect(flat.map((m) => m.descriptor.name)).toContain(
"game_server.logic.rejections_total"
);
});
it("processMove uses elapsed=0 when clock is non-monotonic", {
timeout: 5000,
}, () => {
// Build a clock that goes BACKWARDS — exercise the Math.max(0, ...) clamp.
let calls = 0;
const weird = (): number => {
calls += 1;
return calls === 1 ? 5 : 0;
};
__resetInstrumentsForTesting();
const odd = new BoardLogic({
initialState: { count: 0 },
clock: weird,
idGenerator: () => "x",
});
const next = odd.processMove("p1", { delta: 1 }, { count: 0 });
expect(next).toEqual({ count: 1 });
});
it("processMove falls back to 'Unknown reason' when validator omits reason", {
timeout: 5000,
}, () => {
class Quiet extends BoardLogic {
public override validateMove(): MoveValidationResult {
return { valid: false };
}
}
const q = new Quiet({
initialState: { count: 0 },
clock: clock.now,
idGenerator: () => "x",
});
try {
q.processMove("p2", { delta: 1 }, { count: 0 });
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).message).toContain(
"Unknown reason"
);
expect((err as GameServerError).context).toEqual({
playerId: "p2",
reason: "Unknown reason",
});
}
});
it("addPlayer registers a player and fires onPlayerAdded", {
timeout: 5000,
}, () => {
logic.addPlayer("p1", { name: "Alice" });
expect(logic.hasPlayer("p1")).toBe(true);
expect(logic.getPlayer("p1")).toEqual({ name: "Alice" });
expect(playerAddedCount).toBe(1);
});
it("addPlayer with no data stores an empty object", {
timeout: 5000,
}, () => {
logic.addPlayer("p1");
expect(logic.getPlayer("p1")).toEqual({});
});
it("addPlayer throws GameServerError(PLAYER_ALREADY_EXISTS) on duplicate", {
timeout: 5000,
}, () => {
logic.addPlayer("p1");
try {
logic.addPlayer("p1");
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PLAYER_ALREADY_EXISTS
);
}
});
it("removePlayer fires onPlayerRemoved and clears the entry", {
timeout: 5000,
}, () => {
logic.addPlayer("p1");
logic.removePlayer("p1");
expect(logic.hasPlayer("p1")).toBe(false);
expect(playerRemovedCount).toBe(1);
});
it("removePlayer throws GameServerError(PLAYER_NOT_FOUND) for unknown id", {
timeout: 5000,
}, () => {
try {
logic.removePlayer("ghost");
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PLAYER_NOT_FOUND
);
}
});
it("getPlayerIds returns the current roster ids", { timeout: 5000 }, () => {
logic.addPlayer("p1");
logic.addPlayer("p2");
expect(logic.getPlayerIds()).toEqual(["p1", "p2"]);
expect(logic.getPlayerCount()).toBe(2);
});
it("dispose clears the roster and fires onDispose", {
timeout: 5000,
}, () => {
logic.addPlayer("p1");
logic.dispose();
expect(logic.getPlayerCount()).toBe(0);
expect(disposeCount).toBe(1);
});
it("isGameOver / getGameResult derive from state", {
timeout: 5000,
}, () => {
expect(logic.isGameOver({ count: 1 })).toBe(false);
expect(logic.isGameOver({ count: 5 })).toBe(true);
expect(logic.getGameResult({ count: 5 })).toEqual({ winner: "p1" });
});
it("cloneState produces a deep copy", { timeout: 5000 }, () => {
const a = { count: 1 };
const b = logic.callCloneState(a);
expect(b).toEqual(a);
expect(b).not.toBe(a);
});
it("canPlayerMove reflects roster membership", { timeout: 5000 }, () => {
expect(logic.callCanPlayerMove("p1")).toBe(false);
logic.addPlayer("p1");
expect(logic.callCanPlayerMove("p1")).toBe(true);
});
it("nextId returns the injected idGenerator output", {
timeout: 5000,
}, () => {
expect(logic.callNextId()).toBe("fixed-id");
});
it("uses the default clock and idGenerator when not injected", {
timeout: 5000,
}, () => {
__resetInstrumentsForTesting();
const bare = new BoardLogic({ initialState: { count: 0 } });
const next = bare.processMove("p1", { delta: 1 }, { count: 0 });
expect(next).toEqual({ count: 1 });
expect(typeof bare.callNextId()).toBe("string");
expect(bare.callNextId().length).toBeGreaterThan(0);
});
it("reuses cached instruments when meterName is unchanged", {
timeout: 5000,
}, () => {
__resetInstrumentsForTesting();
const a = new BoardLogic({ initialState: { count: 0 } });
const b = new BoardLogic({ initialState: { count: 0 } });
a.processMove("p1", { delta: 1 }, { count: 0 });
b.processMove("p1", { delta: 1 }, { count: 0 });
// No assertion needed — exercised the cache hit branch.
expect(a.getGameState()).toEqual({ count: 1 });
expect(b.getGameState()).toEqual({ count: 1 });
});
it("default lifecycle hooks are no-ops when subclass does not override", {
timeout: 5000,
}, () => {
// Bare subclass that overrides ONLY the abstract methods so the base
// `beforeMoveProcessing`, `afterMoveProcessing`, `onPlayerAdded`,
// `onPlayerRemoved`, and `onDispose` no-ops are executed directly.
class BareGame extends AbstractGameLogic<BoardState, BoardMove> {
public validateMove(): MoveValidationResult {
return { valid: true };
}
protected applyMove(
_id: string,
m: BoardMove,
s: BoardState
): BoardState {
return { count: s.count + m.delta };
}
public isGameOver(s: BoardState): boolean {
return s.count >= 1;
}
public getGameResult(s: BoardState): GameResult {
return { winner: s.count >= 1 ? "x" : null };
}
public initializeGame(): BoardState {
return { count: 0 };
}
}
__resetInstrumentsForTesting();
const bare = new BareGame({ initialState: { count: 0 } });
bare.addPlayer("p1");
bare.processMove("p1", { delta: 1 }, { count: 0 });
bare.removePlayer("p1");
bare.dispose();
// Reaching here without throwing exercises every default hook.
expect(bare.getPlayerCount()).toBe(0);
});
it("rebinds instruments when meterName changes", { timeout: 5000 }, () => {
__resetInstrumentsForTesting();
const a = new BoardLogic({
initialState: { count: 0 },
meterName: "test.meter.a",
});
const b = new BoardLogic({
initialState: { count: 0 },
meterName: "test.meter.b",
});
a.processMove("p1", { delta: 1 }, { count: 0 });
b.processMove("p1", { delta: 1 }, { count: 0 });
expect(a.getGameState()).toEqual({ count: 1 });
expect(b.getGameState()).toEqual({ count: 1 });
});
});

View File

@@ -0,0 +1,272 @@
import type { Counter, Histogram } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type { Clock, IdGenerator } from "@suaveplan/types";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import type {
GameResult,
MoveValidationResult,
} from "../../types/common.types.js";
import type { IGameLogic } from "../interfaces/i-game-logic/i-game-logic.js";
import type { AbstractGameLogicOptions } from "./abstract-game-logic.types.js";
/** Default meter name for game-server lifecycle instrumentation. */
const DEFAULT_METER_NAME = "@suaveplan/game-server";
interface Instruments {
readonly tickHistogram: Histogram;
readonly moveCounter: Counter;
readonly rejectionCounter: Counter;
}
let cachedInstruments: { name: string; instruments: Instruments } | null = null;
function getInstruments(meterName: string): Instruments {
if (cachedInstruments !== null && cachedInstruments.name === meterName) {
return cachedInstruments.instruments;
}
const meter = metrics.getMeter(meterName);
const instruments: Instruments = {
tickHistogram: meter.createHistogram(
"game_server.logic.tick_duration_ms",
{
description: "Duration of a processMove tick in milliseconds",
unit: "ms",
}
),
moveCounter: meter.createCounter("game_server.logic.moves_total", {
description: "Total moves processed by AbstractGameLogic",
}),
rejectionCounter: meter.createCounter(
"game_server.logic.rejections_total",
{
description: "Total moves rejected by AbstractGameLogic",
}
),
};
cachedInstruments = { name: meterName, instruments };
return instruments;
}
const defaultClock: Clock = () => Date.now();
const defaultIdGenerator: IdGenerator = () => {
// Use webcrypto where available; the test polyfills install it before
// any code path that touches `idGenerator` runs, so this is always safe
// inside the genesis test harness.
return globalThis.crypto.randomUUID();
};
/**
* Abstract Template-Method base class for game-logic implementations.
*
* Subclasses fill in the rule-specific abstract methods (`validateMove`,
* `applyMove`, `isGameOver`, `getGameResult`, `initializeGame`). The base
* class provides `processMove` orchestration, player roster management,
* lifecycle hooks (`beforeMoveProcessing`, `afterMoveProcessing`,
* `onPlayerAdded`, `onPlayerRemoved`, `onDispose`), an injected `Clock` +
* `IdGenerator` (zero direct `Date.now()` / `crypto.randomUUID()` calls
* in subclass code), and OpenTelemetry instrumentation
* (`tick_duration_ms` histogram + `moves_total` / `rejections_total`
* counters).
*/
export abstract class AbstractGameLogic<TState, TMove = unknown>
implements IGameLogic<TState, TMove>
{
protected state: TState;
protected readonly players: Map<string, unknown> = new Map();
protected readonly clock: Clock;
protected readonly idGenerator: IdGenerator;
private readonly instruments: Instruments;
public constructor(options: AbstractGameLogicOptions<TState>) {
this.state = options.initialState;
this.clock = options.clock ?? defaultClock;
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
this.instruments = getInstruments(
options.meterName ?? DEFAULT_METER_NAME
);
}
/**
* Template-method orchestration. Calls `validateMove`, throws
* `GameServerError(INVALID_MOVE)` on rejection, then runs the
* before/applyMove/after pipeline. Emits OTel instruments on every
* call.
*/
public processMove(playerId: string, move: TMove, state: TState): TState {
const start = this.clock();
const validation = this.validateMove(playerId, move, state);
if (!validation.valid) {
this.instruments.rejectionCounter.add(1, {
reason: validation.reason ?? "unknown",
});
throw new GameServerError(
GAME_SERVER_CODES.INVALID_MOVE,
`Invalid move: ${validation.reason ?? "Unknown reason"}`,
{
context: {
playerId,
reason: validation.reason ?? "Unknown reason",
},
}
);
}
this.beforeMoveProcessing(playerId, move);
const newState = this.applyMove(playerId, move, state);
this.state = newState;
this.afterMoveProcessing(playerId, move, newState);
const elapsed = Math.max(0, this.clock() - start);
this.instruments.tickHistogram.record(elapsed);
this.instruments.moveCounter.add(1);
return newState;
}
public getGameState(): TState {
return this.state;
}
/**
* Register a player. Throws `GameServerError(PLAYER_ALREADY_EXISTS)`
* if `playerId` is already in the roster.
*/
public addPlayer(playerId: string, playerData?: unknown): void {
if (this.players.has(playerId)) {
throw new GameServerError(
GAME_SERVER_CODES.PLAYER_ALREADY_EXISTS,
`Player ${playerId} already exists`,
{ context: { playerId } }
);
}
this.players.set(playerId, playerData ?? {});
this.onPlayerAdded(playerId, playerData);
}
/**
* Remove a player. Throws `GameServerError(PLAYER_NOT_FOUND)` if the
* id is unknown.
*/
public removePlayer(playerId: string): void {
if (!this.players.has(playerId)) {
throw new GameServerError(
GAME_SERVER_CODES.PLAYER_NOT_FOUND,
`Player ${playerId} not found`,
{ context: { playerId } }
);
}
const data = this.players.get(playerId);
this.players.delete(playerId);
this.onPlayerRemoved(playerId, data);
}
public getPlayer(playerId: string): unknown {
return this.players.get(playerId);
}
public hasPlayer(playerId: string): boolean {
return this.players.has(playerId);
}
public getPlayerIds(): ReadonlyArray<string> {
return Array.from(this.players.keys());
}
public getPlayerCount(): number {
return this.players.size;
}
public dispose(): void {
this.players.clear();
this.onDispose();
}
// -----------------------------------------------------------------------
// Abstract methods — subclasses must implement.
// -----------------------------------------------------------------------
public abstract validateMove(
playerId: string,
move: TMove,
state: TState
): MoveValidationResult;
protected abstract applyMove(
playerId: string,
move: TMove,
state: TState
): TState;
public abstract isGameOver(state: TState): boolean;
public abstract getGameResult(state: TState): GameResult;
public abstract initializeGame(config: Record<string, unknown>): TState;
// -----------------------------------------------------------------------
// Hook methods — subclasses MAY override.
// -----------------------------------------------------------------------
protected beforeMoveProcessing(_playerId: string, _move: TMove): void {
// Default: no-op. Subclasses override for pre-move side effects.
}
protected afterMoveProcessing(
_playerId: string,
_move: TMove,
_newState: TState
): void {
// Default: no-op. Subclasses override for post-move side effects.
}
protected onPlayerAdded(_playerId: string, _playerData?: unknown): void {
// Default: no-op.
}
protected onPlayerRemoved(_playerId: string, _playerData?: unknown): void {
// Default: no-op.
}
protected onDispose(): void {
// Default: no-op.
}
// -----------------------------------------------------------------------
// Utilities for subclasses.
// -----------------------------------------------------------------------
/**
* Deep-clone a state value. Subclasses MAY override with a more
* efficient cloner (structured-clone, Immer freeze, custom CRDT diff).
*/
protected cloneState(state: TState): TState {
return JSON.parse(JSON.stringify(state)) as TState;
}
protected canPlayerMove(playerId: string): boolean {
return this.players.has(playerId);
}
protected invalid(reason: string): MoveValidationResult {
return { valid: false, reason };
}
protected valid(): MoveValidationResult {
return { valid: true };
}
/**
* Convenience id generator backed by the injected `IdGenerator`. Use
* this in `applyMove` when a deterministic move id is needed (replay,
* audit log).
*/
protected nextId(): string {
return this.idGenerator();
}
}
/**
* Internal test helper: clear the cached OTel instruments so each test
* starts with a fresh meter binding. Exported for the package's own test
* suite — not part of the public API.
*/
export function __resetInstrumentsForTesting(): void {
cachedInstruments = null;
}

View File

@@ -0,0 +1,19 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
/**
* Constructor options accepted by every `AbstractGameLogic` subclass.
* Injecting `clock` and `idGenerator` removes the only two
* non-deterministic surfaces from the game tick — fake them in tests for
* deterministic replays.
*/
export interface AbstractGameLogicOptions<TState> {
readonly initialState: TState;
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
/**
* Override the OTel meter name. Defaults to `@suaveplan/game-server` so
* every game subclass shows up under the same namespace by default;
* subclasses can carve their own meter out by setting this explicitly.
*/
readonly meterName?: string;
}

View File

@@ -0,0 +1,2 @@
export { AbstractGameLogic } from "./abstract-game-logic.js";
export type { AbstractGameLogicOptions } from "./abstract-game-logic.types.js";

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,305 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GAME_SERVER_CODES, GameServerError } from "../../../errors/index.js";
import { GameEventEmitter } from "./game-event-emitter.js";
describe("GameEventEmitter", () => {
it("delivers payloads to subscribed listeners", { timeout: 5000 }, () => {
const e = new GameEventEmitter();
const seen: Array<{ playerId: string }> = [];
e.on("player:reconnected", (p) => seen.push(p));
const fired = e.emit("player:reconnected", { playerId: "alice" });
expect(fired).toBe(true);
expect(seen).toEqual([{ playerId: "alice" }]);
});
it("returns false when emitting an event with no listeners", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
expect(e.emit("game:started", { sessionId: "s1" })).toBe(false);
});
it("once removes the listener after first fire", { timeout: 5000 }, () => {
const e = new GameEventEmitter();
let count = 0;
e.once("game:started", () => {
count += 1;
});
e.emit("game:started", { sessionId: "s1" });
e.emit("game:started", { sessionId: "s1" });
expect(count).toBe(1);
});
it("off removes a previously-registered listener", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
let count = 0;
const l = (): void => {
count += 1;
};
e.on("game:started", l);
e.off("game:started", l);
e.emit("game:started", { sessionId: "s1" });
expect(count).toBe(0);
});
it("off is a no-op for an unregistered listener (no list yet)", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
e.off("game:started", () => undefined);
// also: list exists but listener never registered
e.on("game:started", () => undefined);
e.off("game:started", () => undefined);
// No throw expected
expect(e.listenerCount("game:started")).toBe(1);
});
it("the unsubscribe handle returned by on detaches the listener", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
let count = 0;
const unsub = e.on("game:started", () => {
count += 1;
});
unsub();
e.emit("game:started", { sessionId: "s1" });
expect(count).toBe(0);
});
it("throws GameServerError(LISTENER_LIMIT_EXCEEDED) past the cap", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter({ maxListenersPerKey: 2 });
e.on("game:started", () => undefined);
e.on("game:started", () => undefined);
try {
e.on("game:started", () => undefined);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GameServerError);
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.LISTENER_LIMIT_EXCEEDED
);
}
});
it("safeEmit routes listener errors to the error channel", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
const errors: Error[] = [];
e.on("error", (p) => errors.push(p.error));
e.on("game:started", () => {
throw new Error("boom");
});
const ok = e.safeEmit("game:started", { sessionId: "s1" });
expect(ok).toBe(false);
expect(errors).toHaveLength(1);
expect(errors[0]?.message).toBe("boom");
});
it("safeEmit returns true when at least one listener succeeded", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
let observed = false;
e.on("game:started", () => {
observed = true;
});
e.on("game:started", () => {
throw new Error("noisy");
});
e.on("error", () => undefined);
const ok = e.safeEmit("game:started", { sessionId: "s1" });
expect(ok).toBe(true);
expect(observed).toBe(true);
});
it("safeEmit wraps non-Error throws into Error", { timeout: 5000 }, () => {
const e = new GameEventEmitter();
const errors: Error[] = [];
e.on("error", (p) => errors.push(p.error));
e.on("game:started", () => {
// Throwing a non-Error reaches the wrap path.
const failure: unknown = "literal";
throw failure;
});
e.safeEmit("game:started", { sessionId: "s1" });
expect(errors).toHaveLength(1);
expect(errors[0]?.message).toBe("literal");
});
it("safeEmit returns false when no listeners exist", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
expect(e.safeEmit("game:started", { sessionId: "s1" })).toBe(false);
});
it("safeEmit handles a throwing listener with no error listener registered", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
e.on("game:started", () => {
throw new Error("nobody to catch me");
});
// No "error" subscriber — exercises the errList === undefined branch.
expect(() =>
e.safeEmit("game:started", { sessionId: "s1" })
).not.toThrow();
});
it("waitFor with timeout resolves before timer fires and clears the timeout", {
timeout: 5000,
}, async () => {
const e = new GameEventEmitter();
const promise = e.waitFor("game:started", 1000);
// Emit immediately — handler runs with timer set, exercising the
// `if (timer !== null) clearTimeout(timer)` branch.
e.emit("game:started", { sessionId: "s-fast" });
const payload = await promise;
expect(payload.sessionId).toBe("s-fast");
});
it("safeEmit swallows nested error-handler failures", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
e.on("error", () => {
throw new Error("error handler crashed");
});
e.on("game:started", () => {
throw new Error("primary");
});
// Should not throw despite the nested crash.
expect(() =>
e.safeEmit("game:started", { sessionId: "s1" })
).not.toThrow();
});
it("onMultiple subscribes to several events at once", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
const seen: string[] = [];
const unsub = e.onMultiple(["game:started", "game:paused"], (event) =>
seen.push(event)
);
e.emit("game:started", { sessionId: "s1" });
e.emit("game:paused", { sessionId: "s1" });
unsub();
e.emit("game:started", { sessionId: "s1" });
expect(seen).toEqual(["game:started", "game:paused"]);
});
it("waitFor resolves with the next payload", {
timeout: 5000,
}, async () => {
const e = new GameEventEmitter();
const promise = e.waitFor("game:started");
e.emit("game:started", { sessionId: "s9" });
const payload = await promise;
expect(payload.sessionId).toBe("s9");
});
it("waitFor rejects with GameServerError on timeout", {
timeout: 5000,
}, async () => {
const e = new GameEventEmitter();
try {
await e.waitFor("game:started", 5);
expect.fail("expected reject");
} catch (err) {
expect(err).toBeInstanceOf(GameServerError);
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.EVENT_WAIT_TIMEOUT
);
}
});
it("waitFor with no timeout never schedules a timer", {
timeout: 5000,
}, async () => {
const e = new GameEventEmitter();
const promise = e.waitFor("game:started");
setTimeout(() => {
e.emit("game:started", { sessionId: "s7" });
}, 1);
const payload = await promise;
expect(payload.sessionId).toBe("s7");
});
it("waitFor with timeoutMs<=0 treats the timeout as disabled", {
timeout: 5000,
}, async () => {
const e = new GameEventEmitter();
const promise = e.waitFor("game:started", 0);
e.emit("game:started", { sessionId: "s0" });
const payload = await promise;
expect(payload.sessionId).toBe("s0");
});
it("onN unsubscribes after the configured count", { timeout: 5000 }, () => {
const e = new GameEventEmitter();
let count = 0;
e.onN("game:started", 3, () => {
count += 1;
});
for (let i = 0; i < 5; i++) {
e.emit("game:started", { sessionId: "s" });
}
expect(count).toBe(3);
});
it("onN throws GameServerError(INVALID_LISTENER_COUNT) for non-positive counts", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
try {
e.onN("game:started", 0, () => undefined);
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_LISTENER_COUNT
);
}
});
it("listenerCount reports per-key totals", { timeout: 5000 }, () => {
const e = new GameEventEmitter();
expect(e.listenerCount("game:started")).toBe(0);
e.on("game:started", () => undefined);
e.on("game:started", () => undefined);
expect(e.listenerCount("game:started")).toBe(2);
expect(e.listenerCount("game:ended")).toBe(0);
});
it("removeAllListeners clears every key", { timeout: 5000 }, () => {
const e = new GameEventEmitter();
e.on("game:started", () => undefined);
e.on("game:paused", () => undefined);
e.removeAllListeners();
expect(e.listenerCount("game:started")).toBe(0);
expect(e.listenerCount("game:paused")).toBe(0);
});
it("emit iterates a snapshot — listeners can off themselves mid-emit", {
timeout: 5000,
}, () => {
const e = new GameEventEmitter();
let count = 0;
const l = (): void => {
count += 1;
e.off("game:started", l);
};
e.on("game:started", l);
e.on("game:started", () => {
count += 1;
});
e.emit("game:started", { sessionId: "s1" });
expect(count).toBe(2);
});
});

View File

@@ -0,0 +1,217 @@
import { GAME_SERVER_CODES, GameServerError } from "../../../errors/index.js";
import type { GameEvents } from "../../types/core.types.js";
import type {
GameEventEmitterOptions,
GameEventListener,
GameEventUnsubscribe,
} from "./game-event-emitter.types.js";
/** Default ceiling on listeners per event key. */
const DEFAULT_MAX_LISTENERS_PER_KEY = 100;
/**
* Type-safe event emitter for the game framework.
*
* Listener payloads are a single typed object per key (genesis collapse of
* upstream's variadic tuples). The emitter provides `safeEmit` for opt-in
* error swallowing, `waitFor` for promise-based one-shot subscriptions,
* `onN` for bounded-count subscriptions, and `onMultiple` for multi-key
* fan-out — every helper returns an explicit unsubscribe function.
*/
export class GameEventEmitter {
private readonly listeners = new Map<
keyof GameEvents,
Array<GameEventListener<keyof GameEvents>>
>();
private readonly maxListenersPerKey: number;
public constructor(options: GameEventEmitterOptions = {}) {
this.maxListenersPerKey =
options.maxListenersPerKey ?? DEFAULT_MAX_LISTENERS_PER_KEY;
}
/** Subscribe to an event. Returns an unsubscribe function. */
public on<K extends keyof GameEvents>(
event: K,
listener: GameEventListener<K>
): GameEventUnsubscribe {
const list = this.getOrCreate(event);
if (list.length >= this.maxListenersPerKey) {
throw new GameServerError(
GAME_SERVER_CODES.LISTENER_LIMIT_EXCEEDED,
`Listener limit (${this.maxListenersPerKey}) reached for "${String(event)}"`,
{ context: { event: String(event) } }
);
}
list.push(listener as GameEventListener<keyof GameEvents>);
return () => this.off(event, listener);
}
/** Subscribe once — the listener is removed after its first invocation. */
public once<K extends keyof GameEvents>(
event: K,
listener: GameEventListener<K>
): GameEventUnsubscribe {
const wrapper: GameEventListener<K> = (payload) => {
this.off(event, wrapper);
listener(payload);
};
return this.on(event, wrapper);
}
/** Remove a previously-registered listener. No-op for unknown listeners. */
public off<K extends keyof GameEvents>(
event: K,
listener: GameEventListener<K>
): void {
const list = this.listeners.get(event);
if (list === undefined) return;
const idx = list.indexOf(
listener as GameEventListener<keyof GameEvents>
);
if (idx !== -1) list.splice(idx, 1);
}
/**
* Emit an event. Returns `true` if at least one listener was invoked.
* Listener errors propagate — use `safeEmit` to swallow them and re-
* emit as an `error` event.
*/
public emit<K extends keyof GameEvents>(
event: K,
payload: GameEvents[K]
): boolean {
const list = this.listeners.get(event);
if (list === undefined || list.length === 0) return false;
// Iterate a snapshot so listeners can off-themselves safely.
for (const l of [...list]) (l as GameEventListener<K>)(payload);
return true;
}
/**
* Emit with caught-listener errors re-routed to the `error` channel.
* Returns `true` if at least one listener was invoked successfully.
*/
public safeEmit<K extends keyof GameEvents>(
event: K,
payload: GameEvents[K]
): boolean {
const list = this.listeners.get(event);
if (list === undefined || list.length === 0) return false;
let any = false;
for (const l of [...list]) {
try {
(l as GameEventListener<K>)(payload);
any = true;
} catch (err) {
const error =
err instanceof Error ? err : new Error(String(err));
// Don't recurse into safeEmit("error", ...) — emit raw.
const errList = this.listeners.get("error");
if (errList !== undefined) {
for (const el of [...errList]) {
try {
(el as GameEventListener<"error">)({ error });
} catch {
// Swallow nested error-handler failures — we
// already lost the original; surfacing a second
// crash would mask it further.
}
}
}
}
}
return any;
}
/** Subscribe to multiple events with a single listener. */
public onMultiple<K extends keyof GameEvents>(
events: ReadonlyArray<K>,
listener: (event: K, payload: GameEvents[K]) => void
): GameEventUnsubscribe {
const unsubs: GameEventUnsubscribe[] = [];
for (const event of events) {
const wrapper: GameEventListener<K> = (payload) =>
listener(event, payload);
unsubs.push(this.on(event, wrapper));
}
return () => {
for (const u of unsubs) u();
};
}
/**
* Resolve a promise the next time `event` is emitted, or reject after
* `timeoutMs` if provided. Uses an injectable `setTimeout` so callers
* can drive it with a fake clock.
*/
public waitFor<K extends keyof GameEvents>(
event: K,
timeoutMs?: number
): Promise<GameEvents[K]> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const handler: GameEventListener<K> = (payload) => {
if (timer !== null) clearTimeout(timer);
this.off(event, handler);
resolve(payload);
};
this.on(event, handler);
if (timeoutMs !== undefined && timeoutMs > 0) {
timer = setTimeout(() => {
this.off(event, handler);
reject(
new GameServerError(
GAME_SERVER_CODES.EVENT_WAIT_TIMEOUT,
`Timeout waiting for event: ${String(event)}`,
{ context: { event: String(event), timeoutMs } }
)
);
}, timeoutMs);
}
});
}
/** Subscribe with automatic cleanup after `count` emissions. */
public onN<K extends keyof GameEvents>(
event: K,
count: number,
listener: GameEventListener<K>
): GameEventUnsubscribe {
if (count <= 0) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_LISTENER_COUNT,
`onN(count) must be a positive integer, got ${count}`,
{ context: { count } }
);
}
let n = 0;
const wrapper: GameEventListener<K> = (payload) => {
n += 1;
listener(payload);
if (n >= count) this.off(event, wrapper);
};
return this.on(event, wrapper);
}
/** Number of listeners registered for `event`. */
public listenerCount<K extends keyof GameEvents>(event: K): number {
return this.listeners.get(event)?.length ?? 0;
}
/** Remove every listener for every event. */
public removeAllListeners(): void {
this.listeners.clear();
}
private getOrCreate<K extends keyof GameEvents>(
event: K
): Array<GameEventListener<keyof GameEvents>> {
let list = this.listeners.get(event);
if (list === undefined) {
list = [];
this.listeners.set(event, list);
}
return list;
}
}

View File

@@ -0,0 +1,19 @@
import type { GameEvents } from "../../types/core.types.js";
/** Listener for a single keyed payload. */
export type GameEventListener<K extends keyof GameEvents> = (
payload: GameEvents[K]
) => void;
/** Unsubscribe handle returned from `on`/`once`/`onN`. */
export type GameEventUnsubscribe = () => void;
/** Constructor options for `GameEventEmitter`. */
export interface GameEventEmitterOptions {
/**
* Maximum number of listeners per key. Defaults to 100 — matches upstream
* behaviour, which raised Node's default of 10 because games routinely
* have many subscribers per channel. Set to `Infinity` to disable.
*/
readonly maxListenersPerKey?: number;
}

View File

@@ -0,0 +1,6 @@
export { GameEventEmitter } from "./game-event-emitter.js";
export type {
GameEventEmitterOptions,
GameEventListener,
GameEventUnsubscribe,
} from "./game-event-emitter.types.js";

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,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GAME_EVENT_KEYS, isGameEventKey } from "./game-events.js";
describe("GAME_EVENT_KEYS", () => {
it("contains every documented event family", { timeout: 5000 }, () => {
expect(GAME_EVENT_KEYS).toContain("player:connected");
expect(GAME_EVENT_KEYS).toContain("session:created");
expect(GAME_EVENT_KEYS).toContain("game:ended");
expect(GAME_EVENT_KEYS).toContain("move:processed");
expect(GAME_EVENT_KEYS).toContain("error");
expect(GAME_EVENT_KEYS).toContain("error:state");
});
it("has no duplicates", { timeout: 5000 }, () => {
const set = new Set(GAME_EVENT_KEYS);
expect(set.size).toBe(GAME_EVENT_KEYS.length);
});
});
describe("isGameEventKey", () => {
it("returns true for every registered key", { timeout: 5000 }, () => {
for (const k of GAME_EVENT_KEYS) {
expect(isGameEventKey(k)).toBe(true);
}
});
it("returns false for unknown keys", { timeout: 5000 }, () => {
expect(isGameEventKey("not:a:real:event")).toBe(false);
expect(isGameEventKey("")).toBe(false);
expect(isGameEventKey("player:")).toBe(false);
});
});

View File

@@ -0,0 +1,44 @@
import type { GameEvents } from "../../types/core.types.js";
/**
* Closed array of every well-known event key in the `GameEvents` map. Used
* by the emitter for fan-out helpers (`offAll`, `eachKey`) and by callers
* that want to enumerate the contract surface — for example, an analytics
* subscriber that wires a per-event counter at startup.
*/
export const GAME_EVENT_KEYS: ReadonlyArray<keyof GameEvents> = [
"player:connected",
"player:disconnected",
"player:reconnected",
"player:timeout",
"player:heartbeat",
"player:joined",
"player:left",
"session:created",
"session:destroyed",
"session:updated",
"session:metadata:changed",
"game:initialized",
"game:started",
"game:ended",
"game:paused",
"game:resumed",
"game:state:changed",
"move:requested",
"move:validated",
"move:processed",
"move:rejected",
"error",
"error:game",
"error:network",
"error:state",
] as const;
/**
* Predicate that confirms a string is a registered `GameEvents` key.
* Useful when accepting event names from wire input that needs to be
* validated before being forwarded into the emitter.
*/
export function isGameEventKey(value: string): value is keyof GameEvents {
return (GAME_EVENT_KEYS as ReadonlyArray<string>).includes(value);
}

View File

@@ -0,0 +1 @@
export { GAME_EVENT_KEYS, isGameEventKey } from "./game-events.js";

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,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
GameResult,
MoveValidationResult,
} from "../../../types/common.types.js";
import {
type IGameLogic,
type IGameLogicFixtures,
runIGameLogicConformance,
} from "./i-game-logic.js";
interface CounterState {
readonly count: number;
}
type CounterMove = { readonly delta: number };
class CounterLogic implements IGameLogic<CounterState, CounterMove> {
private state: CounterState = { count: 0 };
private disposed = false;
public initializeGame(config: Record<string, unknown>): CounterState {
const start =
typeof config.start === "number" ? (config.start as number) : 0;
this.state = { count: start };
return this.state;
}
public getGameState(): CounterState {
return this.state;
}
public validateMove(
_playerId: string,
move: CounterMove,
_state: CounterState
): MoveValidationResult {
if (move.delta < 0) return { valid: false, reason: "negative delta" };
return { valid: true };
}
public processMove(
playerId: string,
move: CounterMove,
state: CounterState
): CounterState {
const v = this.validateMove(playerId, move, state);
if (!v.valid) throw new Error(v.reason);
this.state = { count: state.count + move.delta };
return this.state;
}
public isGameOver(state: CounterState): boolean {
return state.count >= 10;
}
public getGameResult(state: CounterState): GameResult {
return { winner: state.count >= 10 ? "player" : null };
}
public dispose(): void {
this.disposed = true;
}
public wasDisposed(): boolean {
return this.disposed;
}
}
describe("IGameLogic conformance", () => {
const fixtures: IGameLogicFixtures<CounterState, CounterMove> = {
config: { start: 0 },
playerId: "p1",
validMove: { delta: 1 },
invalidMove: { delta: -1 },
};
it("passes every scenario for a compliant implementation", {
timeout: 5000,
}, () => {
const results = runIGameLogicConformance(
() => new CounterLogic(),
fixtures
);
for (const r of results) {
expect(r.passed, `${r.scenario} — ${r.detail ?? ""}`).toBe(true);
}
});
it("reports invalid-move scenario when impl flags falsy", {
timeout: 5000,
}, () => {
const results = runIGameLogicConformance(
() => new CounterLogic(),
fixtures
);
const inv = results.find((r) =>
r.scenario.startsWith("validateMove returns invalid")
);
expect(inv?.passed).toBe(true);
expect(inv?.detail).toBe("negative delta");
});
it("reports failure when an impl violates the contract", {
timeout: 5000,
}, () => {
class BrokenLogic extends CounterLogic {
public override isGameOver(_state: CounterState): boolean {
// Lies about its return type — returns a non-boolean.
return "no" as unknown as boolean;
}
public override getGameResult(_state: CounterState): GameResult {
return {} as GameResult;
}
}
const results = runIGameLogicConformance(
() => new BrokenLogic(),
fixtures
);
const isOver = results.find((r) => r.scenario.startsWith("isGameOver"));
const winner = results.find((r) =>
r.scenario.startsWith("getGameResult")
);
expect(isOver?.passed).toBe(false);
expect(winner?.passed).toBe(false);
});
});

View File

@@ -0,0 +1,133 @@
import type {
GameResult,
MoveValidationResult,
} from "../../../types/common.types.js";
/**
* Strategy-pattern contract every concrete game-logic implementation must
* satisfy. The two generics `TState` / `TMove` keep the surface fully typed
* — never use `any` (genesis tightens upstream's `any`-by-default).
*/
export interface IGameLogic<TState = unknown, TMove = unknown> {
validateMove(
playerId: string,
move: TMove,
state: TState
): MoveValidationResult;
processMove(playerId: string, move: TMove, state: TState): TState;
getGameState(): TState;
isGameOver(state: TState): boolean;
getGameResult(state: TState): GameResult;
initializeGame(config: Record<string, unknown>): TState;
dispose(): void;
}
/**
* Result captured by every conformance scenario. Each entry names the
* scenario, what assertion was checked, and whether it passed.
*/
export interface ConformanceCheckResult {
readonly scenario: string;
readonly passed: boolean;
readonly detail?: string | undefined;
}
/** Factory the caller supplies — receives no args, returns a fresh impl. */
export type IGameLogicFactory<
TState = unknown,
TMove = unknown,
> = () => IGameLogic<TState, TMove>;
/**
* Run the `IGameLogic` contract conformance suite against `factory`. Returns
* a list of per-scenario results. Throws nothing; assertion failures are
* reported as `{ passed: false, detail }` entries so callers can assert on
* the whole list.
*
* The harness exercises (in order): `initializeGame`, `getGameState`,
* `validateMove` truthy + falsy branches, `processMove` happy path,
* `isGameOver`, `getGameResult`, and `dispose`. It also confirms that
* `processMove` returns the new state from `validateMove(valid: true)` and
* that `validateMove(valid: false)` is observable by the caller without
* mutating internal state.
*/
export function runIGameLogicConformance<TState, TMove>(
factory: IGameLogicFactory<TState, TMove>,
fixtures: IGameLogicFixtures<TState, TMove>
): ConformanceCheckResult[] {
const results: ConformanceCheckResult[] = [];
const impl = factory();
const initial = impl.initializeGame(fixtures.config);
results.push({
scenario: "initializeGame returns a state",
passed: initial !== undefined && initial !== null,
});
const current = impl.getGameState();
results.push({
scenario: "getGameState returns the current state",
passed: current !== undefined && current !== null,
});
const validResult = impl.validateMove(
fixtures.playerId,
fixtures.validMove,
initial
);
results.push({
scenario: "validateMove returns valid for a legal move",
passed: validResult.valid === true,
detail: validResult.reason,
});
const invalidResult = impl.validateMove(
fixtures.playerId,
fixtures.invalidMove,
initial
);
results.push({
scenario: "validateMove returns invalid for an illegal move",
passed: invalidResult.valid === false,
detail: invalidResult.reason,
});
const nextState = impl.processMove(
fixtures.playerId,
fixtures.validMove,
initial
);
results.push({
scenario: "processMove returns the next state",
passed: nextState !== undefined,
});
const gameOver = impl.isGameOver(nextState);
results.push({
scenario: "isGameOver returns boolean for any state",
passed: typeof gameOver === "boolean",
});
const result = impl.getGameResult(nextState);
results.push({
scenario: "getGameResult returns a GameResult shape",
passed: "winner" in result,
});
impl.dispose();
results.push({
scenario: "dispose returns without throwing",
passed: true,
});
return results;
}
/** Fixture inputs required to drive the conformance harness. */
export interface IGameLogicFixtures<TState = unknown, TMove = unknown> {
readonly config: Record<string, unknown>;
readonly playerId: string;
readonly validMove: TMove;
readonly invalidMove: TMove;
readonly _stateMarker?: TState;
}

View File

@@ -0,0 +1,7 @@
export {
type ConformanceCheckResult,
type IGameLogic,
type IGameLogicFactory,
type IGameLogicFixtures,
runIGameLogicConformance,
} from "./i-game-logic.js";

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,199 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PlayerConnection, PlayerInfo } from "../../types/core.types.js";
import {
type IPlayerManager,
runIPlayerManagerConformance,
} from "./i-player-manager.js";
interface MutableRecord {
playerId: string;
sessionId: string;
status: "connected" | "disconnected" | "reconnecting" | "timeout";
connectedAt: number;
lastHeartbeat: number;
metadata: Record<string, unknown>;
}
class RefPlayerManager implements IPlayerManager {
private readonly connections = new Map<string, MutableRecord>();
private now = 0;
private monitorIntervalMs = 0;
private tick(): number {
this.now += 1;
return this.now;
}
public connect(
playerId: string,
sessionId: string,
metadata: Record<string, unknown> = {}
): PlayerConnection {
const rec: MutableRecord = {
playerId,
sessionId,
status: "connected",
connectedAt: this.tick(),
lastHeartbeat: this.now,
metadata: { ...metadata },
};
this.connections.set(playerId, rec);
return { ...rec };
}
public disconnect(playerId: string, _reason: string): void {
const rec = this.connections.get(playerId);
if (rec !== undefined) {
rec.status = "disconnected";
}
}
public getConnection(playerId: string): PlayerConnection | null {
const rec = this.connections.get(playerId);
return rec === undefined
? null
: { ...rec, metadata: { ...rec.metadata } };
}
public getSessionPlayers(
sessionId: string
): ReadonlyArray<PlayerConnection> {
const out: PlayerConnection[] = [];
for (const rec of this.connections.values()) {
if (rec.sessionId === sessionId) out.push({ ...rec });
}
return out;
}
public getAllConnections(): ReadonlyArray<PlayerConnection> {
return Array.from(this.connections.values()).map((r) => ({ ...r }));
}
public heartbeat(playerId: string): void {
const rec = this.connections.get(playerId);
if (rec !== undefined) rec.lastHeartbeat = this.tick();
}
public checkTimeouts(): void {
for (const rec of this.connections.values()) {
if (this.tick() - rec.lastHeartbeat > 100) rec.status = "timeout";
}
}
public updateMetadata(
playerId: string,
metadata: Record<string, unknown>
): void {
const rec = this.connections.get(playerId);
if (rec !== undefined) {
rec.metadata = { ...rec.metadata, ...metadata };
}
}
public getPlayer(playerId: string): PlayerInfo | null {
const rec = this.connections.get(playerId);
if (rec === undefined) return null;
return {
id: rec.playerId,
sessionId: rec.sessionId,
metadata: { ...rec.metadata },
};
}
public startHeartbeatMonitor(intervalMs: number): void {
this.monitorIntervalMs = intervalMs;
}
public stopHeartbeatMonitor(): void {
this.monitorIntervalMs = 0;
}
public getMonitorInterval(): number {
return this.monitorIntervalMs;
}
}
describe("IPlayerManager conformance", () => {
it("passes every scenario for a compliant implementation", {
timeout: 5000,
}, () => {
const results = runIPlayerManagerConformance(
() => new RefPlayerManager()
);
for (const r of results) {
expect(r.passed, `${r.scenario} — ${r.detail ?? ""}`).toBe(true);
}
});
it("reports failure when connect returns a mismatched id", {
timeout: 5000,
}, () => {
class Broken extends RefPlayerManager {
public override connect(
_playerId: string,
sessionId: string,
meta?: Record<string, unknown>
): PlayerConnection {
return super.connect("WRONG", sessionId, meta);
}
}
const results = runIPlayerManagerConformance(() => new Broken());
const connectResult = results.find((r) =>
r.scenario.startsWith("connect")
);
expect(connectResult?.passed).toBe(false);
});
it("reports failure when getConnection returns null after connect", {
timeout: 5000,
}, () => {
class Broken extends RefPlayerManager {
public override getConnection(
_id: string
): PlayerConnection | null {
return null;
}
}
const results = runIPlayerManagerConformance(() => new Broken());
const getResult = results.find((r) =>
r.scenario.startsWith("getConnection")
);
const updateMeta = results.find((r) =>
r.scenario.startsWith("updateMetadata")
);
expect(getResult?.passed).toBe(false);
expect(updateMeta?.passed).toBe(false);
});
it("reports session/all/getPlayer failures for an empty-returning impl", {
timeout: 5000,
}, () => {
class Empty extends RefPlayerManager {
public override getSessionPlayers(): ReadonlyArray<PlayerConnection> {
return [];
}
public override getAllConnections(): ReadonlyArray<PlayerConnection> {
return [];
}
public override getPlayer(): PlayerInfo | null {
return { id: "WRONG", metadata: {} };
}
public override disconnect(_id: string): void {
/* refuse to disconnect */
}
}
const results = runIPlayerManagerConformance(() => new Empty());
const session = results.find((r) =>
r.scenario.startsWith("getSessionPlayers")
);
const all = results.find((r) =>
r.scenario.startsWith("getAllConnections")
);
const player = results.find((r) => r.scenario.startsWith("getPlayer"));
const disc = results.find((r) => r.scenario.startsWith("disconnect"));
expect(session?.passed).toBe(false);
expect(all?.passed).toBe(false);
expect(player?.passed).toBe(false);
expect(disc?.passed).toBe(false);
});
});

View File

@@ -0,0 +1,108 @@
import type { PlayerConnection, PlayerInfo } from "../../types/core.types.js";
/**
* Contract for managing player connections and lifecycle. Every concrete
* implementation must provide the eleven methods below — the conformance
* harness exercises each one.
*/
export interface IPlayerManager {
connect(
playerId: string,
sessionId: string,
metadata?: Record<string, unknown>
): PlayerConnection;
disconnect(playerId: string, reason: string): void;
getConnection(playerId: string): PlayerConnection | null;
getSessionPlayers(sessionId: string): ReadonlyArray<PlayerConnection>;
getAllConnections(): ReadonlyArray<PlayerConnection>;
heartbeat(playerId: string): void;
checkTimeouts(): void;
updateMetadata(playerId: string, metadata: Record<string, unknown>): void;
getPlayer(playerId: string): PlayerInfo | null;
startHeartbeatMonitor(intervalMs: number): void;
stopHeartbeatMonitor(): void;
}
export interface IPlayerManagerCheckResult {
readonly scenario: string;
readonly passed: boolean;
readonly detail?: string | undefined;
}
export type IPlayerManagerFactory = () => IPlayerManager;
/**
* Run the `IPlayerManager` conformance suite. Exercises connect → heartbeat
* → updateMetadata → disconnect → reconnect → getAllConnections →
* getSessionPlayers → start/stop monitor → checkTimeouts.
*/
export function runIPlayerManagerConformance(
factory: IPlayerManagerFactory
): IPlayerManagerCheckResult[] {
const results: IPlayerManagerCheckResult[] = [];
const mgr = factory();
const conn = mgr.connect("p1", "s1", { tier: "gold" });
results.push({
scenario: "connect returns a PlayerConnection",
passed: conn.playerId === "p1" && conn.sessionId === "s1",
});
const got = mgr.getConnection("p1");
results.push({
scenario: "getConnection returns the registered connection",
passed: got?.playerId === "p1",
});
mgr.heartbeat("p1");
results.push({ scenario: "heartbeat does not throw", passed: true });
mgr.updateMetadata("p1", { region: "eu" });
const afterMeta = mgr.getConnection("p1");
results.push({
scenario: "updateMetadata mutates the stored connection",
passed: afterMeta?.metadata["region"] === "eu",
});
const session = mgr.getSessionPlayers("s1");
results.push({
scenario: "getSessionPlayers returns players for the session",
passed: session.some((c) => c.playerId === "p1"),
});
const all = mgr.getAllConnections();
results.push({
scenario: "getAllConnections returns at least the active player",
passed: all.length >= 1,
});
const info = mgr.getPlayer("p1");
results.push({
scenario: "getPlayer returns PlayerInfo or null",
passed: info === null || info.id === "p1",
});
mgr.startHeartbeatMonitor(1000);
results.push({
scenario: "startHeartbeatMonitor does not throw",
passed: true,
});
mgr.stopHeartbeatMonitor();
results.push({
scenario: "stopHeartbeatMonitor does not throw",
passed: true,
});
mgr.checkTimeouts();
results.push({ scenario: "checkTimeouts does not throw", passed: true });
mgr.disconnect("p1", "test");
const afterDisc = mgr.getConnection("p1");
results.push({
scenario: "disconnect removes or marks the connection",
passed: afterDisc === null || afterDisc.status !== "connected",
});
return results;
}

View File

@@ -0,0 +1,6 @@
export {
type IPlayerManager,
type IPlayerManagerCheckResult,
type IPlayerManagerFactory,
runIPlayerManagerConformance,
} from "./i-player-manager.js";

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,301 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
SessionData,
SessionMetadata,
SessionNode,
SessionType,
} from "../../types/core.types.js";
import {
type ISessionManager,
runISessionManagerConformance,
} from "./i-session-manager.js";
interface NodeRec {
id: string;
type: SessionType;
parentId: string | null;
metadata: SessionMetadata;
children: Set<string>;
}
interface NodeForStubborn {
id: string;
type: SessionType;
parentId: string | null;
metadata: SessionMetadata;
}
class RefSessionManager implements ISessionManager {
private readonly nodes = new Map<string, NodeRec>();
private toNode(rec: NodeRec): SessionNode {
const parent =
rec.parentId === null ? null : this.toNodeShallow(rec.parentId);
const children: SessionNode[] = [];
for (const cid of rec.children) {
const child = this.nodes.get(cid);
if (child !== undefined) children.push(this.toNodeShallow(cid));
}
return {
id: rec.id,
type: rec.type,
parent,
children,
metadata: { ...rec.metadata },
};
}
private toNodeShallow(id: string): SessionNode {
const r = this.nodes.get(id);
if (r === undefined) throw new Error(`missing ${id}`);
return {
id: r.id,
type: r.type,
parent: null,
children: [],
metadata: { ...r.metadata },
};
}
public createSession(
parentId: string | null,
data: SessionData
): SessionNode {
const rec: NodeRec = {
id: data.id,
type: data.type,
parentId,
metadata: { ...data.metadata },
children: new Set(),
};
this.nodes.set(data.id, rec);
if (parentId !== null) {
const p = this.nodes.get(parentId);
if (p !== undefined) p.children.add(data.id);
}
return this.toNode(rec);
}
public getSession(sessionId: string): SessionNode | null {
const r = this.nodes.get(sessionId);
return r === undefined ? null : this.toNode(r);
}
public destroySession(sessionId: string): void {
const r = this.nodes.get(sessionId);
if (r === undefined) return;
for (const cid of [...r.children]) this.destroySession(cid);
if (r.parentId !== null) {
const p = this.nodes.get(r.parentId);
if (p !== undefined) p.children.delete(sessionId);
}
this.nodes.delete(sessionId);
}
public getSessionsByType(type: SessionType): ReadonlyArray<SessionNode> {
const out: SessionNode[] = [];
for (const r of this.nodes.values()) {
if (r.type === type) out.push(this.toNode(r));
}
return out;
}
public getChildren(sessionId: string): ReadonlyArray<SessionNode> {
const r = this.nodes.get(sessionId);
if (r === undefined) return [];
const out: SessionNode[] = [];
for (const cid of r.children) {
const c = this.nodes.get(cid);
if (c !== undefined) out.push(this.toNode(c));
}
return out;
}
public getParent(sessionId: string): SessionNode | null {
const r = this.nodes.get(sessionId);
if (r === undefined || r.parentId === null) return null;
const p = this.nodes.get(r.parentId);
return p === undefined ? null : this.toNode(p);
}
public updateMetadata(
sessionId: string,
metadata: Partial<SessionMetadata>
): void {
const r = this.nodes.get(sessionId);
if (r === undefined) return;
r.metadata = { ...r.metadata, ...metadata } as SessionMetadata;
}
public getAllSessions(): ReadonlyArray<SessionNode> {
return Array.from(this.nodes.values()).map((r) => this.toNode(r));
}
public async persist(): Promise<void> {
await Promise.resolve();
}
public async restore(): Promise<void> {
await Promise.resolve();
}
}
describe("ISessionManager conformance", () => {
it("passes every scenario for a compliant implementation", {
timeout: 5000,
}, async () => {
const results = await runISessionManagerConformance(
() => new RefSessionManager()
);
for (const r of results) {
expect(r.passed, `${r.scenario} — ${r.detail ?? ""}`).toBe(true);
}
});
it("reports failure when destroySession does not cascade to children", {
timeout: 5000,
}, async () => {
// Manager that destroys only the top-level node — leaves children alive.
class Stubborn implements ISessionManager {
private readonly nodes = new Map<string, NodeForStubborn>();
createSession(
parentId: string | null,
d: SessionData
): SessionNode {
this.nodes.set(d.id, {
id: d.id,
type: d.type,
parentId,
metadata: { ...d.metadata },
});
return {
id: d.id,
type: d.type,
parent: null,
children: [],
metadata: d.metadata,
};
}
getSession(id: string): SessionNode | null {
const r = this.nodes.get(id);
return r === undefined
? null
: {
id: r.id,
type: r.type,
parent: null,
children: [],
metadata: r.metadata,
};
}
destroySession(id: string): void {
this.nodes.delete(id); // does NOT cascade
}
getSessionsByType(type: SessionType): ReadonlyArray<SessionNode> {
const out: SessionNode[] = [];
for (const r of this.nodes.values()) {
if (r.type === type) {
const v = this.getSession(r.id);
if (v !== null) out.push(v);
}
}
return out;
}
getChildren(id: string): ReadonlyArray<SessionNode> {
const out: SessionNode[] = [];
for (const r of this.nodes.values()) {
if (r.parentId === id) {
const v = this.getSession(r.id);
if (v !== null) out.push(v);
}
}
return out;
}
getParent(id: string): SessionNode | null {
const r = this.nodes.get(id);
if (r === undefined || r.parentId === null) return null;
return this.getSession(r.parentId);
}
updateMetadata(id: string, m: Partial<SessionMetadata>): void {
const r = this.nodes.get(id);
if (r !== undefined) {
r.metadata = { ...r.metadata, ...m } as SessionMetadata;
}
}
getAllSessions(): ReadonlyArray<SessionNode> {
const out: SessionNode[] = [];
for (const r of this.nodes.values()) {
const v = this.getSession(r.id);
if (v !== null) out.push(v);
}
return out;
}
async persist(): Promise<void> {
await Promise.resolve();
}
async restore(): Promise<void> {
await Promise.resolve();
}
}
const results = await runISessionManagerConformance(
() => new Stubborn()
);
const destroy = results.find((r) =>
r.scenario.startsWith("destroySession")
);
expect(destroy?.passed).toBe(false);
});
it("reports failures across the contract for a no-op manager", {
timeout: 5000,
}, async () => {
class NoOp implements ISessionManager {
createSession(_p: string | null, d: SessionData): SessionNode {
// Returns wrong id to trigger create-scenario failure
return {
id: "wrong",
type: d.type,
parent: null,
children: [],
metadata: d.metadata,
};
}
getSession(): SessionNode | null {
return null;
}
destroySession(): void {
/* no-op */
}
getSessionsByType(): ReadonlyArray<SessionNode> {
return [];
}
getChildren(): ReadonlyArray<SessionNode> {
return [];
}
getParent(): SessionNode | null {
return null;
}
updateMetadata(): void {
/* no-op */
}
getAllSessions(): ReadonlyArray<SessionNode> {
return [];
}
async persist(): Promise<void> {
/* no-op */
}
async restore(): Promise<void> {
/* no-op */
}
}
const results = await runISessionManagerConformance(() => new NoOp());
for (const r of results.filter(
(x) =>
!x.scenario.startsWith("persist") &&
!x.scenario.startsWith("restore") &&
!x.scenario.startsWith("destroySession")
)) {
expect(r.passed, `${r.scenario} should fail`).toBe(false);
}
});
});

View File

@@ -0,0 +1,126 @@
import type {
SessionData,
SessionMetadata,
SessionNode,
SessionType,
} from "../../types/core.types.js";
/**
* Contract for hierarchical session management. Implementations support
* unlimited nesting (`venue → table → queue → game → player`) and MUST
* provide O(1) lookup by id.
*/
export interface ISessionManager {
createSession(
parentId: string | null,
sessionData: SessionData
): SessionNode;
getSession(sessionId: string): SessionNode | null;
destroySession(sessionId: string): void;
getSessionsByType(type: SessionType): ReadonlyArray<SessionNode>;
getChildren(sessionId: string): ReadonlyArray<SessionNode>;
getParent(sessionId: string): SessionNode | null;
updateMetadata(sessionId: string, metadata: Partial<SessionMetadata>): void;
getAllSessions(): ReadonlyArray<SessionNode>;
persist(): Promise<void>;
restore(): Promise<void>;
}
export interface ISessionManagerCheckResult {
readonly scenario: string;
readonly passed: boolean;
readonly detail?: string | undefined;
}
export type ISessionManagerFactory = () => ISessionManager;
/**
* Run the `ISessionManager` conformance suite. Returns an array of per-
* scenario results.
*/
export async function runISessionManagerConformance(
factory: ISessionManagerFactory
): Promise<ISessionManagerCheckResult[]> {
const results: ISessionManagerCheckResult[] = [];
const mgr = factory();
const now = 1;
const meta: SessionMetadata = { createdAt: now, updatedAt: now };
const venue = mgr.createSession(null, {
id: "venue1",
type: "venue",
metadata: meta,
});
results.push({
scenario: "createSession returns a SessionNode",
passed: venue.id === "venue1" && venue.type === "venue",
});
const table = mgr.createSession("venue1", {
id: "table1",
type: "table",
metadata: meta,
});
results.push({
scenario: "createSession nests under parent",
passed: table.parent?.id === "venue1",
});
const got = mgr.getSession("venue1");
results.push({
scenario: "getSession returns the registered node",
passed: got?.id === "venue1",
});
const byType = mgr.getSessionsByType("table");
results.push({
scenario: "getSessionsByType filters by type",
passed: byType.some((n) => n.id === "table1"),
});
const children = mgr.getChildren("venue1");
results.push({
scenario: "getChildren returns the immediate children",
passed: children.some((n) => n.id === "table1"),
});
const parent = mgr.getParent("table1");
results.push({
scenario: "getParent returns the parent node",
passed: parent?.id === "venue1",
});
mgr.updateMetadata("venue1", { updatedAt: 2 });
const afterMeta = mgr.getSession("venue1");
results.push({
scenario: "updateMetadata mutates session metadata",
passed: afterMeta?.metadata.updatedAt === 2,
});
const all = mgr.getAllSessions();
results.push({
scenario: "getAllSessions returns at least the created nodes",
passed: all.length >= 2,
});
await mgr.persist();
results.push({
scenario: "persist resolves without throwing",
passed: true,
});
await mgr.restore();
results.push({
scenario: "restore resolves without throwing",
passed: true,
});
mgr.destroySession("venue1");
const afterDestroy = mgr.getSession("venue1");
results.push({
scenario: "destroySession removes the node and its descendants",
passed: afterDestroy === null && mgr.getSession("table1") === null,
});
return results;
}

View File

@@ -0,0 +1,6 @@
export {
type ISessionManager,
type ISessionManagerCheckResult,
type ISessionManagerFactory,
runISessionManagerConformance,
} from "./i-session-manager.js";

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,172 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { StateChangeEvent } from "../../types/core.types.js";
import {
type IStateManager,
type IStateManagerFixtures,
runIStateManagerConformance,
} from "./i-state-manager.js";
interface ScoreState {
a: number;
b: number;
}
class RefStateManager implements IStateManager<ScoreState> {
private state: ScoreState;
private readonly initial: ScoreState;
private readonly history: StateChangeEvent<ScoreState>[] = [];
private readonly listeners = new Set<
(event: StateChangeEvent<ScoreState>) => void
>();
private now = 0;
public constructor(initial: ScoreState) {
this.state = { ...initial };
this.initial = { ...initial };
}
private record(previous: ScoreState): void {
this.now += 1;
const event: StateChangeEvent<ScoreState> = {
previousState: { ...previous },
currentState: { ...this.state },
timestamp: this.now,
};
this.history.push(event);
for (const l of this.listeners) l(event);
}
public getState(): Readonly<ScoreState> {
return { ...this.state };
}
public update<K extends keyof ScoreState>(
key: K,
value: ScoreState[K]
): void {
const prev = { ...this.state };
this.state = { ...this.state, [key]: value };
this.record(prev);
}
public updateMany(updates: Partial<ScoreState>): void {
const prev = { ...this.state };
this.state = { ...this.state, ...updates };
this.record(prev);
}
public setState(next: ScoreState): void {
const prev = { ...this.state };
this.state = { ...next };
this.record(prev);
}
public subscribe(
listener: (event: StateChangeEvent<ScoreState>) => void
): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
public reset(): void {
const prev = { ...this.state };
this.state = { ...this.initial };
this.record(prev);
}
public getHistory(): ReadonlyArray<StateChangeEvent<ScoreState>> {
return [...this.history];
}
public clearHistory(): void {
this.history.length = 0;
}
}
describe("IStateManager conformance", () => {
const fixtures: IStateManagerFixtures<ScoreState> = {
updateKey: "a",
updateValue: 1,
partialUpdate: { b: 2 },
replacement: { a: 10, b: 20 },
};
it("passes every scenario for a compliant implementation", {
timeout: 5000,
}, () => {
const results = runIStateManagerConformance(
() => new RefStateManager({ a: 0, b: 0 }),
fixtures
);
for (const r of results) {
expect(r.passed, `${r.scenario} — ${r.detail ?? ""}`).toBe(true);
}
});
it("flags failures for an impl that does not emit events", {
timeout: 5000,
}, () => {
class Silent extends RefStateManager {
public override subscribe(): () => void {
return () => undefined;
}
}
const results = runIStateManagerConformance(
() => new Silent({ a: 0, b: 0 }),
fixtures
);
const upd = results.find((r) =>
r.scenario.startsWith("update mutates")
);
const updMany = results.find((r) =>
r.scenario.startsWith("updateMany emits")
);
const setSt = results.find((r) =>
r.scenario.startsWith("setState replaces")
);
expect(upd?.passed).toBe(false);
expect(updMany?.passed).toBe(false);
expect(setSt?.passed).toBe(false);
});
it("flags failure when unsubscribe does not stop events", {
timeout: 5000,
}, () => {
class Sticky extends RefStateManager {
public override subscribe(
listener: (event: StateChangeEvent<ScoreState>) => void
): () => void {
super.subscribe(listener);
return () => undefined; // does NOT actually unsubscribe
}
}
const results = runIStateManagerConformance(
() => new Sticky({ a: 0, b: 0 }),
fixtures
);
const stop = results.find((r) =>
r.scenario.startsWith("unsubscribe stops")
);
expect(stop?.passed).toBe(false);
});
it("flags failure when getHistory always returns empty", {
timeout: 5000,
}, () => {
class Forgetful extends RefStateManager {
public override getHistory(): ReadonlyArray<
StateChangeEvent<ScoreState>
> {
return [];
}
}
const results = runIStateManagerConformance(
() => new Forgetful({ a: 0, b: 0 }),
fixtures
);
const hist = results.find((r) =>
r.scenario.startsWith("getHistory returns")
);
expect(hist?.passed).toBe(false);
});
});

View File

@@ -0,0 +1,104 @@
import type { StateChangeEvent } from "../../types/core.types.js";
/**
* Contract for immutable state management with observability. Concrete
* implementations are free to back the state with a plain object, an
* Immer draft, a Redux store, or any CRDT — as long as the contract holds.
*/
export interface IStateManager<TState extends object> {
getState(): Readonly<TState>;
update<K extends keyof TState>(key: K, value: TState[K]): void;
updateMany(updates: Partial<TState>): void;
setState(newState: TState): void;
subscribe(listener: (event: StateChangeEvent<TState>) => void): () => void;
reset(): void;
getHistory(): ReadonlyArray<StateChangeEvent<TState>>;
clearHistory(): void;
}
export interface IStateManagerCheckResult {
readonly scenario: string;
readonly passed: boolean;
readonly detail?: string | undefined;
}
export type IStateManagerFactory<TState extends object> =
() => IStateManager<TState>;
/**
* Conformance harness for `IStateManager`. Pushes the implementation
* through getState → update → updateMany → setState → subscribe → reset →
* getHistory → clearHistory.
*/
export function runIStateManagerConformance<TState extends object>(
factory: IStateManagerFactory<TState>,
fixtures: IStateManagerFixtures<TState>
): IStateManagerCheckResult[] {
const results: IStateManagerCheckResult[] = [];
const mgr = factory();
const initial = mgr.getState();
results.push({
scenario: "getState returns the current state",
passed: initial !== undefined,
});
let observed = 0;
const unsub = mgr.subscribe(() => {
observed += 1;
});
results.push({
scenario: "subscribe returns an unsubscribe function",
passed: typeof unsub === "function",
});
mgr.update(fixtures.updateKey, fixtures.updateValue);
results.push({
scenario: "update mutates the state for a single key",
passed: observed >= 1,
});
mgr.updateMany(fixtures.partialUpdate);
results.push({
scenario: "updateMany emits at least one change event",
passed: observed >= 2,
});
mgr.setState(fixtures.replacement);
results.push({
scenario: "setState replaces the entire state",
passed: observed >= 3,
});
unsub();
mgr.update(fixtures.updateKey, fixtures.updateValue);
results.push({
scenario: "unsubscribe stops further events",
passed: observed === 3,
});
const history = mgr.getHistory();
results.push({
scenario: "getHistory returns the recorded transitions",
passed: history.length >= 1,
});
mgr.clearHistory();
const cleared = mgr.getHistory();
results.push({
scenario: "clearHistory empties the history buffer",
passed: cleared.length === 0,
});
mgr.reset();
results.push({ scenario: "reset returns without throwing", passed: true });
return results;
}
export interface IStateManagerFixtures<TState extends object> {
readonly updateKey: keyof TState;
readonly updateValue: TState[keyof TState];
readonly partialUpdate: Partial<TState>;
readonly replacement: TState;
}

View File

@@ -0,0 +1,7 @@
export {
type IStateManager,
type IStateManagerCheckResult,
type IStateManagerFactory,
type IStateManagerFixtures,
runIStateManagerConformance,
} from "./i-state-manager.js";

View File

@@ -0,0 +1,144 @@
/**
* Shared core types used by interfaces, events, and the abstract base class.
*
* Genesis tightens upstream `Record<string, any>` slots to
* `Record<string, unknown>` and uses readonly tuples / arrays where the
* upstream signatures were mutable. Runtime Zod schemas are co-located with
* the modules that own the surface (`types/common.ts` for `PlayerInfo` /
* `SessionMetadata` / etc.).
*/
import type {
PlayerInfo,
SessionMetadata,
SessionType,
} from "../../types/common.types.js";
/**
* Connection lifecycle status of a player tracked by `IPlayerManager`.
*/
export type ConnectionStatus =
| "connected"
| "disconnected"
| "reconnecting"
| "timeout";
/**
* Connection record surfaced by `IPlayerManager.connect` / `getConnection`.
*
* All timestamps are milliseconds since the epoch produced by the injected
* `Clock` — never `Date.now()` from the caller.
*/
export interface PlayerConnection {
readonly playerId: string;
readonly sessionId: string;
readonly status: ConnectionStatus;
readonly connectedAt: number;
readonly lastHeartbeat: number;
readonly metadata: Record<string, unknown>;
}
/**
* Hierarchical session node returned by `ISessionManager` accessors. Children
* are an immutable snapshot — mutate via `createSession` / `destroySession`.
*/
export interface SessionNode {
readonly id: string;
readonly type: SessionType;
readonly parent: SessionNode | null;
readonly children: ReadonlyArray<SessionNode>;
readonly metadata: SessionMetadata;
}
/**
* Constructor payload accepted by `ISessionManager.createSession`.
*/
export interface SessionData {
readonly id: string;
readonly type: SessionType;
readonly metadata: SessionMetadata;
}
/**
* State-transition record published by `IStateManager.subscribe` listeners.
*/
export interface StateChangeEvent<TState extends object> {
readonly previousState: TState;
readonly currentState: TState;
readonly timestamp: number;
}
/**
* Typed payload-per-key event map consumed by `GameEventEmitter`. Genesis
* collapses upstream's variadic `[a, b]` tuples to a single object payload
* per event — this maps cleanly onto `@suaveplan/events`'s `EventBus<TMap>`
* single-payload listener model.
*/
export interface GameEvents {
"player:connected": PlayerConnection;
"player:disconnected": {
readonly playerId: string;
readonly reason: string;
};
"player:reconnected": { readonly playerId: string };
"player:timeout": { readonly playerId: string };
"player:heartbeat": {
readonly playerId: string;
readonly timestamp: number;
};
"player:joined": { readonly playerId: string; readonly sessionId: string };
"player:left": { readonly playerId: string; readonly sessionId: string };
"session:created": { readonly session: SessionNode };
"session:destroyed": { readonly sessionId: string };
"session:updated": { readonly session: SessionNode };
"session:metadata:changed": {
readonly sessionId: string;
readonly metadata: Partial<SessionMetadata>;
};
"game:initialized": {
readonly sessionId: string;
readonly config: Record<string, unknown>;
};
"game:started": { readonly sessionId: string };
"game:ended": {
readonly sessionId: string;
readonly result: GameResultRef;
};
"game:paused": { readonly sessionId: string };
"game:resumed": { readonly sessionId: string };
"game:state:changed": {
readonly sessionId: string;
readonly newState: unknown;
};
"move:requested": { readonly playerId: string; readonly move: unknown };
"move:validated": {
readonly playerId: string;
readonly move: unknown;
readonly valid: boolean;
};
"move:processed": {
readonly playerId: string;
readonly move: unknown;
readonly newState: unknown;
};
"move:rejected": {
readonly playerId: string;
readonly move: unknown;
readonly reason: string;
};
error: { readonly error: Error };
"error:game": { readonly sessionId: string; readonly error: Error };
"error:network": { readonly error: Error };
"error:state": { readonly error: Error };
}
/**
* Opaque alias to break the cycle between this file and `types/common.types`.
* The concrete `GameResult` shape is defined in `common.types.ts`; importing
* it here would re-create the dependency loop the file split was meant to
* solve.
*/
export type GameResultRef = import("../../types/common.types.js").GameResult;
/** Re-exports of the contract types shared with `types/common.types`. */
export type { PlayerInfo, SessionMetadata, SessionType };

View File

@@ -0,0 +1,12 @@
export type {
ConnectionStatus,
GameEvents,
GameResultRef,
PlayerConnection,
PlayerInfo,
SessionData,
SessionMetadata,
SessionNode,
SessionType,
StateChangeEvent,
} from "./core.types.js";

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,139 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { DIContainer } from "./di-container";
import type { ServiceIdentifier } from "./di-container.types";
describe("DIContainer", () => {
let container: DIContainer;
beforeEach(() => {
container = new DIContainer();
});
it("registers and resolves a singleton (default lifecycle)", {
timeout: 5000,
}, () => {
let count = 0;
container.register("counter", () => ({ id: ++count }));
const a = container.resolve<{ id: number }>("counter");
const b = container.resolve<{ id: number }>("counter");
expect(a).toBe(b);
expect(a.id).toBe(1);
expect(count).toBe(1);
});
it("registers and resolves a transient service", { timeout: 5000 }, () => {
let count = 0;
container.register("token", () => ++count, "transient");
expect(container.resolve<number>("token")).toBe(1);
expect(container.resolve<number>("token")).toBe(2);
expect(container.resolve<number>("token")).toBe(3);
});
it("supports symbol identifiers with typed generics", {
timeout: 5000,
}, () => {
interface IClock {
now(): number;
}
const CLOCK = Symbol("Clock") as ServiceIdentifier<IClock>;
container.register<IClock>(CLOCK, () => ({ now: () => 42 }));
expect(container.resolve<IClock>(CLOCK).now()).toBe(42);
});
it("supports fluent chained registration", { timeout: 5000 }, () => {
container
.register("a", () => "A")
.register("b", () => "B")
.register("c", () => "C", "transient");
expect(container.resolve<string>("a")).toBe("A");
expect(container.resolve<string>("b")).toBe("B");
expect(container.resolve<string>("c")).toBe("C");
});
it("throws GameServerError(SERVICE_NOT_REGISTERED) for unknown identifier (string)", {
timeout: 5000,
}, () => {
try {
container.resolve("missing");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GameServerError);
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.SERVICE_NOT_REGISTERED
);
expect((err as GameServerError).message).toContain("missing");
expect((err as GameServerError).context).toEqual({
identifier: "missing",
});
}
});
it("throws with the symbol stringified for unknown symbol identifier", {
timeout: 5000,
}, () => {
const SYM = Symbol("Nope");
try {
container.resolve(SYM);
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.SERVICE_NOT_REGISTERED
);
expect((err as GameServerError).message).toContain("Symbol(Nope)");
}
});
it("has() reflects registration state", { timeout: 5000 }, () => {
expect(container.has("x")).toBe(false);
container.register("x", () => 1);
expect(container.has("x")).toBe(true);
});
it("unregister returns true for known, false for unknown, and clears cache", {
timeout: 5000,
}, () => {
container.register("x", () => ({ tag: "first" }));
const first = container.resolve<{ tag: string }>("x");
expect(container.unregister("x")).toBe(true);
expect(container.has("x")).toBe(false);
expect(container.unregister("x")).toBe(false);
container.register("x", () => ({ tag: "second" }));
const second = container.resolve<{ tag: string }>("x");
expect(first.tag).toBe("first");
expect(second.tag).toBe("second");
});
it("re-register invalidates the cached singleton", {
timeout: 5000,
}, () => {
container.register("svc", () => ({ v: 1 }));
const a = container.resolve<{ v: number }>("svc");
expect(a.v).toBe(1);
container.register("svc", () => ({ v: 2 }));
const b = container.resolve<{ v: number }>("svc");
expect(b.v).toBe(2);
expect(a).not.toBe(b);
});
it("clear() removes registrations and singleton cache", {
timeout: 5000,
}, () => {
container.register("a", () => 1).register("b", () => 2);
expect(container.getRegisteredServices()).toHaveLength(2);
container.clear();
expect(container.getRegisteredServices()).toHaveLength(0);
expect(container.has("a")).toBe(false);
});
it("getRegisteredServices() returns the registered identifiers", {
timeout: 5000,
}, () => {
const SYM = Symbol("S");
container.register("a", () => 1).register(SYM, () => 2);
const ids = container.getRegisteredServices();
expect(ids).toContain("a");
expect(ids).toContain(SYM);
expect(ids).toHaveLength(2);
});
});

View File

@@ -0,0 +1,98 @@
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import type {
ServiceDefinition,
ServiceIdentifier,
ServiceLifecycle,
} from "./di-container.types.js";
/**
* Lightweight dependency-injection container.
*
* Migrated from `packages/universal/game-server/src/engine/DIContainer.ts`
* on the reference branch `claude/review-game-engine-arch-Kaykr`. The genesis
* port replaces the plain `Error` thrown on unresolved services with a typed
* {@link GameServerError} carrying the stable code
* `GAME_SERVER.SERVICE_NOT_REGISTERED`.
*/
export class DIContainer {
private readonly services: Map<ServiceIdentifier, ServiceDefinition> =
new Map();
private readonly singletonInstances: Map<ServiceIdentifier, unknown> =
new Map();
/**
* Register a service factory under an identifier.
*
* @param identifier - String or symbol identifier.
* @param factory - Zero-arg factory producing the service instance.
* @param lifecycle - `"singleton"` (default) or `"transient"`.
* @returns the container, for chaining.
*/
public register<T>(
identifier: ServiceIdentifier<T>,
factory: () => T,
lifecycle: ServiceLifecycle = "singleton"
): this {
this.services.set(identifier, {
identifier,
factory,
singleton: lifecycle === "singleton",
});
// A re-register MUST invalidate any cached singleton instance, otherwise
// callers stuck observing the stale value with no obvious cause.
this.singletonInstances.delete(identifier);
return this;
}
/**
* Resolve a service. Throws {@link GameServerError} with code
* `GAME_SERVER.SERVICE_NOT_REGISTERED` if the identifier is unknown.
*/
public resolve<T>(identifier: ServiceIdentifier<T>): T {
const service = this.services.get(identifier);
if (service === undefined) {
throw new GameServerError(
GAME_SERVER_CODES.SERVICE_NOT_REGISTERED,
`Service ${String(identifier)} is not registered`,
{ context: { identifier: String(identifier) } }
);
}
if (service.singleton) {
const cached = this.singletonInstances.get(identifier);
if (cached !== undefined) {
return cached as T;
}
const instance = service.factory();
this.singletonInstances.set(identifier, instance);
return instance as T;
}
return service.factory() as T;
}
/** Report whether an identifier is registered. */
public has(identifier: ServiceIdentifier): boolean {
return this.services.has(identifier);
}
/**
* Remove a registration and any cached singleton instance.
* @returns `true` if the identifier was previously registered.
*/
public unregister(identifier: ServiceIdentifier): boolean {
this.singletonInstances.delete(identifier);
return this.services.delete(identifier);
}
/** Remove every registration and cached singleton. */
public clear(): void {
this.services.clear();
this.singletonInstances.clear();
}
/** List the identifiers currently registered. */
public getRegisteredServices(): ServiceIdentifier[] {
return Array.from(this.services.keys());
}
}

View File

@@ -0,0 +1,19 @@
/**
* Public types for the dependency-injection container.
*/
/**
* Identifier used to register and resolve a service. The type parameter is
* a compile-time tag — it does not affect runtime equality.
*/
export type ServiceIdentifier<_T = unknown> = string | symbol;
/** Lifecycle for a registered service. */
export type ServiceLifecycle = "singleton" | "transient";
/** Internal service-record stored in the container. */
export interface ServiceDefinition<T = unknown> {
readonly identifier: ServiceIdentifier<T>;
readonly factory: () => T;
readonly singleton: boolean;
}

View File

@@ -0,0 +1,6 @@
export { DIContainer } from "./di-container.js";
export type {
ServiceDefinition,
ServiceIdentifier,
ServiceLifecycle,
} from "./di-container.types.js";

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,503 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type {
Clock,
EventBusLike,
IdGenerator,
LoggerLike,
TimersLike,
} from "@suaveplan/types";
import { flushTestMetrics } from "../../../tests/setup/polyfills.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import type { GameLoopEventMap } from "../game-loop/index.js";
import { GameLoop } from "../game-loop/index.js";
import type { PluginManagerEventMap } from "../plugin-manager/index.js";
import { PluginManager } from "../plugin-manager/index.js";
import { GameEngine } from "./game-engine.js";
import {
ENGINE_SERVICE_IDS,
type GameEngineDeps,
type GameEngineEventMap,
} from "./game-engine.types.js";
type AnyMap = GameLoopEventMap & PluginManagerEventMap & GameEngineEventMap;
class FakeClockHost {
private t = 0;
public clock: Clock = () => this.t;
public advance(ms: number): void {
this.t += ms;
}
}
class FakeTimers implements TimersLike {
public entries: Map<number, { cb: () => void; due: number }> = new Map();
private next = 1;
public constructor(private readonly host: FakeClockHost) {}
public setTimer(cb: () => void, delayMs: number): number {
const h = this.next++;
this.entries.set(h, { cb, due: this.host.clock() + delayMs });
return h;
}
public clearTimer(h: number): void {
this.entries.delete(h);
}
}
class UniversalBus implements EventBusLike<AnyMap> {
public emitted: { type: keyof AnyMap; payload: unknown }[] = [];
private readonly subs: Map<
keyof AnyMap,
Set<(p: unknown) => void | Promise<void>>
> = new Map();
public on<K extends keyof AnyMap>(
type: K,
listener: (p: AnyMap[K]) => void | Promise<void>
): () => void {
if (!this.subs.has(type)) this.subs.set(type, new Set());
const set = this.subs.get(type) as Set<
(p: unknown) => void | Promise<void>
>;
set.add(listener as (p: unknown) => void | Promise<void>);
return () =>
set.delete(listener as (p: unknown) => void | Promise<void>);
}
public emit<K extends keyof AnyMap>(type: K, payload: AnyMap[K]): void {
this.emitted.push({ type, payload });
const set = this.subs.get(type);
if (set !== undefined) {
for (const l of set) {
void l(payload);
}
}
}
}
function recordingLogger(): LoggerLike {
return {
debug() {},
info() {},
warn() {},
error() {},
};
}
function makeDeps(opts?: {
sessionCount?: number;
playerCount?: number;
includeState?: boolean;
includeGameLogic?: boolean;
gameLogicDisposeThrows?: boolean;
}): {
deps: GameEngineDeps;
host: FakeClockHost;
timers: FakeTimers;
bus: UniversalBus;
logger: LoggerLike;
loop: GameLoop;
plugins: PluginManager;
} {
const host = new FakeClockHost();
const timers = new FakeTimers(host);
const bus = new UniversalBus();
const logger = recordingLogger();
const idGenerator: IdGenerator = (() => {
let n = 0;
return () => `id-${++n}`;
})();
const loop = new GameLoop(
{ clock: host.clock, timers, logger, events: bus },
{ mode: "event-driven" }
);
const plugins = new PluginManager({ logger, events: bus });
return {
host,
timers,
bus,
logger,
loop,
plugins,
deps: {
clock: host.clock,
timers,
idGenerator,
logger,
events: bus,
gameLoop: loop,
pluginManager: plugins,
sessionManager: {
getSessionCount: () => opts?.sessionCount ?? 0,
dispose() {},
},
playerManager: {
getPlayerCount: () => opts?.playerCount ?? 0,
dispose() {},
},
stateManager: opts?.includeState ? { dispose() {} } : undefined,
gameLogic: opts?.includeGameLogic
? {
dispose: opts?.gameLogicDisposeThrows
? () => {
throw new Error("logic dispose boom");
}
: () => {},
}
: undefined,
},
};
}
describe("GameEngine", () => {
beforeEach(async () => {
await flushTestMetrics();
});
it("constructor registers logger/events/loop/plugins in the container", {
timeout: 5000,
}, () => {
const { deps } = makeDeps({
includeState: true,
includeGameLogic: true,
});
const engine = new GameEngine(deps);
// Resolve every registered identifier to exercise the factory closures.
expect(engine.container.resolve(ENGINE_SERVICE_IDS.logger)).toBe(
deps.logger
);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.events)).toBe(
deps.events
);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.clock)).toBe(
deps.clock
);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.timers)).toBe(
deps.timers
);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.idGenerator)).toBe(
deps.idGenerator
);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.gameLoop)).toBe(
deps.gameLoop
);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.pluginManager)).toBe(
deps.pluginManager
);
expect(
engine.container.resolve(ENGINE_SERVICE_IDS.sessionManager)
).toBe(deps.sessionManager);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.playerManager)).toBe(
deps.playerManager
);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.stateManager)).toBe(
deps.stateManager
);
expect(engine.container.resolve(ENGINE_SERVICE_IDS.gameLogic)).toBe(
deps.gameLogic
);
expect(engine.container.has(ENGINE_SERVICE_IDS.logger)).toBe(true);
expect(engine.container.has(ENGINE_SERVICE_IDS.events)).toBe(true);
expect(engine.container.has(ENGINE_SERVICE_IDS.clock)).toBe(true);
expect(engine.container.has(ENGINE_SERVICE_IDS.timers)).toBe(true);
expect(engine.container.has(ENGINE_SERVICE_IDS.idGenerator)).toBe(true);
expect(engine.container.has(ENGINE_SERVICE_IDS.gameLoop)).toBe(true);
expect(engine.container.has(ENGINE_SERVICE_IDS.pluginManager)).toBe(
true
);
expect(engine.container.has(ENGINE_SERVICE_IDS.sessionManager)).toBe(
true
);
expect(engine.container.has(ENGINE_SERVICE_IDS.playerManager)).toBe(
true
);
expect(engine.container.has(ENGINE_SERVICE_IDS.stateManager)).toBe(
true
);
expect(engine.container.has(ENGINE_SERVICE_IDS.gameLogic)).toBe(true);
});
it("constructor skips optional services that are not supplied", {
timeout: 5000,
}, () => {
const { deps } = makeDeps();
// strip all optionals
const minimal: GameEngineDeps = {
clock: deps.clock,
timers: deps.timers,
idGenerator: deps.idGenerator,
logger: deps.logger,
events: deps.events,
gameLoop: deps.gameLoop,
pluginManager: deps.pluginManager,
};
const engine = new GameEngine(minimal);
expect(engine.container.has(ENGINE_SERVICE_IDS.sessionManager)).toBe(
false
);
expect(engine.container.has(ENGINE_SERVICE_IDS.playerManager)).toBe(
false
);
expect(engine.container.has(ENGINE_SERVICE_IDS.stateManager)).toBe(
false
);
expect(engine.container.has(ENGINE_SERVICE_IDS.gameLogic)).toBe(false);
});
it("start invokes loop.start, transitions running, increments OTel", {
timeout: 5000,
}, async () => {
const { deps, bus, loop } = makeDeps();
const engine = new GameEngine(deps, { autoStartLoop: true });
expect(engine.isRunning()).toBe(false);
engine.start();
expect(engine.isRunning()).toBe(true);
expect(loop.isRunning()).toBe(true);
expect(bus.emitted.some((e) => e.type === "engine:started")).toBe(true);
const metrics = await flushTestMetrics();
const counter = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find(
(m) =>
m.descriptor.name === "game_server.engine.lifecycle_total"
);
expect(counter).toBeDefined();
engine.stop();
await engine.dispose();
});
it("second start throws ENGINE_ALREADY_STARTED", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps();
const engine = new GameEngine(deps);
engine.start();
try {
engine.start();
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.ENGINE_ALREADY_STARTED
);
}
await engine.dispose();
});
it("tick before start throws ENGINE_NOT_STARTED", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps();
const engine = new GameEngine(deps);
try {
await engine.tick();
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.ENGINE_NOT_STARTED
);
}
try {
engine.stop();
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.ENGINE_NOT_STARTED
);
}
await engine.dispose();
});
it("tick after start drives the underlying loop and emits engine:tick", {
timeout: 5000,
}, async () => {
const { deps, bus } = makeDeps();
const engine = new GameEngine(deps);
engine.start();
await engine.tick();
expect(bus.emitted.some((e) => e.type === "engine:tick")).toBe(true);
engine.stop();
await engine.dispose();
});
it("dispose blocks future start with ENGINE_DISPOSED", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps({
includeState: true,
includeGameLogic: true,
});
const engine = new GameEngine(deps);
engine.start();
await engine.dispose();
// idempotent
await engine.dispose();
expect(engine.isDisposed()).toBe(true);
for (const op of ["start", "stop"] as const) {
try {
engine[op]();
expect.fail(`expected throw from ${op}`);
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.ENGINE_DISPOSED
);
}
}
try {
await engine.tick();
expect.fail("expected throw from tick");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.ENGINE_DISPOSED
);
}
try {
await engine.registerPlugin({
name: "p",
version: "1.0.0",
});
expect.fail("expected throw from registerPlugin");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.ENGINE_DISPOSED
);
}
expect(() => engine.getPlugin("p")).toThrow(GameServerError);
});
it("registerPlugin delegates to the underlying PluginManager", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps();
const engine = new GameEngine(deps);
const plugin = { name: "m", version: "1.0.0" };
await engine.registerPlugin(plugin);
expect(engine.getPlugin("m")).toBe(plugin);
await engine.dispose();
});
it("invalid options throw INVALID_ENGINE_OPTIONS", {
timeout: 5000,
}, () => {
const { deps } = makeDeps();
try {
new GameEngine(deps, { playerTimeoutMs: -1 });
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_ENGINE_OPTIONS
);
}
try {
new GameEngine(deps, { heartbeatIntervalMs: -1 });
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_ENGINE_OPTIONS
);
}
});
it("getStats reports running/disposed/loop/plugins", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps({ sessionCount: 3, playerCount: 5 });
const engine = new GameEngine(deps);
engine.start();
await engine.registerPlugin({ name: "p", version: "1.0.0" });
const stats = engine.getStats();
expect(stats.running).toBe(true);
expect(stats.disposed).toBe(false);
expect(stats.loop.running).toBe(true);
expect(stats.plugins.total).toBe(1);
expect(stats.sessions).toBe(3);
expect(stats.players).toBe(5);
engine.stop();
await engine.dispose();
});
it("getStats reports 0 sessions/players when slots are absent", {
timeout: 5000,
}, () => {
const { deps } = makeDeps();
const minimal: GameEngineDeps = {
clock: deps.clock,
timers: deps.timers,
idGenerator: deps.idGenerator,
logger: deps.logger,
events: deps.events,
gameLoop: deps.gameLoop,
pluginManager: deps.pluginManager,
};
const engine = new GameEngine(minimal);
const stats = engine.getStats();
expect(stats.sessions).toBe(0);
expect(stats.players).toBe(0);
});
it("autoStartLoop=false leaves the loop idle on engine.start", {
timeout: 5000,
}, async () => {
const { deps, loop } = makeDeps();
const engine = new GameEngine(deps, { autoStartLoop: false });
engine.start();
expect(loop.isRunning()).toBe(false);
expect(engine.isRunning()).toBe(true);
engine.stop();
await engine.dispose();
});
it("stop tolerates an already-stopped loop", {
timeout: 5000,
}, async () => {
const { deps, loop } = makeDeps();
const engine = new GameEngine(deps, { autoStartLoop: true });
engine.start();
// External actor stops the loop before engine.stop().
loop.stop();
engine.stop();
expect(engine.isRunning()).toBe(false);
await engine.dispose();
});
it("dispose wraps a slot-cleanup throw into ENGINE_DISPOSED", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps({
includeGameLogic: true,
gameLogicDisposeThrows: true,
});
const engine = new GameEngine(deps);
engine.start();
try {
await engine.dispose();
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(Error);
expect(engine.isDisposed()).toBe(true);
}
});
it("GameServerError thrown by a slot dispose surfaces unchanged", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps();
const inner = new GameServerError(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED,
"inner"
);
const tweaked: GameEngineDeps = {
...deps,
gameLogic: {
dispose() {
throw inner;
},
},
};
const engine = new GameEngine(tweaked);
engine.start();
try {
await engine.dispose();
expect.fail("expected throw");
} catch (err) {
expect(err).toBe(inner);
}
});
});

View File

@@ -0,0 +1,242 @@
import z from "zod";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { recordEngineLifecycle } from "../../observability/index.js";
import { DIContainer } from "../di-container/index.js";
import type { EnginePlugin } from "../plugin-manager/index.js";
import {
ENGINE_SERVICE_IDS,
type GameEngineDeps,
type GameEngineOptions,
type GameEngineOptionsInput,
type GameEngineStats,
} from "./game-engine.types.js";
const GameEngineOptionsSchema = z
.object({
heartbeatIntervalMs: z
.number()
.positive()
.finite()
.nullable()
.optional(),
playerTimeoutMs: z.number().positive().finite().nullable().optional(),
autoStartLoop: z.boolean().optional(),
loop: z.unknown().optional(),
})
.strict();
function resolveOptions(input: GameEngineOptionsInput): GameEngineOptions {
const parsed = GameEngineOptionsSchema.safeParse(input);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_ENGINE_OPTIONS,
"GameEngineOptions failed schema validation",
{ cause: parsed.error, context: { input } }
);
}
return {
heartbeatIntervalMs: parsed.data.heartbeatIntervalMs ?? null,
playerTimeoutMs: parsed.data.playerTimeoutMs ?? null,
autoStartLoop: parsed.data.autoStartLoop ?? true,
loop: (parsed.data.loop as GameEngineOptions["loop"]) ?? {},
};
}
/**
* Top-level game-server orchestrator. Wires a `DIContainer`, a `GameLoop`,
* a `PluginManager`, and opaque session/player/state/game-logic slots so
* downstream waves can plug in concretes without re-shaping the engine
* surface.
*
* Lifecycle:
* 1. `new GameEngine(deps, options)` — Zod-validates options, registers
* every injected service in the DI container under
* {@link ENGINE_SERVICE_IDS}.
* 2. `start()` — starts the loop if `autoStartLoop` is true, flips
* `running`, emits `engine:started` and increments the OTel
* `game_server.engine.lifecycle_total{op:"start"}` counter.
* 3. `tick()` — drives one loop tick.
* 4. `stop()` — stops the loop, flips `running` back to `false`.
* 5. `dispose()` — irreversibly tears down loop, plugins, optional
* session/player/state/game-logic slots, and clears the container.
*/
export class GameEngine {
public readonly container: DIContainer;
public readonly options: GameEngineOptions;
private readonly deps: GameEngineDeps;
private running = false;
private disposed = false;
public constructor(
deps: GameEngineDeps,
options: GameEngineOptionsInput = {}
) {
this.options = Object.freeze(resolveOptions(options));
this.deps = deps;
this.container = new DIContainer();
this.registerServices();
}
/** Whether `start()` has been called and `dispose()` has not. */
public isRunning(): boolean {
return this.running;
}
/** Whether `dispose()` has been called. */
public isDisposed(): boolean {
return this.disposed;
}
/** Start the engine. */
public start(): void {
this.assertNotDisposed();
if (this.running) {
recordEngineLifecycle("start", "error");
throw new GameServerError(
GAME_SERVER_CODES.ENGINE_ALREADY_STARTED,
"GameEngine.start() called while already running"
);
}
if (this.options.autoStartLoop) {
this.deps.gameLoop.start();
}
this.running = true;
this.deps.logger.info("game-engine started");
this.deps.events.emit("engine:started", {});
recordEngineLifecycle("start", "ok");
}
/** Stop the engine. */
public stop(): void {
this.assertNotDisposed();
if (!this.running) {
throw new GameServerError(
GAME_SERVER_CODES.ENGINE_NOT_STARTED,
"GameEngine.stop() called before start()"
);
}
if (this.deps.gameLoop.isRunning()) {
this.deps.gameLoop.stop();
}
this.running = false;
this.deps.logger.info("game-engine stopped");
this.deps.events.emit("engine:stopped", {});
recordEngineLifecycle("stop", "ok");
}
/** Drive a single engine tick. */
public async tick(): Promise<void> {
this.assertNotDisposed();
if (!this.running) {
throw new GameServerError(
GAME_SERVER_CODES.ENGINE_NOT_STARTED,
"GameEngine.tick() called before start()"
);
}
await this.deps.gameLoop.tick();
this.deps.events.emit("engine:tick", {
tickCount: this.deps.gameLoop.getStats().tickCount,
});
}
/** Register a plugin. Delegates to the underlying PluginManager. */
public async registerPlugin(plugin: EnginePlugin): Promise<void> {
this.assertNotDisposed();
await this.deps.pluginManager.install(plugin);
}
/** Lookup a registered plugin. */
public getPlugin(name: string): EnginePlugin {
this.assertNotDisposed();
return this.deps.pluginManager.get(name);
}
/** Snapshot of engine + child stats. */
public getStats(): GameEngineStats {
return {
running: this.running,
disposed: this.disposed,
loop: this.deps.gameLoop.getStats(),
plugins: this.deps.pluginManager.getStats(),
sessions: this.deps.sessionManager?.getSessionCount() ?? 0,
players: this.deps.playerManager?.getPlayerCount() ?? 0,
};
}
/**
* Irreversibly tear the engine down. Idempotent — subsequent calls are
* no-ops; subsequent `start()`/`tick()`/`stop()` throw `ENGINE_DISPOSED`.
*/
public async dispose(): Promise<void> {
if (this.disposed) {
return;
}
try {
if (this.running && this.deps.gameLoop.isRunning()) {
this.deps.gameLoop.stop();
}
this.running = false;
this.deps.gameLoop.dispose();
await this.deps.pluginManager.disposeAll();
await this.deps.gameLogic?.dispose?.();
await this.deps.stateManager?.dispose?.();
await this.deps.playerManager?.dispose?.();
await this.deps.sessionManager?.dispose?.();
this.container.clear();
this.disposed = true;
this.deps.events.emit("engine:disposed", {});
this.deps.logger.info("game-engine disposed");
recordEngineLifecycle("dispose", "ok");
} catch (cause) {
this.disposed = true;
recordEngineLifecycle("dispose", "error");
throw cause instanceof GameServerError
? cause
: new GameServerError(
GAME_SERVER_CODES.ENGINE_DISPOSED,
"GameEngine dispose failed",
{ cause }
);
}
}
private assertNotDisposed(): void {
if (this.disposed) {
throw new GameServerError(
GAME_SERVER_CODES.ENGINE_DISPOSED,
"GameEngine has been disposed"
);
}
}
private registerServices(): void {
const c = this.container;
c.register(ENGINE_SERVICE_IDS.logger, () => this.deps.logger);
c.register(ENGINE_SERVICE_IDS.events, () => this.deps.events);
c.register(ENGINE_SERVICE_IDS.clock, () => this.deps.clock);
c.register(ENGINE_SERVICE_IDS.timers, () => this.deps.timers);
c.register(ENGINE_SERVICE_IDS.idGenerator, () => this.deps.idGenerator);
c.register(ENGINE_SERVICE_IDS.gameLoop, () => this.deps.gameLoop);
c.register(
ENGINE_SERVICE_IDS.pluginManager,
() => this.deps.pluginManager
);
if (this.deps.sessionManager !== undefined) {
const ref = this.deps.sessionManager;
c.register(ENGINE_SERVICE_IDS.sessionManager, () => ref);
}
if (this.deps.playerManager !== undefined) {
const ref = this.deps.playerManager;
c.register(ENGINE_SERVICE_IDS.playerManager, () => ref);
}
if (this.deps.stateManager !== undefined) {
const ref = this.deps.stateManager;
c.register(ENGINE_SERVICE_IDS.stateManager, () => ref);
}
if (this.deps.gameLogic !== undefined) {
const ref = this.deps.gameLogic;
c.register(ENGINE_SERVICE_IDS.gameLogic, () => ref);
}
}
}

View File

@@ -0,0 +1,106 @@
/**
* Public types for {@link GameEngine}.
*/
import type {
Clock,
EventBusLike,
IdGenerator,
LoggerLike,
TimersLike,
} from "@suaveplan/types";
import type { GameLoop, GameLoopOptionsInput } from "../game-loop/index.js";
import type { PluginManager } from "../plugin-manager/index.js";
/**
* Minimal structural contract for an injectable session manager. Wave B-1
* carries this as an opaque slot — Wave B-2 ships the concrete
* `SessionManager` that satisfies it.
*/
export interface ISessionManagerLike {
getSessionCount(): number;
dispose?(): void | Promise<void>;
}
/** Minimal structural contract for an injectable player manager. */
export interface IPlayerManagerLike {
getPlayerCount(): number;
dispose?(): void | Promise<void>;
}
/** Minimal structural contract for an injectable state manager. */
export interface IStateManagerLike {
dispose?(): void | Promise<void>;
}
/** Minimal structural contract for an injectable game-logic strategy. */
export interface IGameLogicLike {
initialize?(): void | Promise<void>;
dispose?(): void | Promise<void>;
}
/** Validated, frozen engine options. */
export interface GameEngineOptions {
/** Heartbeat interval in milliseconds, or `null` to disable. */
readonly heartbeatIntervalMs: number | null;
/** Player connection timeout in milliseconds, or `null` to disable. */
readonly playerTimeoutMs: number | null;
/** Whether to start the underlying game loop in the engine's `start()`. */
readonly autoStartLoop: boolean;
readonly loop: GameLoopOptionsInput;
}
/** Caller-supplied subset of {@link GameEngineOptions}. */
export interface GameEngineOptionsInput {
readonly heartbeatIntervalMs?: number | null;
readonly playerTimeoutMs?: number | null;
readonly autoStartLoop?: boolean;
readonly loop?: GameLoopOptionsInput;
}
/** Snapshot of engine state. */
export interface GameEngineStats {
readonly running: boolean;
readonly disposed: boolean;
readonly loop: ReturnType<GameLoop["getStats"]>;
readonly plugins: ReturnType<PluginManager["getStats"]>;
readonly sessions: number;
readonly players: number;
}
/** Topics the engine emits onto the injected EventBus. */
export type GameEngineEventMap = {
"engine:started": Record<string, never>;
"engine:stopped": Record<string, never>;
"engine:disposed": Record<string, never>;
"engine:tick": { tickCount: number };
};
/** Dependencies required to construct a GameEngine. */
export interface GameEngineDeps {
readonly clock: Clock;
readonly timers: TimersLike;
readonly idGenerator: IdGenerator;
readonly logger: LoggerLike;
readonly events: EventBusLike<GameEngineEventMap>;
readonly gameLoop: GameLoop;
readonly pluginManager: PluginManager;
readonly sessionManager?: ISessionManagerLike;
readonly playerManager?: IPlayerManagerLike;
readonly stateManager?: IStateManagerLike;
readonly gameLogic?: IGameLogicLike;
}
/** Canonical DI identifiers registered by the engine on construction. */
export const ENGINE_SERVICE_IDS = {
logger: "GameEngine.logger",
events: "GameEngine.events",
clock: "GameEngine.clock",
timers: "GameEngine.timers",
idGenerator: "GameEngine.idGenerator",
gameLoop: "GameEngine.gameLoop",
pluginManager: "GameEngine.pluginManager",
sessionManager: "GameEngine.sessionManager",
playerManager: "GameEngine.playerManager",
stateManager: "GameEngine.stateManager",
gameLogic: "GameEngine.gameLogic",
} as const;

View File

@@ -0,0 +1,13 @@
export { GameEngine } from "./game-engine.js";
export {
ENGINE_SERVICE_IDS,
type GameEngineDeps,
type GameEngineEventMap,
type GameEngineOptions,
type GameEngineOptionsInput,
type GameEngineStats,
type IGameLogicLike,
type IPlayerManagerLike,
type ISessionManagerLike,
type IStateManagerLike,
} from "./game-engine.types.js";

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,530 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type {
Clock,
EventBusLike,
LoggerLike,
TimersLike,
} from "@suaveplan/types";
import { flushTestMetrics } from "../../../tests/setup/polyfills.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { GameLoop } from "./game-loop.js";
import type { GameLoopEventMap } from "./game-loop.types.js";
type TimerEntry = { cb: () => void; due: number };
class FakeClock {
private t = 0;
public read: Clock = () => this.t;
public advance(ms: number): void {
this.t += ms;
}
}
class FakeTimers implements TimersLike {
public entries: Map<number, TimerEntry> = new Map();
private nextHandle = 1;
public constructor(private readonly clock: FakeClock) {}
public setTimer(cb: () => void, delayMs: number): number {
const handle = this.nextHandle++;
this.entries.set(handle, { cb, due: this.clock.read() + delayMs });
return handle;
}
public clearTimer(handle: number): void {
this.entries.delete(handle);
}
public fireDue(): number {
let fired = 0;
const now = this.clock.read();
for (const [h, entry] of [...this.entries.entries()]) {
if (entry.due <= now) {
this.entries.delete(h);
entry.cb();
fired++;
}
}
return fired;
}
}
function recordingLogger(): {
log: LoggerLike;
events: { level: string; msg: string }[];
} {
const events: { level: string; msg: string }[] = [];
const make = (level: string) => (msg: string) => {
events.push({ level, msg });
};
return {
events,
log: {
debug: make("debug"),
info: make("info"),
warn: make("warn"),
error: make("error"),
},
};
}
class FakeBus implements EventBusLike<GameLoopEventMap> {
public emitted: { type: keyof GameLoopEventMap; payload: unknown }[] = [];
private readonly subs: Map<
keyof GameLoopEventMap,
Set<(payload: unknown) => void | Promise<void>>
> = new Map();
public on<K extends keyof GameLoopEventMap>(
type: K,
listener: (payload: GameLoopEventMap[K]) => void | Promise<void>
): () => void {
if (!this.subs.has(type)) this.subs.set(type, new Set());
const set = this.subs.get(type) as Set<
(payload: unknown) => void | Promise<void>
>;
set.add(listener as (payload: unknown) => void | Promise<void>);
return () =>
set.delete(listener as (p: unknown) => void | Promise<void>);
}
public emit<K extends keyof GameLoopEventMap>(
type: K,
payload: GameLoopEventMap[K]
): void {
this.emitted.push({ type, payload });
const set = this.subs.get(type);
if (set !== undefined) {
for (const l of set) {
void l(payload);
}
}
}
}
function makeDeps(): {
deps: ConstructorParameters<typeof GameLoop>[0];
clock: FakeClock;
timers: FakeTimers;
bus: FakeBus;
log: ReturnType<typeof recordingLogger>;
} {
const clock = new FakeClock();
const timers = new FakeTimers(clock);
const bus = new FakeBus();
const log = recordingLogger();
return {
clock,
timers,
bus,
log,
deps: {
clock: clock.read,
timers,
logger: log.log,
events: bus,
},
};
}
describe("GameLoop", () => {
beforeEach(async () => {
await flushTestMetrics();
});
it("schedules tick-based ticks via injected TimersLike at the configured rate", {
timeout: 5000,
}, async () => {
const { deps, clock, timers } = makeDeps();
const loop = new GameLoop(deps, { mode: "tick-based", tickRate: 50 });
let fired = 0;
loop.onFixedUpdate(() => {
fired++;
});
loop.start();
expect(timers.entries.size).toBe(1);
// first entry due at clock+20ms (1000/50)
const first = [...timers.entries.values()][0];
expect(first.due).toBeCloseTo(20, 5);
clock.advance(25);
timers.fireDue();
// Tick() is async; await its completion via the next tick's queue.
// We loop a few microtask flushes to let the chained `.finally(scheduleNext)` run.
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
expect(fired).toBe(1);
// After tick completes, the loop schedules its next tick.
expect(timers.entries.size).toBe(1);
loop.stop();
});
it("pause halts ticking and resume refreshes lastTickTime", {
timeout: 5000,
}, async () => {
const { deps, clock, bus } = makeDeps();
const loop = new GameLoop(deps, {
mode: "event-driven",
maxDeltaTime: 50,
});
loop.start();
clock.advance(100);
loop.pause();
// pause a second time is a no-op
loop.pause();
clock.advance(10_000); // simulate long pause
loop.resume();
// resume a second time is a no-op
loop.resume();
const deltas: number[] = [];
loop.onUpdate((d) => {
deltas.push(d);
});
clock.advance(16);
await loop.tick();
expect(deltas[0]).toBeLessThanOrEqual(50); // clamped by maxDeltaTime
expect(deltas[0]).toBe(16);
expect(bus.emitted.some((e) => e.type === "loop:paused")).toBe(true);
expect(bus.emitted.some((e) => e.type === "loop:resumed")).toBe(true);
loop.stop();
});
it("manual tick invokes fixed-update then update callbacks in order", {
timeout: 5000,
}, async () => {
const { deps, clock } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
const order: string[] = [];
loop.onFixedUpdate(() => {
order.push("fixed1");
});
loop.onFixedUpdate(() => {
order.push("fixed2");
});
loop.onUpdate(() => {
order.push("update");
});
loop.start();
clock.advance(10);
await loop.tick();
expect(order).toEqual(["fixed1", "fixed2", "update"]);
expect(loop.getStats().tickCount).toBe(1);
loop.stop();
});
it("rejects construction with invalid options as INVALID_GAME_LOOP_OPTIONS", {
timeout: 5000,
}, () => {
const { deps } = makeDeps();
try {
new GameLoop(deps, { tickRate: 0 });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GameServerError);
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_GAME_LOOP_OPTIONS
);
}
try {
new GameLoop(deps, { maxDeltaTime: -1 });
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_GAME_LOOP_OPTIONS
);
}
});
it("throws GAME_LOOP_ALREADY_RUNNING on second start", {
timeout: 5000,
}, () => {
const { deps } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
loop.start();
try {
loop.start();
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.GAME_LOOP_ALREADY_RUNNING
);
}
loop.stop();
});
it("throws GAME_LOOP_NOT_RUNNING when paused/resumed/stopped before start", {
timeout: 5000,
}, () => {
const { deps } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
for (const op of ["pause", "resume", "stop"] as const) {
try {
loop[op]();
expect.fail(`expected throw from ${op}`);
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.GAME_LOOP_NOT_RUNNING
);
}
}
});
it("callback errors emit loop:error and do not stop the loop", {
timeout: 5000,
}, async () => {
const { deps, bus, log } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
loop.onFixedUpdate(() => {
throw new Error("boom fixed");
});
loop.onUpdate(() => {
// non-Error throw covers the String(cause) branch
const cause: unknown = "boom update";
throw cause;
});
loop.start();
await loop.tick();
const errors = bus.emitted.filter((e) => e.type === "loop:error");
expect(errors).toHaveLength(2);
expect(loop.isRunning()).toBe(true);
expect(log.events.some((e) => e.level === "error")).toBe(true);
loop.stop();
});
it("records a tick_duration_ms histogram sample per tick", {
timeout: 5000,
}, async () => {
const { deps, clock } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
loop.start();
clock.advance(5);
await loop.tick();
await loop.tick();
const metrics = await flushTestMetrics();
const hist = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find(
(m) => m.descriptor.name === "game_server.loop.tick_duration_ms"
);
expect(hist).toBeDefined();
// At least one data point with our `mode` attribute and a positive count.
const dp = hist?.dataPoints.find(
(p) => (p.attributes as { mode?: string }).mode === "event-driven"
);
expect(dp).toBeDefined();
expect((dp?.value as { count: number }).count).toBeGreaterThanOrEqual(
2
);
loop.stop();
});
it("triggerUpdate bumps frameCount and clamps delta to maxDeltaTime", {
timeout: 5000,
}, async () => {
const { deps, clock } = makeDeps();
const loop = new GameLoop(deps, {
mode: "event-driven",
maxDeltaTime: 30,
});
let observed = -1;
loop.onUpdate((d) => {
observed = d;
});
loop.start();
clock.advance(500);
await loop.triggerUpdate();
expect(observed).toBe(30); // clamped
expect(loop.getStats().frameCount).toBe(1);
loop.stop();
});
it("tick on a non-running or disposed loop is a no-op", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
// not started — no-op
await loop.tick();
await loop.triggerUpdate();
expect(loop.getStats().tickCount).toBe(0);
loop.start();
loop.dispose();
// disposed — no-op
await loop.tick();
await loop.triggerUpdate();
// dispose is idempotent
loop.dispose();
});
it("tick while paused is a no-op", { timeout: 5000 }, async () => {
const { deps } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
loop.start();
loop.pause();
await loop.tick();
await loop.triggerUpdate();
expect(loop.getStats().tickCount).toBe(0);
expect(loop.getStats().frameCount).toBe(0);
loop.resume();
loop.stop();
});
it("onUpdate/onFixedUpdate return unsubscribe functions", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
let updates = 0;
let fixed = 0;
const offU = loop.onUpdate(() => {
updates++;
});
const offF = loop.onFixedUpdate(() => {
fixed++;
});
loop.start();
await loop.tick();
offU();
offF();
await loop.tick();
expect(updates).toBe(1);
expect(fixed).toBe(1);
loop.stop();
});
it("clearCallbacks drops every registered callback", {
timeout: 5000,
}, async () => {
const { deps } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
loop.onUpdate(() => {});
loop.onFixedUpdate(() => {});
expect(loop.getStats().updateCallbackCount).toBe(1);
expect(loop.getStats().fixedUpdateCallbackCount).toBe(1);
loop.clearCallbacks();
expect(loop.getStats().updateCallbackCount).toBe(0);
expect(loop.getStats().fixedUpdateCallbackCount).toBe(0);
});
it("getMode and averageTickMs match the configured values", {
timeout: 5000,
}, async () => {
const { deps, clock } = makeDeps();
const loop = new GameLoop(deps, { mode: "hybrid", tickRate: 30 });
expect(loop.getMode()).toBe("hybrid");
expect(loop.getStats().averageTickMs).toBe(0);
loop.start();
clock.advance(10);
await loop.tick();
expect(loop.getStats().averageTickMs).toBeGreaterThan(0);
loop.stop();
});
it("hybrid mode also schedules a tick-based timer", {
timeout: 5000,
}, async () => {
const { deps, timers } = makeDeps();
const loop = new GameLoop(deps, { mode: "hybrid", tickRate: 10 });
loop.start();
expect(timers.entries.size).toBe(1);
loop.stop();
expect(timers.entries.size).toBe(0);
});
it("scheduled tick after stop is a no-op (re-entrancy guard)", {
timeout: 5000,
}, async () => {
const { deps, timers } = makeDeps();
const loop = new GameLoop(deps, { mode: "tick-based", tickRate: 100 });
let fired = 0;
loop.onFixedUpdate(() => {
fired++;
});
loop.start();
const entryHandle = [...timers.entries.keys()][0];
// Manually stop and then fire the now-stale timer.
loop.stop();
// The entry should have been cleared via clearTimer
expect(timers.entries.has(entryHandle)).toBe(false);
expect(fired).toBe(0);
});
it("uses default options when none supplied", { timeout: 5000 }, () => {
const { deps } = makeDeps();
// No second arg — exercises the `options = {}` default and every
// `?? <default>` branch in resolveOptions.
const loop = new GameLoop(deps);
expect(loop.options.mode).toBe("event-driven");
expect(loop.options.tickRate).toBe(60);
expect(loop.options.maxDeltaTime).toBe(100);
expect(loop.options.catchUpLimit).toBe(5);
});
it("isPaused reflects pause state", { timeout: 5000 }, () => {
const { deps } = makeDeps();
const loop = new GameLoop(deps, { mode: "event-driven" });
loop.start();
expect(loop.isPaused()).toBe(false);
loop.pause();
expect(loop.isPaused()).toBe(true);
loop.resume();
expect(loop.isPaused()).toBe(false);
loop.stop();
});
it("stale timer callback that fires after stop is a no-op", {
timeout: 5000,
}, async () => {
const { deps, timers, clock } = makeDeps();
const loop = new GameLoop(deps, { mode: "tick-based", tickRate: 100 });
let fired = 0;
loop.onFixedUpdate(() => {
fired++;
});
loop.start();
// Capture the timer entry's callback BEFORE stop clears it.
const entry = [...timers.entries.values()][0];
// Forcefully call the captured callback after stop —
// simulates a "fire while we were stopping" race.
loop.stop();
clock.advance(20);
entry.cb();
// After cb runs, the inner running/disposed check short-circuits.
expect(fired).toBe(0);
expect(loop.isRunning()).toBe(false);
});
it("scheduleNext re-entry after stop-during-tick short-circuits", {
timeout: 5000,
}, async () => {
const { deps, timers } = makeDeps();
const loop = new GameLoop(deps, { mode: "tick-based", tickRate: 100 });
loop.onFixedUpdate(() => {
// Stop the loop from inside the very first tick callback.
loop.stop();
});
loop.start();
const [handle, entry] = [...timers.entries.entries()][0];
// Manually firing the captured cb does not auto-remove it from
// the fake timer map — clear it ourselves to mimic real-timer
// single-shot semantics.
timers.entries.delete(handle);
entry.cb();
await new Promise((r) => setTimeout(r, 0));
// scheduleNext saw !running and bailed out — no replacement timer scheduled.
expect(timers.entries.size).toBe(0);
expect(loop.isRunning()).toBe(false);
});
it("scheduleNext is a no-op when called from a disposed loop", {
timeout: 5000,
}, async () => {
const { deps, timers } = makeDeps();
const loop = new GameLoop(deps, { mode: "tick-based", tickRate: 100 });
loop.start();
const entry = [...timers.entries.values()][0];
// Trigger the inner scheduleNext branch where running becomes false
// mid-tick by disposing inside the callback chain.
loop.dispose();
// The next scheduled tick callback now sees disposed=true.
entry.cb();
// scheduleNext early-returns; no new timers scheduled.
expect(timers.entries.size).toBe(0);
});
});

View File

@@ -0,0 +1,328 @@
import z from "zod";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { recordLoopTickDuration } from "../../observability/index.js";
import type {
FixedUpdateCallback,
GameLoopDeps,
GameLoopMode,
GameLoopOptions,
GameLoopOptionsInput,
GameLoopStats,
UpdateCallback,
} from "./game-loop.types.js";
const GameLoopOptionsSchema = z
.object({
mode: z.enum(["tick-based", "event-driven", "hybrid"]).optional(),
tickRate: z.number().positive().finite().optional(),
maxDeltaTime: z.number().positive().finite().optional(),
catchUpLimit: z.number().int().positive().optional(),
})
.strict();
function resolveOptions(input: GameLoopOptionsInput): GameLoopOptions {
const parsed = GameLoopOptionsSchema.safeParse(input);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_GAME_LOOP_OPTIONS,
"GameLoopOptions failed schema validation",
{ cause: parsed.error, context: { input } }
);
}
return {
mode: parsed.data.mode ?? "event-driven",
tickRate: parsed.data.tickRate ?? 60,
maxDeltaTime: parsed.data.maxDeltaTime ?? 100,
catchUpLimit: parsed.data.catchUpLimit ?? 5,
};
}
/**
* Deterministic game loop with three modes:
*
* - `tick-based` — drives `tick()` on a recurring schedule via `TimersLike`.
* - `event-driven` — caller drives `triggerUpdate()`/`tick()` directly.
* - `hybrid` — both a recurring schedule AND caller-driven updates.
*
* All wall-clock reads route through the injected `Clock`. All scheduling
* routes through `TimersLike`. The loop never touches `Date.now` or
* `setInterval`/`setTimeout` directly, which is what makes the package
* testable under `FakeClock`.
*/
export class GameLoop {
public readonly options: GameLoopOptions;
private readonly deps: GameLoopDeps;
private readonly updateCallbacks: Set<UpdateCallback> = new Set();
private readonly fixedUpdateCallbacks: Set<FixedUpdateCallback> = new Set();
private running = false;
private paused = false;
private disposed = false;
private timerHandle: number | undefined;
private lastTickTime = 0;
private totalTime = 0;
private tickCount = 0;
private frameCount = 0;
public constructor(deps: GameLoopDeps, options: GameLoopOptionsInput = {}) {
this.deps = deps;
this.options = Object.freeze(resolveOptions(options));
}
/** Whether the loop is currently scheduled. */
public isRunning(): boolean {
return this.running;
}
/** Whether the loop is paused (still scheduled, ticks skipped). */
public isPaused(): boolean {
return this.paused;
}
/** Register an update callback. Returns an unsubscribe. */
public onUpdate(callback: UpdateCallback): () => void {
this.updateCallbacks.add(callback);
return () => {
this.updateCallbacks.delete(callback);
};
}
/** Register a fixed-update callback. Returns an unsubscribe. */
public onFixedUpdate(callback: FixedUpdateCallback): () => void {
this.fixedUpdateCallbacks.add(callback);
return () => {
this.fixedUpdateCallbacks.delete(callback);
};
}
/** Start the loop. Throws `GAME_LOOP_ALREADY_RUNNING` if already running. */
public start(): void {
if (this.running) {
throw new GameServerError(
GAME_SERVER_CODES.GAME_LOOP_ALREADY_RUNNING,
"GameLoop is already running",
{ context: { mode: this.options.mode } }
);
}
this.running = true;
this.paused = false;
this.lastTickTime = this.deps.clock();
this.totalTime = 0;
this.tickCount = 0;
this.frameCount = 0;
this.deps.logger.info("game-loop started", {
mode: this.options.mode,
tickRate: this.options.tickRate,
});
this.deps.events.emit("loop:started", { mode: this.options.mode });
if (
this.options.mode === "tick-based" ||
this.options.mode === "hybrid"
) {
this.scheduleNext();
}
}
/** Stop the loop. Throws `GAME_LOOP_NOT_RUNNING` if not running. */
public stop(): void {
if (!this.running) {
throw new GameServerError(
GAME_SERVER_CODES.GAME_LOOP_NOT_RUNNING,
"GameLoop.stop() called while not running"
);
}
this.cancelTimer();
this.running = false;
this.paused = false;
this.deps.logger.info("game-loop stopped", {
totalTime: this.totalTime,
tickCount: this.tickCount,
});
this.deps.events.emit("loop:stopped", {
totalTime: this.totalTime,
tickCount: this.tickCount,
});
}
/** Pause the loop (timers stay scheduled but ticks are skipped). */
public pause(): void {
if (!this.running) {
throw new GameServerError(
GAME_SERVER_CODES.GAME_LOOP_NOT_RUNNING,
"GameLoop.pause() called while not running"
);
}
if (this.paused) {
return;
}
this.paused = true;
this.deps.events.emit("loop:paused", {});
}
/** Resume a paused loop. */
public resume(): void {
if (!this.running) {
throw new GameServerError(
GAME_SERVER_CODES.GAME_LOOP_NOT_RUNNING,
"GameLoop.resume() called while not running"
);
}
if (!this.paused) {
return;
}
this.paused = false;
this.lastTickTime = this.deps.clock();
this.deps.events.emit("loop:resumed", {});
}
/**
* Manually drive a single tick. Used by `event-driven` mode and the test
* suite. In `tick-based`/`hybrid` mode it is also legal — the next
* scheduled tick will simply see a smaller delta.
*/
public async tick(): Promise<void> {
if (this.disposed || !this.running || this.paused) {
return;
}
const startStamp = this.deps.clock();
const deltaTime = Math.min(
startStamp - this.lastTickTime,
this.options.maxDeltaTime
);
this.lastTickTime = startStamp;
this.totalTime += deltaTime;
this.tickCount++;
await this.runFixedUpdates();
await this.runUpdates(deltaTime);
recordLoopTickDuration(
this.deps.clock() - startStamp,
this.options.mode
);
}
/**
* Caller-driven update for `event-driven` mode. Skips fixed-update
* callbacks (those only run from `tick()`) and bumps `frameCount`.
*/
public async triggerUpdate(): Promise<void> {
if (this.disposed || !this.running || this.paused) {
return;
}
const startStamp = this.deps.clock();
const deltaTime = Math.min(
startStamp - this.lastTickTime,
this.options.maxDeltaTime
);
this.lastTickTime = startStamp;
this.totalTime += deltaTime;
this.frameCount++;
await this.runUpdates(deltaTime);
recordLoopTickDuration(
this.deps.clock() - startStamp,
this.options.mode
);
}
/** Snapshot of loop statistics. */
public getStats(): GameLoopStats {
const avg = this.tickCount > 0 ? this.totalTime / this.tickCount : 0;
return {
mode: this.options.mode,
running: this.running,
paused: this.paused,
tickRate: this.options.tickRate,
totalTime: this.totalTime,
tickCount: this.tickCount,
frameCount: this.frameCount,
updateCallbackCount: this.updateCallbacks.size,
fixedUpdateCallbackCount: this.fixedUpdateCallbacks.size,
averageTickMs: avg,
};
}
/** Drop every registered callback. Loop state is otherwise unchanged. */
public clearCallbacks(): void {
this.updateCallbacks.clear();
this.fixedUpdateCallbacks.clear();
}
/** Stop the loop (if running) and detach all callbacks. Idempotent. */
public dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
if (this.running) {
this.cancelTimer();
this.running = false;
this.paused = false;
this.deps.events.emit("loop:stopped", {
totalTime: this.totalTime,
tickCount: this.tickCount,
});
}
this.clearCallbacks();
}
/** Current operating mode. */
public getMode(): GameLoopMode {
return this.options.mode;
}
private scheduleNext(): void {
if (!this.running || this.disposed) {
return;
}
const intervalMs = 1000 / this.options.tickRate;
this.timerHandle = this.deps.timers.setTimer(() => {
this.timerHandle = undefined;
if (!this.running || this.disposed) {
return;
}
// Fire-and-forget — error bus already covers callback failures.
void this.tick().finally(() => {
this.scheduleNext();
});
}, intervalMs);
}
private cancelTimer(): void {
if (this.timerHandle !== undefined) {
this.deps.timers.clearTimer(this.timerHandle);
this.timerHandle = undefined;
}
}
private async runFixedUpdates(): Promise<void> {
for (const callback of this.fixedUpdateCallbacks) {
try {
await callback(this.tickCount);
} catch (cause) {
this.handleCallbackError(cause, "fixed-update");
}
}
}
private async runUpdates(deltaTime: number): Promise<void> {
for (const callback of this.updateCallbacks) {
try {
await callback(deltaTime, this.totalTime);
} catch (cause) {
this.handleCallbackError(cause, "update");
}
}
}
private handleCallbackError(
cause: unknown,
phase: "update" | "fixed-update"
): void {
const error = cause instanceof Error ? cause : new Error(String(cause));
this.deps.logger.error("game-loop callback failed", {
phase,
error: error.message,
});
this.deps.events.emit("loop:error", { error, phase });
}
}

View File

@@ -0,0 +1,71 @@
/**
* Public types for {@link GameLoop}.
*/
import type {
Clock,
EventBusLike,
LoggerLike,
TimersLike,
} from "@suaveplan/types";
/** Operating mode for the game loop. */
export type GameLoopMode = "tick-based" | "event-driven" | "hybrid";
/** Validated, frozen game-loop options. */
export interface GameLoopOptions {
readonly mode: GameLoopMode;
/** Ticks-per-second for `tick-based`/`hybrid`. Defaults to 60. */
readonly tickRate: number;
/** Maximum delta time clamp in milliseconds. Defaults to 100. */
readonly maxDeltaTime: number;
/** Maximum catch-up ticks per scheduler invocation. Defaults to 5. */
readonly catchUpLimit: number;
}
/** Caller-supplied subset of {@link GameLoopOptions} — everything optional. */
export interface GameLoopOptionsInput {
readonly mode?: GameLoopMode;
readonly tickRate?: number;
readonly maxDeltaTime?: number;
readonly catchUpLimit?: number;
}
/** Update callback invoked every tick/frame with delta + total elapsed (ms). */
export type UpdateCallback = (
deltaTime: number,
totalTime: number
) => void | Promise<void>;
/** Fixed-update callback invoked at tick rate with the monotonically increasing tick count. */
export type FixedUpdateCallback = (tickCount: number) => void | Promise<void>;
/** Snapshot of loop statistics. */
export interface GameLoopStats {
readonly mode: GameLoopMode;
readonly running: boolean;
readonly paused: boolean;
readonly tickRate: number;
readonly totalTime: number;
readonly tickCount: number;
readonly frameCount: number;
readonly updateCallbackCount: number;
readonly fixedUpdateCallbackCount: number;
readonly averageTickMs: number;
}
/** Event topics the loop emits onto the injected EventBus. */
export type GameLoopEventMap = {
"loop:started": { mode: GameLoopMode };
"loop:stopped": { totalTime: number; tickCount: number };
"loop:paused": Record<string, never>;
"loop:resumed": Record<string, never>;
"loop:error": { error: Error; phase: "update" | "fixed-update" };
};
/** Dependencies required to construct a GameLoop. */
export interface GameLoopDeps {
readonly clock: Clock;
readonly timers: TimersLike;
readonly logger: LoggerLike;
readonly events: EventBusLike<GameLoopEventMap>;
}

View File

@@ -0,0 +1,11 @@
export { GameLoop } from "./game-loop.js";
export type {
FixedUpdateCallback,
GameLoopDeps,
GameLoopEventMap,
GameLoopMode,
GameLoopOptions,
GameLoopOptionsInput,
GameLoopStats,
UpdateCallback,
} from "./game-loop.types.js";

View File

@@ -0,0 +1,8 @@
export { PlayerManager } from "./player-manager.js";
export type {
PlayerManagerDeps,
PlayerManagerEventMap,
PlayerManagerOptions,
PlayerManagerOptionsInput,
PlayerManagerStats,
} from "./player-manager.types.js";

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,453 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike, TimersLike } from "@suaveplan/types";
import { runIPlayerManagerConformance } from "../../core/interfaces/i-player-manager/index.js";
import { GAME_SERVER_CODES, type GameServerError } from "../../errors/index.js";
import { PlayerManager } from "./player-manager.js";
import type { PlayerManagerEventMap } from "./player-manager.types.js";
type TimerEntry = { cb: () => void; due: number };
class FakeClock {
private t = 0;
public read = (): number => this.t;
public advance(ms: number): void {
this.t += ms;
}
}
class FakeTimers implements TimersLike {
public entries: Map<number, TimerEntry> = new Map();
private nextHandle = 1;
public constructor(private readonly clock: FakeClock) {}
public setTimer(cb: () => void, delayMs: number): number {
const handle = this.nextHandle++;
this.entries.set(handle, { cb, due: this.clock.read() + delayMs });
return handle;
}
public clearTimer(handle: number): void {
this.entries.delete(handle);
}
public fireDue(): number {
let fired = 0;
const now = this.clock.read();
for (const [h, entry] of [...this.entries.entries()]) {
if (entry.due <= now) {
this.entries.delete(h);
entry.cb();
fired++;
}
}
return fired;
}
}
class FakeBus implements EventBusLike<PlayerManagerEventMap> {
public emitted: { type: keyof PlayerManagerEventMap; payload: unknown }[] =
[];
public on(): () => void {
return () => {};
}
public emit<K extends keyof PlayerManagerEventMap>(
type: K,
payload: PlayerManagerEventMap[K]
): void {
this.emitted.push({ type, payload });
}
}
function nullLogger(): {
log: LoggerLike;
events: { level: string; msg: string }[];
} {
const events: { level: string; msg: string }[] = [];
const make = (level: string) => (msg: string) =>
events.push({ level, msg });
return {
events,
log: {
debug: make("debug"),
info: make("info"),
warn: make("warn"),
error: make("error"),
},
};
}
function makeMgr(options = {}): {
mgr: PlayerManager;
clock: FakeClock;
timers: FakeTimers;
bus: FakeBus;
log: ReturnType<typeof nullLogger>;
} {
const clock = new FakeClock();
const timers = new FakeTimers(clock);
const bus = new FakeBus();
const log = nullLogger();
const mgr = new PlayerManager(
{ clock: clock.read, timers, logger: log.log, events: bus },
options
);
return { mgr, clock, timers, bus, log };
}
describe("PlayerManager", () => {
it("IPlayerManager conformance harness passes", { timeout: 5000 }, () => {
const factory = () => makeMgr().mgr;
const results = runIPlayerManagerConformance(factory);
for (const r of results) {
expect(r.passed, r.scenario).toBe(true);
}
expect(results.length).toBeGreaterThan(0);
});
it("rejects invalid options with INVALID_PLAYER_MANAGER_OPTIONS", {
timeout: 5000,
}, () => {
try {
new PlayerManager(
{
clock: () => 0,
timers: new FakeTimers(new FakeClock()),
logger: nullLogger().log,
events: new FakeBus(),
},
{ timeoutThresholdMs: 0 }
);
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_PLAYER_MANAGER_OPTIONS
);
}
});
it("connect builds a fresh record and emits player:connected + player:joined", {
timeout: 5000,
}, () => {
const { mgr, bus, clock } = makeMgr();
clock.advance(100);
const conn = mgr.connect("p1", "s1", { tier: "gold" });
expect(conn.playerId).toBe("p1");
expect(conn.connectedAt).toBe(100);
expect(conn.metadata).toEqual({ tier: "gold" });
expect(bus.emitted.some((e) => e.type === "player:connected")).toBe(
true
);
expect(bus.emitted.some((e) => e.type === "player:joined")).toBe(true);
});
it("connect twice for the same already-connected player is a warning no-op", {
timeout: 5000,
}, () => {
const { mgr, log } = makeMgr();
mgr.connect("p1", "s1");
mgr.connect("p1", "s1");
expect(log.events.some((e) => e.level === "warn")).toBe(true);
expect(mgr.getAllConnections().length).toBe(1);
});
it("connect on a disconnected player walks the reconnection path", {
timeout: 5000,
}, () => {
const { mgr, bus, clock } = makeMgr();
clock.advance(10);
mgr.connect("p1", "s1", { tier: "gold" });
mgr.disconnect("p1", "test");
bus.emitted = [];
clock.advance(50);
const reconnected = mgr.connect("p1", "s1", { region: "eu" });
expect(reconnected.connectedAt).toBe(10); // preserved from original
expect(reconnected.metadata).toEqual({ tier: "gold", region: "eu" });
expect(bus.emitted.some((e) => e.type === "player:reconnected")).toBe(
true
);
});
it("disconnect emits both player:disconnected and player:left", {
timeout: 5000,
}, () => {
const { mgr, bus } = makeMgr();
mgr.connect("p1", "s1");
bus.emitted = [];
mgr.disconnect("p1", "client_quit");
expect(bus.emitted.some((e) => e.type === "player:disconnected")).toBe(
true
);
expect(bus.emitted.some((e) => e.type === "player:left")).toBe(true);
});
it("disconnect on unknown player is a logged no-op", {
timeout: 5000,
}, () => {
const { mgr, log } = makeMgr();
mgr.disconnect("nope", "x");
expect(log.events.some((e) => e.level === "warn")).toBe(true);
});
it("getConnection returns null for unknown ids", { timeout: 5000 }, () => {
const { mgr } = makeMgr();
expect(mgr.getConnection("nope")).toBeNull();
});
it("getSessionPlayers + getAllConnections return defensive copies", {
timeout: 5000,
}, () => {
const { mgr } = makeMgr();
mgr.connect("a", "s1");
mgr.connect("b", "s1");
mgr.connect("c", "s2");
expect(mgr.getSessionPlayers("s1").length).toBe(2);
expect(mgr.getAllConnections().length).toBe(3);
});
it("heartbeat refreshes lastHeartbeat and recovers reconnecting status", {
timeout: 5000,
}, () => {
const { mgr, clock } = makeMgr();
mgr.connect("p1", "s1");
// Force reconnecting status (private), then heartbeat recovers.
const internal = (
mgr as unknown as {
connections: Map<string, { status: string }>;
}
).connections.get("p1");
if (internal !== undefined) internal.status = "reconnecting";
clock.advance(500);
mgr.heartbeat("p1");
expect(mgr.getConnection("p1")?.status).toBe("connected");
expect(mgr.getConnection("p1")?.lastHeartbeat).toBe(500);
});
it("heartbeat from unknown player is a logged no-op", {
timeout: 5000,
}, () => {
const { mgr, log } = makeMgr();
mgr.heartbeat("nope");
expect(log.events.some((e) => e.level === "warn")).toBe(true);
});
it("checkTimeouts marks connected players past threshold as timeout", {
timeout: 5000,
}, () => {
const { mgr, clock, bus } = makeMgr({ timeoutThresholdMs: 100 });
mgr.connect("p1", "s1");
clock.advance(500);
bus.emitted = [];
mgr.checkTimeouts();
expect(mgr.getConnection("p1")?.status).toBe("timeout");
expect(bus.emitted.some((e) => e.type === "player:timeout")).toBe(true);
});
it("checkTimeouts evicts disconnected entries past grace window", {
timeout: 5000,
}, () => {
const { mgr, clock } = makeMgr({
timeoutThresholdMs: 1_000,
reconnectionGraceMs: 100,
});
mgr.connect("p1", "s1");
mgr.disconnect("p1", "x");
clock.advance(500);
mgr.checkTimeouts();
expect(mgr.getConnection("p1")).toBeNull();
});
it("updateMetadata merges into existing metadata", {
timeout: 5000,
}, () => {
const { mgr } = makeMgr();
mgr.connect("p1", "s1", { tier: "gold" });
mgr.updateMetadata("p1", { region: "eu" });
expect(mgr.getConnection("p1")?.metadata).toEqual({
tier: "gold",
region: "eu",
});
});
it("updateMetadata on unknown player is a logged no-op", {
timeout: 5000,
}, () => {
const { mgr, log } = makeMgr();
mgr.updateMetadata("nope", { x: 1 });
expect(log.events.some((e) => e.level === "warn")).toBe(true);
});
it("getPlayer returns PlayerInfo or null", { timeout: 5000 }, () => {
const { mgr } = makeMgr();
expect(mgr.getPlayer("nope")).toBeNull();
mgr.connect("p1", "s1", { tier: "gold" });
const info = mgr.getPlayer("p1");
expect(info?.id).toBe("p1");
expect(info?.sessionId).toBe("s1");
expect(info?.metadata).toEqual({ tier: "gold" });
});
it("startHeartbeatMonitor schedules ticks via TimersLike", {
timeout: 5000,
}, () => {
const { mgr, timers, clock } = makeMgr({ timeoutThresholdMs: 100 });
mgr.connect("p1", "s1");
mgr.startHeartbeatMonitor(50);
expect(timers.entries.size).toBe(1);
clock.advance(200);
timers.fireDue();
expect(mgr.getConnection("p1")?.status).toBe("timeout");
// The tick re-scheduled itself.
expect(timers.entries.size).toBe(1);
mgr.stopHeartbeatMonitor();
expect(timers.entries.size).toBe(0);
});
it("startHeartbeatMonitor twice is a logged no-op (already running)", {
timeout: 5000,
}, () => {
const { mgr, timers, log } = makeMgr();
mgr.startHeartbeatMonitor(50);
mgr.startHeartbeatMonitor(50);
expect(log.events.some((e) => e.level === "warn")).toBe(true);
expect(timers.entries.size).toBe(1);
mgr.stopHeartbeatMonitor();
});
it("startHeartbeatMonitor rejects non-positive intervals", {
timeout: 5000,
}, () => {
const { mgr } = makeMgr();
try {
mgr.startHeartbeatMonitor(0);
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_HEARTBEAT_INTERVAL
);
}
try {
mgr.startHeartbeatMonitor(1.5);
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_HEARTBEAT_INTERVAL
);
}
});
it("stopHeartbeatMonitor without start is a no-op", {
timeout: 5000,
}, () => {
const { mgr } = makeMgr();
mgr.stopHeartbeatMonitor();
expect(mgr.getStats().heartbeatMonitorRunning).toBe(false);
});
it("getStats reports buckets and heartbeat status", {
timeout: 5000,
}, () => {
const { mgr, clock } = makeMgr();
clock.advance(10);
mgr.connect("a", "s1");
mgr.connect("b", "s1");
mgr.disconnect("b", "x");
clock.advance(100);
const s = mgr.getStats();
expect(s.total).toBe(2);
expect(s.connected).toBe(1);
expect(s.disconnected).toBe(1);
expect(s.avgConnectionDurationMs).toBeGreaterThan(0);
expect(s.heartbeatMonitorRunning).toBe(false);
});
it("getStats with zero connections returns avgDuration 0", {
timeout: 5000,
}, () => {
const { mgr } = makeMgr();
expect(mgr.getStats().avgConnectionDurationMs).toBe(0);
});
it("constructs with default options (no second arg)", {
timeout: 5000,
}, () => {
const clock = new FakeClock();
const timers = new FakeTimers(clock);
const mgr = new PlayerManager({
clock: clock.read,
timers,
logger: nullLogger().log,
events: new FakeBus(),
});
expect(mgr.options.timeoutThresholdMs).toBe(30_000);
expect(mgr.options.reconnectionGraceMs).toBe(10_000);
});
it("reconnection without metadata uses the existing metadata bag", {
timeout: 5000,
}, () => {
const { mgr } = makeMgr();
mgr.connect("p1", "s1", { tier: "gold" });
mgr.disconnect("p1", "x");
// Reconnect WITHOUT a metadata arg — branch coverage on `metadata !== undefined`.
const conn = mgr.connect("p1", "s1");
expect(conn.metadata).toEqual({ tier: "gold" });
});
it("checkTimeouts ignores disconnected entries within the grace window", {
timeout: 5000,
}, () => {
const { mgr, clock } = makeMgr({
timeoutThresholdMs: 1_000,
reconnectionGraceMs: 10_000,
});
mgr.connect("p1", "s1");
mgr.disconnect("p1", "x");
clock.advance(500); // within grace
mgr.checkTimeouts();
// Still present (grace not expired).
expect(mgr.getConnection("p1")?.status).toBe("disconnected");
});
it("checkTimeouts no-op branch for timeout-status entries (else-if false path)", {
timeout: 5000,
}, () => {
const { mgr, clock } = makeMgr({
timeoutThresholdMs: 100,
reconnectionGraceMs: 10_000,
});
mgr.connect("p1", "s1");
clock.advance(500);
mgr.checkTimeouts();
expect(mgr.getConnection("p1")?.status).toBe("timeout");
// A second checkTimeouts walks the entry and falls through the
// else-if (status is "timeout", not "disconnected"), exercising
// the false branch of the outer condition.
mgr.checkTimeouts();
expect(mgr.getConnection("p1")?.status).toBe("timeout");
});
it("heartbeat tick stops re-scheduling if monitor was stopped mid-tick", {
timeout: 5000,
}, () => {
const { mgr, timers, clock } = makeMgr({ timeoutThresholdMs: 10_000 });
mgr.connect("p1", "s1");
mgr.startHeartbeatMonitor(50);
// Capture the timer cb directly.
const entry = [...timers.entries.values()][0];
clock.advance(50);
// Stop the monitor BEFORE firing the captured cb so the
// self-rescheduling branch sees handle === undefined.
mgr.stopHeartbeatMonitor();
if (entry !== undefined) entry.cb();
// No new timer was scheduled — the early-return branch fired.
expect(timers.entries.size).toBe(0);
});
it("dispose stops the monitor and drops every connection", {
timeout: 5000,
}, () => {
const { mgr, timers } = makeMgr();
mgr.connect("a", "s1");
mgr.startHeartbeatMonitor(50);
mgr.dispose();
expect(timers.entries.size).toBe(0);
expect(mgr.getAllConnections()).toEqual([]);
});
});

View File

@@ -0,0 +1,305 @@
import z from "zod";
import type { IPlayerManager } from "../../core/interfaces/i-player-manager/index.js";
import type { PlayerConnection, PlayerInfo } from "../../core/types/index.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { recordPlayerLifecycle } from "../../observability/index.js";
import type {
PlayerManagerDeps,
PlayerManagerOptions,
PlayerManagerOptionsInput,
PlayerManagerStats,
} from "./player-manager.types.js";
const PlayerManagerOptionsSchema = z
.object({
timeoutThresholdMs: z.number().int().positive().optional(),
reconnectionGraceMs: z.number().int().nonnegative().optional(),
})
.strict();
function resolveOptions(
input: PlayerManagerOptionsInput
): PlayerManagerOptions {
const parsed = PlayerManagerOptionsSchema.safeParse(input);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_PLAYER_MANAGER_OPTIONS,
"PlayerManagerOptions failed schema validation",
{ cause: parsed.error, context: { input } }
);
}
return {
timeoutThresholdMs: parsed.data.timeoutThresholdMs ?? 30_000,
reconnectionGraceMs: parsed.data.reconnectionGraceMs ?? 10_000,
};
}
interface MutableConnection {
playerId: string;
sessionId: string;
status: PlayerConnection["status"];
connectedAt: number;
lastHeartbeat: number;
metadata: Record<string, unknown>;
disconnectedAt: number | null;
}
function freeze(c: MutableConnection): PlayerConnection {
return {
playerId: c.playerId,
sessionId: c.sessionId,
status: c.status,
connectedAt: c.connectedAt,
lastHeartbeat: c.lastHeartbeat,
metadata: { ...c.metadata },
};
}
/** Player connection manager — see `player-manager.md`. */
export class PlayerManager implements IPlayerManager {
public readonly options: PlayerManagerOptions;
private readonly deps: PlayerManagerDeps;
private readonly connections: Map<string, MutableConnection> = new Map();
private heartbeatHandle: number | undefined;
private heartbeatInterval = 0;
public constructor(
deps: PlayerManagerDeps,
options: PlayerManagerOptionsInput = {}
) {
this.deps = deps;
this.options = Object.freeze(resolveOptions(options));
}
public connect(
playerId: string,
sessionId: string,
metadata?: Record<string, unknown>
): PlayerConnection {
const now = this.deps.clock();
const existing = this.connections.get(playerId);
if (existing !== undefined) {
if (existing.status === "connected") {
this.deps.logger.warn("player already connected", {
playerId,
sessionId,
});
recordPlayerLifecycle("connect", "error");
return freeze(existing);
}
existing.status = "connected";
existing.sessionId = sessionId;
existing.lastHeartbeat = now;
existing.disconnectedAt = null;
if (metadata !== undefined) {
existing.metadata = { ...existing.metadata, ...metadata };
}
this.deps.events.emit("player:reconnected", { playerId });
this.deps.events.emit("player:connected", {
connection: freeze(existing),
});
this.deps.events.emit("player:joined", { playerId, sessionId });
this.deps.logger.info("player reconnected", {
playerId,
sessionId,
});
recordPlayerLifecycle("reconnect", "ok");
return freeze(existing);
}
const conn: MutableConnection = {
playerId,
sessionId,
status: "connected",
connectedAt: now,
lastHeartbeat: now,
metadata: { ...(metadata ?? {}) },
disconnectedAt: null,
};
this.connections.set(playerId, conn);
this.deps.events.emit("player:connected", { connection: freeze(conn) });
this.deps.events.emit("player:joined", { playerId, sessionId });
this.deps.logger.info("player connected", { playerId, sessionId });
recordPlayerLifecycle("connect", "ok");
return freeze(conn);
}
public disconnect(playerId: string, reason: string): void {
const conn = this.connections.get(playerId);
if (conn === undefined) {
this.deps.logger.warn("disconnect on unknown player", { playerId });
recordPlayerLifecycle("disconnect", "error");
return;
}
const sessionId = conn.sessionId;
conn.status = "disconnected";
conn.disconnectedAt = this.deps.clock();
this.deps.events.emit("player:disconnected", { playerId, reason });
this.deps.events.emit("player:left", { playerId, sessionId });
this.deps.logger.info("player disconnected", {
playerId,
sessionId,
reason,
});
recordPlayerLifecycle("disconnect", "ok");
}
public getConnection(playerId: string): PlayerConnection | null {
const conn = this.connections.get(playerId);
return conn === undefined ? null : freeze(conn);
}
public getSessionPlayers(
sessionId: string
): ReadonlyArray<PlayerConnection> {
const out: PlayerConnection[] = [];
for (const conn of this.connections.values()) {
if (conn.sessionId === sessionId) out.push(freeze(conn));
}
return out;
}
public getAllConnections(): ReadonlyArray<PlayerConnection> {
return [...this.connections.values()].map(freeze);
}
public heartbeat(playerId: string): void {
const conn = this.connections.get(playerId);
if (conn === undefined) {
this.deps.logger.warn("heartbeat from unknown player", {
playerId,
});
recordPlayerLifecycle("heartbeat", "error");
return;
}
const now = this.deps.clock();
conn.lastHeartbeat = now;
if (conn.status === "reconnecting" || conn.status === "timeout") {
conn.status = "connected";
this.deps.logger.debug("player connection recovered", { playerId });
}
this.deps.events.emit("player:heartbeat", {
playerId,
timestamp: now,
});
recordPlayerLifecycle("heartbeat", "ok");
}
public checkTimeouts(): void {
const now = this.deps.clock();
const threshold = this.options.timeoutThresholdMs;
const grace = this.options.reconnectionGraceMs;
for (const conn of this.connections.values()) {
if (conn.status === "connected") {
if (now - conn.lastHeartbeat > threshold) {
conn.status = "timeout";
conn.disconnectedAt = now;
this.deps.events.emit("player:timeout", {
playerId: conn.playerId,
});
this.deps.logger.warn("player timed out", {
playerId: conn.playerId,
});
recordPlayerLifecycle("timeout", "ok");
}
} else if (
conn.status === "disconnected" &&
conn.disconnectedAt !== null &&
now - conn.disconnectedAt > grace
) {
this.connections.delete(conn.playerId);
this.deps.logger.debug("player grace expired; removed", {
playerId: conn.playerId,
});
}
}
}
public updateMetadata(
playerId: string,
metadata: Record<string, unknown>
): void {
const conn = this.connections.get(playerId);
if (conn === undefined) {
this.deps.logger.warn("updateMetadata on unknown player", {
playerId,
});
return;
}
conn.metadata = { ...conn.metadata, ...metadata };
}
public getPlayer(playerId: string): PlayerInfo | null {
const conn = this.connections.get(playerId);
if (conn === undefined) return null;
return {
id: playerId,
sessionId: conn.sessionId,
metadata: { ...conn.metadata },
};
}
public startHeartbeatMonitor(intervalMs: number): void {
if (!Number.isInteger(intervalMs) || intervalMs <= 0) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_HEARTBEAT_INTERVAL,
"intervalMs must be a positive integer",
{ context: { intervalMs } }
);
}
if (this.heartbeatHandle !== undefined) {
this.deps.logger.warn("heartbeat monitor already running", {
intervalMs: this.heartbeatInterval,
});
return;
}
this.heartbeatInterval = intervalMs;
const tick = (): void => {
this.checkTimeouts();
if (this.heartbeatHandle !== undefined) {
this.heartbeatHandle = this.deps.timers.setTimer(
tick,
intervalMs
);
}
};
this.heartbeatHandle = this.deps.timers.setTimer(tick, intervalMs);
this.deps.logger.info("heartbeat monitor started", { intervalMs });
}
public stopHeartbeatMonitor(): void {
if (this.heartbeatHandle === undefined) return;
this.deps.timers.clearTimer(this.heartbeatHandle);
this.heartbeatHandle = undefined;
this.heartbeatInterval = 0;
this.deps.logger.info("heartbeat monitor stopped");
}
/** Snapshot of stats. */
public getStats(): PlayerManagerStats {
const now = this.deps.clock();
const buckets = {
connected: 0,
disconnected: 0,
reconnecting: 0,
timeout: 0,
};
let totalDuration = 0;
for (const conn of this.connections.values()) {
buckets[conn.status]++;
totalDuration += now - conn.connectedAt;
}
const total = this.connections.size;
return {
total,
...buckets,
avgConnectionDurationMs: total > 0 ? totalDuration / total : 0,
heartbeatMonitorRunning: this.heartbeatHandle !== undefined,
};
}
/** Stop the monitor and drop every connection. */
public dispose(): void {
this.stopHeartbeatMonitor();
this.connections.clear();
}
}

View File

@@ -0,0 +1,64 @@
/**
* Public types for {@link PlayerManager}.
*/
import type {
Clock,
EventBusLike,
LoggerLike,
TimersLike,
} from "@suaveplan/types";
import type { PlayerConnection } from "../../core/types/index.js";
/** Event topics emitted by `PlayerManager`. */
export type PlayerManagerEventMap = {
"player:connected": { readonly connection: PlayerConnection };
"player:disconnected": {
readonly playerId: string;
readonly reason: string;
};
"player:reconnected": { readonly playerId: string };
"player:timeout": { readonly playerId: string };
"player:heartbeat": {
readonly playerId: string;
readonly timestamp: number;
};
"player:joined": {
readonly playerId: string;
readonly sessionId: string;
};
"player:left": {
readonly playerId: string;
readonly sessionId: string;
};
};
/** Dependencies required to construct a `PlayerManager`. */
export interface PlayerManagerDeps {
readonly clock: Clock;
readonly timers: TimersLike;
readonly logger: LoggerLike;
readonly events: EventBusLike<PlayerManagerEventMap>;
}
/** Validated, frozen `PlayerManager` options. */
export interface PlayerManagerOptions {
readonly timeoutThresholdMs: number;
readonly reconnectionGraceMs: number;
}
/** Caller-supplied subset — everything optional. */
export interface PlayerManagerOptionsInput {
readonly timeoutThresholdMs?: number;
readonly reconnectionGraceMs?: number;
}
/** Snapshot of player-manager state. */
export interface PlayerManagerStats {
readonly total: number;
readonly connected: number;
readonly disconnected: number;
readonly reconnecting: number;
readonly timeout: number;
readonly avgConnectionDurationMs: number;
readonly heartbeatMonitorRunning: boolean;
}

View File

@@ -0,0 +1,8 @@
export { PluginManager } from "./plugin-manager.js";
export type {
EnginePlugin,
EnginePluginContext,
PluginManagerDeps,
PluginManagerEventMap,
PluginManagerStats,
} from "./plugin-manager.types.js";

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,373 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import { flushTestMetrics } from "../../../tests/setup/polyfills.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { PluginManager } from "./plugin-manager.js";
import type {
EnginePlugin,
PluginManagerEventMap,
} from "./plugin-manager.types.js";
class FakeBus implements EventBusLike<PluginManagerEventMap> {
public emitted: { type: keyof PluginManagerEventMap; payload: unknown }[] =
[];
private readonly subs: Map<
keyof PluginManagerEventMap,
Set<(payload: unknown) => void | Promise<void>>
> = new Map();
public on<K extends keyof PluginManagerEventMap>(
type: K,
listener: (payload: PluginManagerEventMap[K]) => void | Promise<void>
): () => void {
if (!this.subs.has(type)) this.subs.set(type, new Set());
const set = this.subs.get(type) as Set<
(payload: unknown) => void | Promise<void>
>;
set.add(listener as (payload: unknown) => void | Promise<void>);
return () =>
set.delete(listener as (p: unknown) => void | Promise<void>);
}
public emit<K extends keyof PluginManagerEventMap>(
type: K,
payload: PluginManagerEventMap[K]
): void {
this.emitted.push({ type, payload });
const set = this.subs.get(type);
if (set !== undefined) {
for (const l of set) {
void l(payload);
}
}
}
}
function recordingLogger(): {
log: LoggerLike;
events: { level: string; msg: string }[];
} {
const events: { level: string; msg: string }[] = [];
const make = (level: string) => (msg: string) => {
events.push({ level, msg });
};
return {
events,
log: {
debug: make("debug"),
info: make("info"),
warn: make("warn"),
error: make("error"),
},
};
}
function makeManager(): {
manager: PluginManager;
bus: FakeBus;
log: ReturnType<typeof recordingLogger>;
} {
const bus = new FakeBus();
const log = recordingLogger();
return {
bus,
log,
manager: new PluginManager({ logger: log.log, events: bus }),
};
}
describe("PluginManager", () => {
beforeEach(async () => {
await flushTestMetrics();
});
it("install registers the plugin and fires onInstall + OTel counter", {
timeout: 5000,
}, async () => {
const { manager, bus } = makeManager();
let installed = 0;
const plugin: EnginePlugin = {
name: "metrics",
version: "1.0.0",
onInstall() {
installed++;
},
};
await manager.install(plugin);
expect(installed).toBe(1);
expect(manager.has("metrics")).toBe(true);
expect(manager.get("metrics")).toBe(plugin);
expect(manager.list()).toEqual(["metrics"]);
expect(bus.emitted.some((e) => e.type === "plugin:installed")).toBe(
true
);
const metrics = await flushTestMetrics();
const counter = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find(
(m) =>
m.descriptor.name === "game_server.plugin.lifecycle_total"
);
expect(counter).toBeDefined();
});
it("second install with the same name rejects with DUPLICATE_PLUGIN", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
const p: EnginePlugin = { name: "dup", version: "1.0.0" };
await manager.install(p);
try {
await manager.install({ ...p });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GameServerError);
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.DUPLICATE_PLUGIN
);
}
});
it("unknown plugin name rejects with PLUGIN_NOT_FOUND on uninstall/enable/disable/get", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
for (const op of ["uninstall", "enable", "disable"] as const) {
try {
await manager[op]("nope");
expect.fail(`expected throw from ${op}`);
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PLUGIN_NOT_FOUND
);
}
}
expect(() => manager.get("nope")).toThrow(GameServerError);
expect(() => manager.isEnabled("nope")).toThrow(GameServerError);
expect(manager.has("nope")).toBe(false);
});
it("enable then disable toggles state and fires both hooks", {
timeout: 5000,
}, async () => {
const { manager, bus } = makeManager();
const calls: string[] = [];
const plugin: EnginePlugin = {
name: "p1",
version: "1.0.0",
onEnable() {
calls.push("enable");
},
onDisable() {
calls.push("disable");
},
};
await manager.install(plugin);
expect(manager.isEnabled("p1")).toBe(false);
await manager.enable("p1");
await manager.enable("p1"); // idempotent
expect(manager.isEnabled("p1")).toBe(true);
await manager.disable("p1");
await manager.disable("p1"); // idempotent
expect(manager.isEnabled("p1")).toBe(false);
expect(calls).toEqual(["enable", "disable"]);
expect(
bus.emitted.filter((e) => e.type === "plugin:enabled")
).toHaveLength(1);
expect(
bus.emitted.filter((e) => e.type === "plugin:disabled")
).toHaveLength(1);
});
it("hook errors wrap into PLUGIN_LIFECYCLE_FAILED with cause and context", {
timeout: 5000,
}, async () => {
const { manager, bus, log } = makeManager();
const original = new Error("install boom");
try {
await manager.install({
name: "broken",
version: "1.0.0",
onInstall() {
throw original;
},
});
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED
);
expect((err as GameServerError).cause).toBe(original);
expect((err as GameServerError).context).toEqual({
name: "broken",
hook: "onInstall",
});
}
expect(bus.emitted.some((e) => e.type === "plugin:error")).toBe(true);
expect(log.events.some((e) => e.level === "error")).toBe(true);
// Plugin must NOT remain registered if onInstall threw.
expect(manager.has("broken")).toBe(false);
// non-Error throw branch
try {
await manager.install({
name: "broken2",
version: "1.0.0",
onInstall() {
const cause: unknown = "string-cause";
throw cause;
},
});
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED
);
}
});
it("installAll honours topological dependency order", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
const order: string[] = [];
await manager.installAll([
{
name: "leaderboard",
version: "1.0.0",
dependencies: ["metrics"],
onInstall() {
order.push("leaderboard");
},
},
{
name: "metrics",
version: "1.0.0",
onInstall() {
order.push("metrics");
},
},
]);
expect(order).toEqual(["metrics", "leaderboard"]);
expect(manager.list()).toEqual(["metrics", "leaderboard"]);
});
it("installAll rejects when a declared dependency is missing", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
try {
await manager.installAll([
{
name: "needs-x",
version: "1.0.0",
dependencies: ["x"],
},
]);
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED
);
expect((err as GameServerError).context?.missing).toBe("x");
}
});
it("installAll rejects on circular dependency", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
try {
await manager.installAll([
{ name: "a", version: "1.0.0", dependencies: ["b"] },
{ name: "b", version: "1.0.0", dependencies: ["a"] },
]);
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED
);
expect(String((err as GameServerError).context?.cycle)).toContain(
"->"
);
}
});
it("uninstall disables an enabled plugin first then calls onUninstall", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
const calls: string[] = [];
await manager.install({
name: "p",
version: "1.0.0",
onEnable() {
calls.push("enable");
},
onDisable() {
calls.push("disable");
},
onUninstall() {
calls.push("uninstall");
},
});
await manager.enable("p");
await manager.uninstall("p");
expect(calls).toEqual(["enable", "disable", "uninstall"]);
expect(manager.has("p")).toBe(false);
});
it("disposeAll tears plugins down in reverse install order", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
const order: string[] = [];
await manager.installAll([
{
name: "a",
version: "1.0.0",
onUninstall() {
order.push("a");
},
},
{
name: "b",
version: "1.0.0",
onUninstall() {
order.push("b");
},
},
]);
await manager.disposeAll();
expect(order).toEqual(["b", "a"]);
expect(manager.list()).toEqual([]);
});
it("getStats reports total/enabled/disabled/installOrder", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
await manager.install({ name: "a", version: "1.0.0" });
await manager.install({ name: "b", version: "1.0.0" });
await manager.enable("a");
const stats = manager.getStats();
expect(stats.total).toBe(2);
expect(stats.enabled).toBe(1);
expect(stats.disabled).toBe(1);
expect(stats.installOrder).toEqual(["a", "b"]);
});
it("schema validation rejects malformed plugins via PLUGIN_LIFECYCLE_FAILED", {
timeout: 5000,
}, async () => {
const { manager } = makeManager();
try {
// Empty strings satisfy the static type but fail the Zod min(1).
await manager.install({ name: "", version: "" });
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED
);
}
});
});

View File

@@ -0,0 +1,252 @@
import z from "zod";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { recordPluginLifecycle } from "../../observability/index.js";
import type {
EnginePlugin,
EnginePluginContext,
PluginManagerDeps,
PluginManagerStats,
} from "./plugin-manager.types.js";
const PluginSchema = z.object({
name: z.string().min(1),
version: z.string().min(1),
dependencies: z.array(z.string().min(1)).optional(),
});
type PluginEntry = {
readonly plugin: EnginePlugin;
enabled: boolean;
};
/**
* Typed plugin registry with install/uninstall/enable/disable lifecycle and
* topological dependency ordering. Every plugin lifecycle hook is wrapped
* in a try/catch that re-throws as `PLUGIN_LIFECYCLE_FAILED`, so consumers
* have a single discriminable error code to catch.
*/
export class PluginManager {
private readonly entries: Map<string, PluginEntry> = new Map();
private readonly installOrder: string[] = [];
private readonly deps: PluginManagerDeps;
private readonly ctx: EnginePluginContext;
public constructor(deps: PluginManagerDeps) {
this.deps = deps;
this.ctx = { logger: deps.logger, events: deps.events };
}
/** Install a single plugin (no dependency batching). */
public async install(plugin: EnginePlugin): Promise<void> {
this.validatePlugin(plugin);
if (this.entries.has(plugin.name)) {
recordPluginLifecycle("install", "error");
throw new GameServerError(
GAME_SERVER_CODES.DUPLICATE_PLUGIN,
`Plugin "${plugin.name}" is already installed`,
{ context: { name: plugin.name } }
);
}
await this.runHook(plugin, "onInstall");
this.entries.set(plugin.name, { plugin, enabled: false });
this.installOrder.push(plugin.name);
this.deps.events.emit("plugin:installed", {
name: plugin.name,
version: plugin.version,
});
this.deps.logger.info("plugin installed", {
name: plugin.name,
version: plugin.version,
});
recordPluginLifecycle("install", "ok");
}
/** Install a batch of plugins honouring declared dependency order. */
public async installAll(plugins: readonly EnginePlugin[]): Promise<void> {
const ordered = this.topoSort(plugins);
for (const p of ordered) {
// sequential install matches upstream guarantee
await this.install(p);
}
}
/** Uninstall a plugin by name. */
public async uninstall(name: string): Promise<void> {
const entry = this.requireEntry(name, "uninstall");
if (entry.enabled) {
await this.runHook(entry.plugin, "onDisable");
entry.enabled = false;
}
await this.runHook(entry.plugin, "onUninstall");
this.entries.delete(name);
// The map and installOrder are kept in sync at every mutation,
// so the index is guaranteed to be non-negative here.
const idx = this.installOrder.indexOf(name);
this.installOrder.splice(idx, 1);
this.deps.events.emit("plugin:uninstalled", { name });
this.deps.logger.info("plugin uninstalled", { name });
recordPluginLifecycle("uninstall", "ok");
}
/** Enable a plugin (fires `onEnable`). */
public async enable(name: string): Promise<void> {
const entry = this.requireEntry(name, "enable");
if (entry.enabled) {
return;
}
await this.runHook(entry.plugin, "onEnable");
entry.enabled = true;
this.deps.events.emit("plugin:enabled", { name });
recordPluginLifecycle("enable", "ok");
}
/** Disable a plugin (fires `onDisable`). */
public async disable(name: string): Promise<void> {
const entry = this.requireEntry(name, "disable");
if (!entry.enabled) {
return;
}
await this.runHook(entry.plugin, "onDisable");
entry.enabled = false;
this.deps.events.emit("plugin:disabled", { name });
recordPluginLifecycle("disable", "ok");
}
/** Lookup a plugin by name. Throws `PLUGIN_NOT_FOUND` on miss. */
public get(name: string): EnginePlugin {
return this.requireEntry(name, "get").plugin;
}
/** Whether a plugin is registered. */
public has(name: string): boolean {
return this.entries.has(name);
}
/** Whether a registered plugin is enabled. Throws if unknown. */
public isEnabled(name: string): boolean {
return this.requireEntry(name, "isEnabled").enabled;
}
/** Names of installed plugins, in install order. */
public list(): readonly string[] {
return [...this.installOrder];
}
/** Snapshot statistics. */
public getStats(): PluginManagerStats {
let enabled = 0;
for (const e of this.entries.values()) {
if (e.enabled) enabled++;
}
return {
total: this.entries.size,
enabled,
disabled: this.entries.size - enabled,
installOrder: [...this.installOrder],
};
}
/** Uninstall every plugin in reverse install order. */
public async disposeAll(): Promise<void> {
const reverse = [...this.installOrder].reverse();
for (const name of reverse) {
await this.uninstall(name);
}
}
private requireEntry(name: string, op: string): PluginEntry {
const entry = this.entries.get(name);
if (entry === undefined) {
throw new GameServerError(
GAME_SERVER_CODES.PLUGIN_NOT_FOUND,
`Plugin "${name}" is not installed`,
{ context: { name, op } }
);
}
return entry;
}
private validatePlugin(plugin: EnginePlugin): void {
const parsed = PluginSchema.safeParse(plugin);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED,
"Plugin failed schema validation",
{ cause: parsed.error, context: { plugin } }
);
}
}
private async runHook(
plugin: EnginePlugin,
hook: "onInstall" | "onUninstall" | "onEnable" | "onDisable"
): Promise<void> {
const fn = plugin[hook];
if (typeof fn !== "function") {
return;
}
try {
await fn.call(plugin, this.ctx);
} catch (cause) {
const error =
cause instanceof Error ? cause : new Error(String(cause));
this.deps.events.emit("plugin:error", {
name: plugin.name,
hook,
error,
});
this.deps.logger.error("plugin hook failed", {
name: plugin.name,
hook,
error: error.message,
});
throw new GameServerError(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED,
`Plugin "${plugin.name}" hook "${hook}" failed`,
{ cause, context: { name: plugin.name, hook } }
);
}
}
private topoSort(plugins: readonly EnginePlugin[]): EnginePlugin[] {
const byName = new Map<string, EnginePlugin>();
for (const p of plugins) {
this.validatePlugin(p);
byName.set(p.name, p);
}
const order: EnginePlugin[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
const visit = (name: string, path: readonly string[]): void => {
if (visited.has(name)) return;
if (visiting.has(name)) {
throw new GameServerError(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED,
"Circular plugin dependency detected",
{ context: { cycle: [...path, name].join(" -> ") } }
);
}
visiting.add(name);
const plugin = byName.get(name);
if (plugin === undefined) {
throw new GameServerError(
GAME_SERVER_CODES.PLUGIN_LIFECYCLE_FAILED,
`Plugin dependency "${name}" is missing`,
{ context: { missing: name, requestedBy: path } }
);
}
for (const dep of plugin.dependencies ?? []) {
visit(dep, [...path, name]);
}
visiting.delete(name);
visited.add(name);
order.push(plugin);
};
for (const p of plugins) {
visit(p.name, []);
}
return order;
}
}

View File

@@ -0,0 +1,49 @@
/**
* Public types for {@link PluginManager}.
*/
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
/** Engine handle passed to each plugin lifecycle hook. */
export interface EnginePluginContext {
readonly logger: LoggerLike;
readonly events: EventBusLike<PluginManagerEventMap>;
}
/**
* Typed engine plugin contract. Every plugin SHALL declare a unique `name`
* and `version`. Lifecycle hooks are optional and may be synchronous or
* asynchronous. Dependencies are name references to other plugins that
* MUST be installed first (topological install order is enforced).
*/
export interface EnginePlugin {
readonly name: string;
readonly version: string;
readonly dependencies?: readonly string[];
onInstall?(ctx: EnginePluginContext): void | Promise<void>;
onUninstall?(ctx: EnginePluginContext): void | Promise<void>;
onEnable?(ctx: EnginePluginContext): void | Promise<void>;
onDisable?(ctx: EnginePluginContext): void | Promise<void>;
}
/** Snapshot of plugin manager state. */
export interface PluginManagerStats {
readonly total: number;
readonly enabled: number;
readonly disabled: number;
readonly installOrder: readonly string[];
}
/** Event topics the manager emits. */
export type PluginManagerEventMap = {
"plugin:installed": { name: string; version: string };
"plugin:uninstalled": { name: string };
"plugin:enabled": { name: string };
"plugin:disabled": { name: string };
"plugin:error": { name: string; hook: string; error: Error };
};
/** Dependencies required to construct a PluginManager. */
export interface PluginManagerDeps {
readonly logger: LoggerLike;
readonly events: EventBusLike<PluginManagerEventMap>;
}

View File

@@ -0,0 +1,8 @@
export { SessionLifecycle } from "./session-lifecycle.js";
export type {
SessionLifecycleDeps,
SessionLifecycleEntry,
SessionLifecycleEventMap,
SessionState,
SessionTransition,
} from "./session-lifecycle.types.js";

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,330 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import { flushTestMetrics } from "../../../tests/setup/polyfills.js";
import { GAME_SERVER_CODES, type GameServerError } from "../../errors/index.js";
import { SessionLifecycle } from "./session-lifecycle.js";
import type { SessionLifecycleEventMap } from "./session-lifecycle.types.js";
class FakeClock {
private t = 0;
public read = (): number => this.t;
public advance(ms: number): void {
this.t += ms;
}
}
class FakeBus implements EventBusLike<SessionLifecycleEventMap> {
public emitted: {
type: keyof SessionLifecycleEventMap;
payload: unknown;
}[] = [];
public on(): () => void {
return () => {};
}
public emit<K extends keyof SessionLifecycleEventMap>(
type: K,
payload: SessionLifecycleEventMap[K]
): void {
this.emitted.push({ type, payload });
}
}
function nullLogger(): {
log: LoggerLike;
events: { level: string; msg: string }[];
} {
const events: { level: string; msg: string }[] = [];
const make = (level: string) => (msg: string) =>
events.push({ level, msg });
return {
events,
log: {
debug: make("debug"),
info: make("info"),
warn: make("warn"),
error: make("error"),
},
};
}
function makeFsm(): {
fsm: SessionLifecycle;
clock: FakeClock;
bus: FakeBus;
log: ReturnType<typeof nullLogger>;
} {
const clock = new FakeClock();
const bus = new FakeBus();
const log = nullLogger();
const fsm = new SessionLifecycle({
clock: clock.read,
logger: log.log,
events: bus,
});
return { fsm, clock, bus, log };
}
describe("SessionLifecycle", () => {
beforeEach(async () => {
await flushTestMetrics();
});
it("register creates an entry in lobby state", { timeout: 5000 }, () => {
const { fsm, clock } = makeFsm();
clock.advance(100);
const entry = fsm.register("s1");
expect(entry.state).toBe("lobby");
expect(entry.enteredStateAt).toBe(100);
expect(fsm.getState("s1")).toBe("lobby");
});
it("register rejects duplicates with DUPLICATE_SESSION_ID", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
fsm.register("s1");
try {
fsm.register("s1");
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.DUPLICATE_SESSION_ID
);
}
});
it("lobby → active transition succeeds and emits event", {
timeout: 5000,
}, () => {
const { fsm, bus, clock } = makeFsm();
fsm.register("s1");
clock.advance(50);
const t = fsm.transition("s1", "active", "all_players_ready");
expect(t.from).toBe("lobby");
expect(t.to).toBe("active");
expect(t.at).toBe(50);
expect(t.reason).toBe("all_players_ready");
expect(bus.emitted.some((e) => e.type === "session:transitioned")).toBe(
true
);
});
it("active → ended transition succeeds", { timeout: 5000 }, () => {
const { fsm } = makeFsm();
fsm.register("s1");
fsm.transition("s1", "active");
fsm.transition("s1", "ended");
expect(fsm.getState("s1")).toBe("ended");
});
it("lobby → ended transition succeeds (short-circuit)", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
fsm.register("s1");
fsm.transition("s1", "ended", "host_cancelled");
expect(fsm.getState("s1")).toBe("ended");
});
it("lobby → lobby (self) is rejected with INVALID_SESSION_TRANSITION", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
fsm.register("s1");
try {
fsm.transition("s1", "lobby");
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_SESSION_TRANSITION
);
}
});
it("active → lobby (backwards) is rejected with INVALID_SESSION_TRANSITION", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
fsm.register("s1");
fsm.transition("s1", "active");
try {
fsm.transition("s1", "lobby");
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_SESSION_TRANSITION
);
}
});
it("transition out of ended is rejected with SESSION_ALREADY_ENDED", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
fsm.register("s1");
fsm.transition("s1", "ended");
try {
fsm.transition("s1", "active");
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.SESSION_ALREADY_ENDED
);
}
});
it("transition on unknown id is rejected with SESSION_NOT_FOUND", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
try {
fsm.transition("nope", "active");
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.SESSION_NOT_FOUND
);
}
});
it("canTransition is a non-throwing legality predicate", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
expect(fsm.canTransition("nope", "active")).toBe(false);
fsm.register("s1");
expect(fsm.canTransition("s1", "active")).toBe(true);
expect(fsm.canTransition("s1", "lobby")).toBe(false);
fsm.transition("s1", "ended");
expect(fsm.canTransition("s1", "active")).toBe(false);
});
it("getEntry / getAllEntries / getState return null/empty for unknown", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
expect(fsm.getEntry("nope")).toBeNull();
expect(fsm.getState("nope")).toBeNull();
expect(fsm.getAllEntries()).toEqual([]);
});
it("getEntry / getAllEntries return defensive copies", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
fsm.register("s1");
const e = fsm.getEntry("s1");
if (e === null) {
expect.fail();
return;
}
// Mutating a returned copy doesn't affect internal state.
(e as unknown as { state: string }).state = "active";
expect(fsm.getState("s1")).toBe("lobby");
const all = fsm.getAllEntries();
expect(all.length).toBe(1);
});
it("history preserves transitions across forget()", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
fsm.register("s1");
fsm.transition("s1", "active");
fsm.transition("s1", "ended");
expect(fsm.getHistory().length).toBe(2);
expect(fsm.getHistoryFor("s1").length).toBe(2);
fsm.forget("s1");
expect(fsm.getState("s1")).toBeNull();
expect(fsm.getHistoryFor("s1").length).toBe(2);
});
it("getHistory and getHistoryFor return defensive copies", {
timeout: 5000,
}, () => {
const { fsm } = makeFsm();
fsm.register("s1");
fsm.transition("s1", "active");
const h = fsm.getHistory();
(h as { length: number }).length = 0;
expect(fsm.getHistory().length).toBe(1);
const hf = fsm.getHistoryFor("s1");
(hf as { length: number }).length = 0;
expect(fsm.getHistoryFor("s1").length).toBe(1);
});
it("emits session.lifecycle_total counter on each transition", {
timeout: 5000,
}, async () => {
const { fsm } = makeFsm();
fsm.register("s1");
fsm.transition("s1", "active");
fsm.transition("s1", "ended");
const metrics = await flushTestMetrics();
const counter = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find(
(m) =>
m.descriptor.name === "game_server.session.lifecycle_total"
);
expect(counter).toBeDefined();
const dps = counter?.dataPoints ?? [];
// At least 3 samples: register (none → lobby) + 2 transitions.
const total = dps.reduce((acc, dp) => acc + (dp.value as number), 0);
expect(total).toBeGreaterThanOrEqual(3);
});
it("emits session.dwell_ms histogram sample for the state being left", {
timeout: 5000,
}, async () => {
const { fsm, clock } = makeFsm();
fsm.register("s1");
clock.advance(500);
fsm.transition("s1", "active");
const metrics = await flushTestMetrics();
const hist = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find((m) => m.descriptor.name === "game_server.session.dwell_ms");
expect(hist).toBeDefined();
const dp = hist?.dataPoints.find(
(p) => (p.attributes as { state?: string }).state === "lobby"
);
expect(dp).toBeDefined();
});
it("records outcome=error counter on every rejection path", {
timeout: 5000,
}, async () => {
const { fsm } = makeFsm();
// SESSION_NOT_FOUND path.
try {
fsm.transition("nope", "active");
} catch {}
// SESSION_ALREADY_ENDED path.
fsm.register("s1");
fsm.transition("s1", "ended");
try {
fsm.transition("s1", "active");
} catch {}
// INVALID_SESSION_TRANSITION path.
fsm.register("s2");
try {
fsm.transition("s2", "lobby");
} catch {}
const metrics = await flushTestMetrics();
const counter = metrics
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find(
(m) =>
m.descriptor.name === "game_server.session.lifecycle_total"
);
const errorDps = counter?.dataPoints.filter(
(p) => (p.attributes as { outcome?: string }).outcome === "error"
);
expect(errorDps).toBeDefined();
expect(errorDps?.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,142 @@
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import {
recordSessionDwell,
recordSessionLifecycle,
} from "../../observability/index.js";
import type {
SessionLifecycleDeps,
SessionLifecycleEntry,
SessionState,
SessionTransition,
} from "./session-lifecycle.types.js";
const ALLOWED: Readonly<Record<SessionState, ReadonlyArray<SessionState>>> = {
lobby: ["active", "ended"],
active: ["ended"],
ended: [],
};
interface MutableEntry {
sessionId: string;
state: SessionState;
enteredStateAt: number;
}
/** Per-session FSM: `lobby → active → ended`. See `session-lifecycle.md`. */
export class SessionLifecycle {
private readonly entries: Map<string, MutableEntry> = new Map();
private readonly history: SessionTransition[] = [];
private readonly deps: SessionLifecycleDeps;
public constructor(deps: SessionLifecycleDeps) {
this.deps = deps;
}
/** Register a new session in the `lobby` state. */
public register(sessionId: string): SessionLifecycleEntry {
if (this.entries.has(sessionId)) {
throw new GameServerError(
GAME_SERVER_CODES.DUPLICATE_SESSION_ID,
`Session "${sessionId}" already registered with lifecycle`,
{ context: { sessionId } }
);
}
const entry: MutableEntry = {
sessionId,
state: "lobby",
enteredStateAt: this.deps.clock(),
};
this.entries.set(sessionId, entry);
this.deps.logger.debug("session lifecycle registered", { sessionId });
recordSessionLifecycle("none", "lobby", "ok");
return { ...entry };
}
/** Transition `sessionId` to `to`, with an optional reason. */
public transition(
sessionId: string,
to: SessionState,
reason?: string
): SessionTransition {
const entry = this.entries.get(sessionId);
if (entry === undefined) {
recordSessionLifecycle("unknown", to, "error");
throw new GameServerError(
GAME_SERVER_CODES.SESSION_NOT_FOUND,
`Session "${sessionId}" is not registered`,
{ context: { sessionId, to } }
);
}
if (entry.state === "ended") {
recordSessionLifecycle(entry.state, to, "error");
throw new GameServerError(
GAME_SERVER_CODES.SESSION_ALREADY_ENDED,
`Session "${sessionId}" already ended`,
{ context: { sessionId, to } }
);
}
if (!ALLOWED[entry.state].includes(to)) {
recordSessionLifecycle(entry.state, to, "error");
throw new GameServerError(
GAME_SERVER_CODES.INVALID_SESSION_TRANSITION,
`Invalid transition ${entry.state} → ${to}`,
{ context: { sessionId, from: entry.state, to } }
);
}
const now = this.deps.clock();
const dwell = now - entry.enteredStateAt;
recordSessionDwell(entry.state, dwell);
const transition: SessionTransition = {
sessionId,
from: entry.state,
to,
at: now,
...(reason !== undefined ? { reason } : {}),
};
entry.state = to;
entry.enteredStateAt = now;
this.history.push(transition);
this.deps.events.emit("session:transitioned", transition);
this.deps.logger.info("session transitioned", { ...transition });
recordSessionLifecycle(transition.from, to, "ok");
return transition;
}
/** Current state of `sessionId`. Returns `null` if unknown. */
public getState(sessionId: string): SessionState | null {
return this.entries.get(sessionId)?.state ?? null;
}
/** Full entry. */
public getEntry(sessionId: string): SessionLifecycleEntry | null {
const e = this.entries.get(sessionId);
return e === undefined ? null : { ...e };
}
/** All entries currently tracked. */
public getAllEntries(): ReadonlyArray<SessionLifecycleEntry> {
return [...this.entries.values()].map((e) => ({ ...e }));
}
/** Whether `to` is a legal next state for `sessionId`. */
public canTransition(sessionId: string, to: SessionState): boolean {
const entry = this.entries.get(sessionId);
if (entry === undefined) return false;
return ALLOWED[entry.state].includes(to);
}
/** Full audit history (defensive copy). */
public getHistory(): ReadonlyArray<SessionTransition> {
return [...this.history];
}
/** Per-session audit history (defensive copy). */
public getHistoryFor(sessionId: string): ReadonlyArray<SessionTransition> {
return this.history.filter((t) => t.sessionId === sessionId);
}
/** Drop a session entry. The audit history is preserved. */
public forget(sessionId: string): void {
this.entries.delete(sessionId);
}
}

View File

@@ -0,0 +1,35 @@
/**
* Public types for {@link SessionLifecycle}.
*/
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
/** Closed FSM state vocabulary. */
export type SessionState = "lobby" | "active" | "ended";
/** A single transition record in the audit history. */
export interface SessionTransition {
readonly sessionId: string;
readonly from: SessionState;
readonly to: SessionState;
readonly at: number;
readonly reason?: string;
}
/** Event topics emitted by `SessionLifecycle`. */
export type SessionLifecycleEventMap = {
"session:transitioned": SessionTransition;
};
/** Dependencies required to construct a `SessionLifecycle`. */
export interface SessionLifecycleDeps {
readonly clock: Clock;
readonly logger: LoggerLike;
readonly events: EventBusLike<SessionLifecycleEventMap>;
}
/** Internal entry tracked per session. */
export interface SessionLifecycleEntry {
readonly sessionId: string;
readonly state: SessionState;
readonly enteredStateAt: number;
}

View File

@@ -0,0 +1,2 @@
export { SessionGraphNode } from "./session-node.js";
export type { SerializedSessionNode } from "./session-node.types.js";

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,203 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { SessionMetadata } from "../../core/types/index.js";
import { SessionGraphNode } from "./session-node.js";
function makeClock(start = 1_000): {
read: () => number;
advance: (ms: number) => void;
} {
let t = start;
return {
read: () => t,
advance: (ms: number) => {
t += ms;
},
};
}
const meta = (createdAt = 0, updatedAt = 0): SessionMetadata => ({
createdAt,
updatedAt,
});
describe("SessionGraphNode", () => {
it("constructs with clock-supplied createdAt/updatedAt when no timestamps passed", {
timeout: 5000,
}, () => {
const clock = makeClock(42);
const n = new SessionGraphNode("a", "venue", meta(), clock.read);
expect(n.createdAt).toBe(42);
expect(n.updatedAt).toBe(42);
expect(n.parent).toBeNull();
expect(n.children).toEqual([]);
});
it("preserves timestamps when explicitly passed (restore path)", {
timeout: 5000,
}, () => {
const clock = makeClock(9_999);
const n = new SessionGraphNode("a", "venue", meta(), clock.read, null, {
createdAt: 100,
updatedAt: 200,
});
expect(n.createdAt).toBe(100);
expect(n.updatedAt).toBe(200);
});
it("addChild attaches the child and sets bidirectional links", {
timeout: 5000,
}, () => {
const clock = makeClock();
clock.advance(0);
const root = new SessionGraphNode("r", "venue", meta(), clock.read);
const child = new SessionGraphNode("c", "table", meta(), clock.read);
clock.advance(10);
root.addChild(child);
expect(root.children).toEqual([child]);
expect(child.parent).toBe(root);
expect(root.updatedAt).toBe(1_010);
});
it("addChild is idempotent — re-adding the same child is a no-op", {
timeout: 5000,
}, () => {
const clock = makeClock();
const root = new SessionGraphNode("r", "venue", meta(), clock.read);
const child = new SessionGraphNode("c", "table", meta(), clock.read);
root.addChild(child);
root.addChild(child);
expect(root.children.length).toBe(1);
});
it("removeChild detaches and returns true; returns false for non-children", {
timeout: 5000,
}, () => {
const clock = makeClock();
const root = new SessionGraphNode("r", "venue", meta(), clock.read);
const child = new SessionGraphNode("c", "table", meta(), clock.read);
const stranger = new SessionGraphNode("x", "table", meta(), clock.read);
root.addChild(child);
expect(root.removeChild(child)).toBe(true);
expect(child.parent).toBeNull();
expect(root.children).toEqual([]);
expect(root.removeChild(stranger)).toBe(false);
});
it("updateMetadata shallow-merges patch and bumps updatedAt", {
timeout: 5000,
}, () => {
const clock = makeClock();
const n = new SessionGraphNode("a", "venue", meta(0, 0), clock.read);
clock.advance(50);
n.updateMetadata({ foo: "bar" });
expect(n.metadata.foo).toBe("bar");
expect(n.metadata.createdAt).toBe(0);
expect(n.metadata.updatedAt).toBe(1_050);
expect(n.updatedAt).toBe(1_050);
});
it("updateMetadata honours an explicit updatedAt in the patch", {
timeout: 5000,
}, () => {
const clock = makeClock();
const n = new SessionGraphNode("a", "venue", meta(0, 0), clock.read);
n.updateMetadata({ updatedAt: 777 });
expect(n.metadata.updatedAt).toBe(777);
});
it("getDepth returns 0 for root, distance otherwise", {
timeout: 5000,
}, () => {
const clock = makeClock();
const a = new SessionGraphNode("a", "venue", meta(), clock.read);
const b = new SessionGraphNode("b", "table", meta(), clock.read);
const c = new SessionGraphNode("c", "game", meta(), clock.read);
a.addChild(b);
b.addChild(c);
expect(a.getDepth()).toBe(0);
expect(b.getDepth()).toBe(1);
expect(c.getDepth()).toBe(2);
});
it("getDescendants returns DFS pre-order array", { timeout: 5000 }, () => {
const clock = makeClock();
const root = new SessionGraphNode("r", "venue", meta(), clock.read);
const a = new SessionGraphNode("a", "table", meta(), clock.read);
const b = new SessionGraphNode("b", "table", meta(), clock.read);
const a1 = new SessionGraphNode("a1", "game", meta(), clock.read);
root.addChild(a);
root.addChild(b);
a.addChild(a1);
expect(root.getDescendants().map((n) => n.id)).toEqual([
"a",
"a1",
"b",
]);
});
it("getAncestors walks parent chain to root", { timeout: 5000 }, () => {
const clock = makeClock();
const a = new SessionGraphNode("a", "venue", meta(), clock.read);
const b = new SessionGraphNode("b", "table", meta(), clock.read);
const c = new SessionGraphNode("c", "game", meta(), clock.read);
a.addChild(b);
b.addChild(c);
expect(c.getAncestors().map((n) => n.id)).toEqual(["b", "a"]);
expect(a.getAncestors()).toEqual([]);
});
it("getRoot returns the top of the chain (self for a root)", {
timeout: 5000,
}, () => {
const clock = makeClock();
const root = new SessionGraphNode("r", "venue", meta(), clock.read);
const child = new SessionGraphNode("c", "table", meta(), clock.read);
root.addChild(child);
expect(child.getRoot()).toBe(root);
expect(root.getRoot()).toBe(root);
});
it("isAncestorOf / isDescendantOf are strict (exclude self)", {
timeout: 5000,
}, () => {
const clock = makeClock();
const a = new SessionGraphNode("a", "venue", meta(), clock.read);
const b = new SessionGraphNode("b", "table", meta(), clock.read);
const c = new SessionGraphNode("c", "game", meta(), clock.read);
const d = new SessionGraphNode("d", "game", meta(), clock.read);
a.addChild(b);
b.addChild(c);
expect(a.isAncestorOf(c)).toBe(true);
expect(c.isDescendantOf(a)).toBe(true);
expect(a.isAncestorOf(a)).toBe(false); // strict
expect(d.isAncestorOf(c)).toBe(false);
expect(c.isDescendantOf(d)).toBe(false);
});
it("toJSON serialises a node with null parentId for a root", {
timeout: 5000,
}, () => {
const clock = makeClock(2_000);
const n = new SessionGraphNode("r", "venue", meta(1, 1), clock.read);
const json = n.toJSON();
expect(json).toEqual({
id: "r",
type: "venue",
parentId: null,
metadata: { createdAt: 1, updatedAt: 1 },
createdAt: 2_000,
updatedAt: 2_000,
});
});
it("toJSON serialises a non-root with its parent id", {
timeout: 5000,
}, () => {
const clock = makeClock();
const root = new SessionGraphNode("r", "venue", meta(), clock.read);
const child = new SessionGraphNode("c", "table", meta(), clock.read);
root.addChild(child);
const json = child.toJSON();
expect(json.parentId).toBe("r");
});
});

View File

@@ -0,0 +1,170 @@
import type { Clock } from "@suaveplan/types";
import type {
SessionMetadata,
SessionNode,
SessionType,
} from "../../core/types/index.js";
import type { SerializedSessionNode } from "./session-node.types.js";
/**
* Internal mutable graph node backing `SessionTree` and `SessionStore`.
*
* `SessionGraphNode` is the in-memory representation of a session in the
* hierarchical tree. It implements Wave A's read-only `SessionNode`
* interface — callers consuming the public manager surface receive nodes
* typed as `SessionNode` (read-only). Internally the manager mutates the
* `parent`/`children` slots via the methods on this class.
*
* All wall-clock reads route through the injected `Clock` — the node never
* calls `Date.now()` directly, which makes the entire tree deterministic
* under `FakeClock`.
*/
export class SessionGraphNode implements SessionNode {
public readonly id: string;
public readonly type: SessionType;
public parent: SessionGraphNode | null;
public readonly children: SessionGraphNode[];
public metadata: SessionMetadata;
private readonly clock: Clock;
private _createdAt: number;
private _updatedAt: number;
public constructor(
id: string,
type: SessionType,
metadata: SessionMetadata,
clock: Clock,
parent: SessionGraphNode | null = null,
timestamps?: { readonly createdAt: number; readonly updatedAt: number }
) {
this.id = id;
this.type = type;
this.metadata = { ...metadata };
this.parent = parent;
this.children = [];
this.clock = clock;
const now = timestamps?.createdAt ?? clock();
this._createdAt = now;
this._updatedAt = timestamps?.updatedAt ?? now;
}
/** Add a child to this node, setting bidirectional parent linkage. */
public addChild(child: SessionGraphNode): void {
if (this.children.includes(child)) {
return;
}
this.children.push(child);
child.parent = this;
this._updatedAt = this.clock();
}
/**
* Remove a child from this node and clear its parent pointer. Returns
* `true` if the child was attached, `false` otherwise.
*/
public removeChild(child: SessionGraphNode): boolean {
const idx = this.children.indexOf(child);
if (idx === -1) {
return false;
}
this.children.splice(idx, 1);
child.parent = null;
this._updatedAt = this.clock();
return true;
}
/** Patch metadata (shallow merge) and bump `updatedAt`. */
public updateMetadata(patch: Partial<SessionMetadata>): void {
const now = this.clock();
this.metadata = {
...this.metadata,
...patch,
updatedAt: patch.updatedAt ?? now,
};
this._updatedAt = now;
}
/** Depth in the tree (0 for a root). */
public getDepth(): number {
let depth = 0;
let current: SessionGraphNode | null = this.parent;
while (current !== null) {
depth++;
current = current.parent;
}
return depth;
}
/** All descendants (depth-first pre-order, excluding `this`). */
public getDescendants(): SessionGraphNode[] {
const out: SessionGraphNode[] = [];
const walk = (node: SessionGraphNode): void => {
for (const child of node.children) {
out.push(child);
walk(child);
}
};
walk(this);
return out;
}
/** All ancestors from parent to root. */
public getAncestors(): SessionGraphNode[] {
const out: SessionGraphNode[] = [];
let current: SessionGraphNode | null = this.parent;
while (current !== null) {
out.push(current);
current = current.parent;
}
return out;
}
/** The root of the tree this node belongs to. */
public getRoot(): SessionGraphNode {
let current: SessionGraphNode = this;
while (current.parent !== null) {
current = current.parent;
}
return current;
}
/** Whether this node is an ancestor of `other`. */
public isAncestorOf(other: SessionGraphNode): boolean {
let current = other.parent;
while (current !== null) {
if (current === this) {
return true;
}
current = current.parent;
}
return false;
}
/** Whether this node is a descendant of `other`. */
public isDescendantOf(other: SessionGraphNode): boolean {
return other.isAncestorOf(this);
}
/** Creation timestamp (ms since epoch, from injected `Clock`). */
public get createdAt(): number {
return this._createdAt;
}
/** Last-update timestamp. */
public get updatedAt(): number {
return this._updatedAt;
}
/** Serialise to a plain object for persistence. */
public toJSON(): SerializedSessionNode {
return {
id: this.id,
type: this.type,
parentId: this.parent?.id ?? null,
metadata: this.metadata,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
};
}
}

View File

@@ -0,0 +1,19 @@
/**
* Public types for {@link SessionGraphNode}.
*
* The node class implemented in `session-node.ts` is structurally compatible
* with Wave A's read-only `SessionNode` interface (from `core/types`). It is
* named `SessionGraphNode` to avoid shadowing the public interface symbol
* (per the Wave B-2 dispatch).
*/
import type { SessionMetadata, SessionType } from "../../core/types/index.js";
/** Serialised form of a graph node, used by `SessionStore` for persistence. */
export interface SerializedSessionNode {
readonly id: string;
readonly type: SessionType;
readonly parentId: string | null;
readonly metadata: SessionMetadata;
readonly createdAt: number;
readonly updatedAt: number;
}

View File

@@ -0,0 +1,7 @@
export { SessionStore } from "./session-store.js";
export type {
SessionPersistence,
SessionStoreDeps,
SessionStoreOptions,
SessionStoreOptionsInput,
} from "./session-store.types.js";

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,307 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import { runISessionManagerConformance } from "../../core/interfaces/i-session-manager/index.js";
import type { SessionMetadata } from "../../core/types/index.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import type { SerializedSessionNode } from "../session-node/index.js";
import type { SessionTreeEventMap } from "../session-tree/index.js";
import { SessionStore } from "./session-store.js";
import type { SessionPersistence } from "./session-store.types.js";
class FakeBus implements EventBusLike<SessionTreeEventMap> {
public emitted: { type: keyof SessionTreeEventMap; payload: unknown }[] =
[];
public on<_K extends keyof SessionTreeEventMap>(): () => void {
return () => {};
}
public emit<K extends keyof SessionTreeEventMap>(
type: K,
payload: SessionTreeEventMap[K]
): void {
this.emitted.push({ type, payload });
}
}
function nullLogger(): LoggerLike {
const noop = (): void => {};
return { debug: noop, info: noop, warn: noop, error: noop };
}
const meta = (createdAt = 0, updatedAt = 0): SessionMetadata => ({
createdAt,
updatedAt,
});
function makeStore(overrides?: {
persistence?: SessionPersistence;
storageKey?: string;
}): { store: SessionStore; bus: FakeBus } {
const bus = new FakeBus();
const store = new SessionStore(
{
clock: () => 1_000,
logger: nullLogger(),
events: bus,
persistence: overrides?.persistence,
storageKey: overrides?.storageKey,
},
overrides?.storageKey ? { storageKey: overrides.storageKey } : {}
);
return { store, bus };
}
describe("SessionStore", () => {
it("ISessionManager conformance harness passes", {
timeout: 5000,
}, async () => {
const factory = () => makeStore().store;
const results = await runISessionManagerConformance(factory);
for (const r of results) {
expect(r.passed, r.scenario).toBe(true);
}
expect(results.length).toBeGreaterThan(0);
});
it("rejects invalid options with INVALID_SESSION_STORE_OPTIONS", {
timeout: 5000,
}, () => {
try {
new SessionStore(
{ clock: () => 0, logger: nullLogger(), events: new FakeBus() },
{ storageKey: "" }
);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GameServerError);
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_SESSION_STORE_OPTIONS
);
}
});
it("uses the default storageKey when none provided", {
timeout: 5000,
}, () => {
const { store } = makeStore();
expect(store.options.storageKey).toBe("game_server:sessions");
});
it("honours an explicit storageKey", { timeout: 5000 }, () => {
const { store } = makeStore({ storageKey: "custom:key" });
expect(store.options.storageKey).toBe("custom:key");
});
it("getStats forwards from the underlying tree", { timeout: 5000 }, () => {
const { store } = makeStore();
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
expect(store.getStats().totalSessions).toBe(1);
});
it("hasSession reflects presence", { timeout: 5000 }, () => {
const { store } = makeStore();
expect(store.hasSession("v")).toBe(false);
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
expect(store.hasSession("v")).toBe(true);
});
it("clear drops in-memory only (does NOT clear persistence)", {
timeout: 5000,
}, async () => {
const persisted = new Map<string, SerializedSessionNode[]>();
const adapter: SessionPersistence = {
async get(k) {
return persisted.get(k) ?? null;
},
async set(k, v) {
persisted.set(k, v);
},
async delete(k) {
persisted.delete(k);
},
};
const { store } = makeStore({ persistence: adapter });
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
await store.persist();
store.clear();
expect(store.getAllSessions()).toEqual([]);
expect(persisted.get("game_server:sessions")?.length).toBe(1);
});
it("clearPersistence with the default in-memory persistence works", {
timeout: 5000,
}, async () => {
const { store } = makeStore();
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
await store.persist();
await store.clearPersistence();
store.clear();
await store.restore();
expect(store.getAllSessions()).toEqual([]);
});
it("clearPersistence drops the persisted blob", {
timeout: 5000,
}, async () => {
const persisted = new Map<string, SerializedSessionNode[]>();
const adapter: SessionPersistence = {
async get(k) {
return persisted.get(k) ?? null;
},
async set(k, v) {
persisted.set(k, v);
},
async delete(k) {
persisted.delete(k);
},
};
const { store } = makeStore({ persistence: adapter });
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
await store.persist();
await store.clearPersistence();
expect(persisted.has("game_server:sessions")).toBe(false);
});
it("persist+restore round-trips with the in-memory default", {
timeout: 5000,
}, async () => {
const { store } = makeStore();
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
store.createSession("v", { id: "t", type: "table", metadata: meta() });
store.createSession("t", { id: "g", type: "game", metadata: meta() });
await store.persist();
const stats = store.getStats();
store.clear();
await store.restore();
const after = store.getStats();
expect(after.totalSessions).toBe(stats.totalSessions);
expect(store.getParent("g")?.id).toBe("t");
});
it("restore with empty payload is a no-op", { timeout: 5000 }, async () => {
const { store } = makeStore();
await store.restore();
expect(store.getAllSessions()).toEqual([]);
});
it("restore handles permuted record ordering via fixed-point walk", {
timeout: 5000,
}, async () => {
// Inject a payload whose children precede their parents.
const payload: SerializedSessionNode[] = [
{
id: "g",
type: "game",
parentId: "t",
metadata: meta(),
createdAt: 1,
updatedAt: 2,
},
{
id: "t",
type: "table",
parentId: "v",
metadata: meta(),
createdAt: 1,
updatedAt: 2,
},
{
id: "v",
type: "venue",
parentId: null,
metadata: meta(),
createdAt: 1,
updatedAt: 2,
},
];
const map = new Map<string, SerializedSessionNode[]>();
map.set("game_server:sessions", payload);
const adapter: SessionPersistence = {
async get(k) {
return map.get(k) ?? null;
},
async set(k, v) {
map.set(k, v);
},
async delete(k) {
map.delete(k);
},
};
const { store } = makeStore({ persistence: adapter });
await store.restore();
expect(store.getStats().totalSessions).toBe(3);
expect(store.getParent("g")?.id).toBe("t");
expect(store.getParent("t")?.id).toBe("v");
});
it("restore logs orphan records as warnings and skips them", {
timeout: 5000,
}, async () => {
const payload: SerializedSessionNode[] = [
{
id: "orphan",
type: "table",
parentId: "missing",
metadata: meta(),
createdAt: 1,
updatedAt: 2,
},
];
const map = new Map<string, SerializedSessionNode[]>();
map.set("game_server:sessions", payload);
const adapter: SessionPersistence = {
async get(k) {
return map.get(k) ?? null;
},
async set() {},
async delete() {},
};
const warnings: string[] = [];
const store = new SessionStore({
clock: () => 1_000,
logger: {
debug: () => {},
info: () => {},
warn: (msg: string) => warnings.push(msg),
error: () => {},
},
events: new FakeBus(),
persistence: adapter,
});
await store.restore();
expect(warnings.some((w) => w.includes("orphan"))).toBe(true);
expect(store.getAllSessions()).toEqual([]);
});
it("getSession returns null for unknown ids", { timeout: 5000 }, () => {
const { store } = makeStore();
expect(store.getSession("nope")).toBeNull();
});
it("destroySession cascades through descendants", { timeout: 5000 }, () => {
const { store } = makeStore();
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
store.createSession("v", { id: "t", type: "table", metadata: meta() });
store.destroySession("v");
expect(store.getAllSessions()).toEqual([]);
});
it("getSessionsByType / getChildren / getParent forward to tree", {
timeout: 5000,
}, () => {
const { store } = makeStore();
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
store.createSession("v", { id: "t", type: "table", metadata: meta() });
expect(store.getSessionsByType("table").map((n) => n.id)).toEqual([
"t",
]);
expect(store.getChildren("v").map((n) => n.id)).toEqual(["t"]);
expect(store.getParent("t")?.id).toBe("v");
});
it("updateMetadata forwards to tree", { timeout: 5000 }, () => {
const { store } = makeStore();
store.createSession(null, { id: "v", type: "venue", metadata: meta() });
store.updateMetadata("v", { region: "eu" });
expect(store.getSession("v")?.metadata.region).toBe("eu");
});
});

View File

@@ -0,0 +1,223 @@
import z from "zod";
import type { ISessionManager } from "../../core/interfaces/i-session-manager/index.js";
import type {
SessionData,
SessionMetadata,
SessionNode,
SessionType,
} from "../../core/types/index.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { SessionGraphNode } from "../session-node/index.js";
import { SessionTree } from "../session-tree/index.js";
import type {
SessionPersistence,
SessionStoreDeps,
SessionStoreOptions,
SessionStoreOptionsInput,
} from "./session-store.types.js";
const SessionStoreOptionsSchema = z
.object({
storageKey: z.string().min(1).optional(),
})
.strict();
function resolveOptions(input: SessionStoreOptionsInput): SessionStoreOptions {
const parsed = SessionStoreOptionsSchema.safeParse(input);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_SESSION_STORE_OPTIONS,
"SessionStoreOptions failed schema validation",
{ cause: parsed.error, context: { input } }
);
}
return {
storageKey: parsed.data.storageKey ?? "game_server:sessions",
};
}
/**
* Default in-memory persistence — used when callers don't inject one.
*
* Keeps a single payload per key in a `Map`. The map is module-private so
* each instantiation gets a fresh store (no cross-test pollution).
*/
class InMemoryPersistence implements SessionPersistence {
private readonly store: Map<
string,
ReturnType<SessionGraphNode["toJSON"]>[]
> = new Map();
public async get(
key: string
): Promise<ReturnType<SessionGraphNode["toJSON"]>[] | null> {
return this.store.get(key) ?? null;
}
public async set(
key: string,
payload: ReturnType<SessionGraphNode["toJSON"]>[]
): Promise<void> {
this.store.set(key, payload);
}
public async delete(key: string): Promise<void> {
this.store.delete(key);
}
}
/**
* `ISessionManager` implementation that composes `SessionTree` (the in-
* memory graph) with an injectable `SessionPersistence` adapter for
* durability. Callers receive the read-only `SessionNode` shape — the
* mutable `SessionGraphNode` is internal to the engine package.
*/
export class SessionStore implements ISessionManager {
public readonly options: SessionStoreOptions;
private readonly tree: SessionTree;
private readonly persistence: SessionPersistence;
private readonly deps: SessionStoreDeps;
public constructor(
deps: SessionStoreDeps,
options: SessionStoreOptionsInput = {}
) {
this.deps = deps;
this.options = Object.freeze(resolveOptions(options));
this.tree = new SessionTree({
clock: deps.clock,
logger: deps.logger,
events: deps.events,
});
this.persistence = deps.persistence ?? new InMemoryPersistence();
}
public createSession(
parentId: string | null,
sessionData: SessionData
): SessionNode {
return this.tree.createSession(parentId, sessionData);
}
public getSession(sessionId: string): SessionNode | null {
return this.tree.getSession(sessionId);
}
public destroySession(sessionId: string): void {
this.tree.destroySession(sessionId);
}
public getSessionsByType(type: SessionType): ReadonlyArray<SessionNode> {
return this.tree.getSessionsByType(type);
}
public getChildren(sessionId: string): ReadonlyArray<SessionNode> {
return this.tree.getChildren(sessionId);
}
public getParent(sessionId: string): SessionNode | null {
return this.tree.getParent(sessionId);
}
public updateMetadata(
sessionId: string,
metadata: Partial<SessionMetadata>
): void {
this.tree.updateMetadata(sessionId, metadata);
}
public getAllSessions(): ReadonlyArray<SessionNode> {
return this.tree.getAllSessions();
}
/** Snapshot of internal tree statistics. */
public getStats(): ReturnType<SessionTree["getStats"]> {
return this.tree.getStats();
}
/** Whether `sessionId` is known. */
public hasSession(sessionId: string): boolean {
return this.tree.hasSession(sessionId);
}
/** Drop every session from memory (does NOT clear persistence). */
public clear(): void {
this.tree.clear();
}
/** Drop the persisted blob. */
public async clearPersistence(): Promise<void> {
await this.persistence.delete(this.options.storageKey);
this.deps.logger.info("session store persistence cleared", {
key: this.options.storageKey,
});
}
/** Persist the current tree to the injected store. */
public async persist(): Promise<void> {
const snapshot = this.tree.getAllSessions().map((s) => s.toJSON());
await this.persistence.set(this.options.storageKey, snapshot);
this.deps.logger.info("session store persisted", {
count: snapshot.length,
key: this.options.storageKey,
});
}
/**
* Restore from the injected store, replacing the in-memory tree.
*
* Reconstructs the parent/children relationships in two passes:
* 1. instantiate every `SessionGraphNode` (without parent linkage)
* 2. walk the records and `attachRestored` in parent-before-child
* order
*/
public async restore(): Promise<void> {
const payload = await this.persistence.get(this.options.storageKey);
if (payload === null || payload.length === 0) {
this.deps.logger.info("session store: nothing to restore", {
key: this.options.storageKey,
});
return;
}
const byId = new Map<string, SessionGraphNode>();
for (const record of payload) {
const node = new SessionGraphNode(
record.id,
record.type,
record.metadata,
this.deps.clock,
null,
{ createdAt: record.createdAt, updatedAt: record.updatedAt }
);
byId.set(record.id, node);
}
this.tree.clear();
// Two-pass: roots first, then any record whose parent is already
// attached. Repeat until everything is in place. This handles
// arbitrary record orderings in the payload.
const remaining = new Map(payload.map((r) => [r.id, r] as const));
let progress = true;
while (progress && remaining.size > 0) {
progress = false;
for (const [id, record] of [...remaining]) {
if (
record.parentId === null ||
this.tree.hasSession(record.parentId)
) {
const node = byId.get(id) as SessionGraphNode;
this.tree.attachRestored(node, record.parentId);
remaining.delete(id);
progress = true;
}
}
}
if (remaining.size > 0) {
const orphanIds = [...remaining.keys()];
this.deps.logger.warn(
"session store: orphan records skipped during restore",
{ orphanIds }
);
}
this.deps.logger.info("session store restored", {
count: this.tree.getSessionCount(),
key: this.options.storageKey,
});
}
}

View File

@@ -0,0 +1,35 @@
/**
* Public types for {@link SessionStore}.
*/
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { SerializedSessionNode } from "../session-node/index.js";
import type { SessionTreeEventMap } from "../session-tree/index.js";
/**
* Persistence adapter for `SessionStore`. Implementations get/set/clear an
* opaque payload keyed by `key` — the store owns the serialisation format.
*/
export interface SessionPersistence {
get(key: string): Promise<SerializedSessionNode[] | null>;
set(key: string, payload: SerializedSessionNode[]): Promise<void>;
delete(key: string): Promise<void>;
}
/** Dependencies required to construct a `SessionStore`. */
export interface SessionStoreDeps {
readonly clock: Clock;
readonly logger: LoggerLike;
readonly events: EventBusLike<SessionTreeEventMap>;
readonly persistence?: SessionPersistence;
readonly storageKey?: string;
}
/** Validated, frozen `SessionStore` options. */
export interface SessionStoreOptions {
readonly storageKey: string;
}
/** Caller-supplied subset — everything optional. */
export interface SessionStoreOptionsInput {
readonly storageKey?: string;
}

View File

@@ -0,0 +1,7 @@
export { SessionTree } from "./session-tree.js";
export type {
SessionTreeDeps,
SessionTreeEventMap,
SessionTreeIntegrityReport,
SessionTreeStats,
} from "./session-tree.types.js";

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,495 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type { SessionMetadata } from "../../core/types/index.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { SessionGraphNode } from "../session-node/index.js";
import { SessionTree } from "./session-tree.js";
import type { SessionTreeEventMap } from "./session-tree.types.js";
class FakeBus implements EventBusLike<SessionTreeEventMap> {
public emitted: { type: keyof SessionTreeEventMap; payload: unknown }[] =
[];
private readonly subs: Map<
keyof SessionTreeEventMap,
Set<(payload: unknown) => void | Promise<void>>
> = new Map();
public on<K extends keyof SessionTreeEventMap>(
type: K,
listener: (payload: SessionTreeEventMap[K]) => void | Promise<void>
): () => void {
if (!this.subs.has(type)) {
this.subs.set(type, new Set());
}
const set = this.subs.get(type) as Set<
(payload: unknown) => void | Promise<void>
>;
set.add(listener as (payload: unknown) => void | Promise<void>);
return () =>
set.delete(listener as (p: unknown) => void | Promise<void>);
}
public emit<K extends keyof SessionTreeEventMap>(
type: K,
payload: SessionTreeEventMap[K]
): void {
this.emitted.push({ type, payload });
const set = this.subs.get(type);
if (set !== undefined) {
for (const l of set) {
void l(payload);
}
}
}
}
function nullLogger(): {
log: LoggerLike;
events: { level: string; msg: string }[];
} {
const events: { level: string; msg: string }[] = [];
const make = (level: string) => (msg: string) =>
events.push({ level, msg });
return {
events,
log: {
debug: make("debug"),
info: make("info"),
warn: make("warn"),
error: make("error"),
},
};
}
const meta = (createdAt = 0, updatedAt = 0): SessionMetadata => ({
createdAt,
updatedAt,
});
function makeTree(): {
tree: SessionTree;
bus: FakeBus;
log: ReturnType<typeof nullLogger>;
} {
const bus = new FakeBus();
const log = nullLogger();
const tree = new SessionTree({
clock: () => 1_000,
logger: log.log,
events: bus,
});
return { tree, bus, log };
}
describe("SessionTree", () => {
it("createSession registers a root and emits session:created", {
timeout: 5000,
}, () => {
const { tree, bus } = makeTree();
const node = tree.createSession(null, {
id: "venue1",
type: "venue",
metadata: meta(),
});
expect(node.id).toBe("venue1");
expect(tree.getRoots().map((r) => r.id)).toEqual(["venue1"]);
expect(bus.emitted.some((e) => e.type === "session:created")).toBe(
true
);
});
it("createSession nests under a parent and tracks bidirectional links", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
const child = tree.createSession("v", {
id: "t",
type: "table",
metadata: meta(),
});
expect(child.parent?.id).toBe("v");
expect(tree.getChildren("v").map((c) => c.id)).toEqual(["t"]);
expect(tree.getParent("t")?.id).toBe("v");
});
it("createSession rejects invalid SessionData with INVALID_SESSION_DATA", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
try {
tree.createSession(null, {
id: "",
type: "venue",
metadata: meta(),
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GameServerError);
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.INVALID_SESSION_DATA
);
}
});
it("createSession rejects duplicate ids with DUPLICATE_SESSION_ID", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
try {
tree.createSession(null, {
id: "v",
type: "venue",
metadata: meta(),
});
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.DUPLICATE_SESSION_ID
);
}
});
it("createSession rejects unknown parents with PARENT_SESSION_NOT_FOUND", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
try {
tree.createSession("missing", {
id: "t",
type: "table",
metadata: meta(),
});
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PARENT_SESSION_NOT_FOUND
);
}
});
it("getSession / hasSession return null/false for unknown ids", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
expect(tree.getSession("nope")).toBeNull();
expect(tree.hasSession("nope")).toBe(false);
});
it("destroySession cascades through descendants bottom-up", {
timeout: 5000,
}, () => {
const { tree, bus } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
tree.createSession("v", { id: "t", type: "table", metadata: meta() });
tree.createSession("t", { id: "g", type: "game", metadata: meta() });
bus.emitted = [];
tree.destroySession("v");
const destroyed = bus.emitted
.filter((e) => e.type === "session:destroyed")
.map((e) => (e.payload as { sessionId: string }).sessionId);
expect(destroyed).toEqual(["g", "t", "v"]);
expect(tree.getAllSessions()).toEqual([]);
});
it("destroySession on unknown id logs a warning and is a no-op", {
timeout: 5000,
}, () => {
const { tree, log, bus } = makeTree();
tree.destroySession("nope");
expect(log.events.some((e) => e.level === "warn")).toBe(true);
expect(bus.emitted).toEqual([]);
});
it("updateMetadata merges patch and emits both events", {
timeout: 5000,
}, () => {
const { tree, bus } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
bus.emitted = [];
tree.updateMetadata("v", { region: "eu" });
const node = tree.getSession("v");
expect(node?.metadata.region).toBe("eu");
expect(
bus.emitted.some((e) => e.type === "session:metadata:changed")
).toBe(true);
expect(bus.emitted.some((e) => e.type === "session:updated")).toBe(
true
);
});
it("updateMetadata on unknown id logs a warning and is a no-op", {
timeout: 5000,
}, () => {
const { tree, log } = makeTree();
tree.updateMetadata("nope", { region: "eu" });
expect(log.events.some((e) => e.level === "warn")).toBe(true);
});
it("getSessionsByType filters by closed vocabulary", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
tree.createSession("v", { id: "t1", type: "table", metadata: meta() });
tree.createSession("v", { id: "t2", type: "table", metadata: meta() });
expect(
tree
.getSessionsByType("table")
.map((n) => n.id)
.sort()
).toEqual(["t1", "t2"]);
expect(tree.getSessionsByType("queue")).toEqual([]);
});
it("getChildren/Parent/Descendants/Ancestors return [] for unknown ids", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
expect(tree.getChildren("nope")).toEqual([]);
expect(tree.getParent("nope")).toBeNull();
expect(tree.getDescendants("nope")).toEqual([]);
expect(tree.getAncestors("nope")).toEqual([]);
});
it("getDescendants / getAncestors walk the tree", { timeout: 5000 }, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
tree.createSession("v", { id: "t", type: "table", metadata: meta() });
tree.createSession("t", { id: "g", type: "game", metadata: meta() });
expect(tree.getDescendants("v").map((n) => n.id)).toEqual(["t", "g"]);
expect(tree.getAncestors("g").map((n) => n.id)).toEqual(["t", "v"]);
});
it("getStats aggregates totals, max depth, type distribution", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
tree.createSession("v", { id: "t", type: "table", metadata: meta() });
tree.createSession("t", { id: "g", type: "game", metadata: meta() });
const stats = tree.getStats();
expect(stats.totalSessions).toBe(3);
expect(stats.rootCount).toBe(1);
expect(stats.maxDepth).toBe(2);
expect(stats.typeDistribution).toEqual({ venue: 1, table: 1, game: 1 });
});
it("getSessionCount mirrors map size", { timeout: 5000 }, () => {
const { tree } = makeTree();
expect(tree.getSessionCount()).toBe(0);
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
expect(tree.getSessionCount()).toBe(1);
});
it("clear drops every session and root entry", { timeout: 5000 }, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
tree.createSession("v", { id: "t", type: "table", metadata: meta() });
tree.clear();
expect(tree.getAllSessions()).toEqual([]);
expect(tree.getRoots()).toEqual([]);
});
it("attachRestored attaches a pre-built node without firing session:created", {
timeout: 5000,
}, () => {
const { tree, bus } = makeTree();
const node = new SessionGraphNode(
"v",
"venue",
meta(),
() => 1_000,
null,
{ createdAt: 50, updatedAt: 60 }
);
tree.attachRestored(node, null);
expect(tree.hasSession("v")).toBe(true);
expect(node.createdAt).toBe(50);
expect(bus.emitted.some((e) => e.type === "session:created")).toBe(
false
);
});
it("attachRestored with a parent inserts under the existing parent", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
const root = new SessionGraphNode("r", "venue", meta(), () => 1_000);
tree.attachRestored(root, null);
const child = new SessionGraphNode("c", "table", meta(), () => 1_000);
tree.attachRestored(child, "r");
expect(tree.getChildren("r").map((c) => c.id)).toEqual(["c"]);
});
it("attachRestored rejects duplicate ids", { timeout: 5000 }, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
const dup = new SessionGraphNode("v", "venue", meta(), () => 1_000);
try {
tree.attachRestored(dup, null);
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.DUPLICATE_SESSION_ID
);
}
});
it("attachRestored rejects unknown parent", { timeout: 5000 }, () => {
const { tree } = makeTree();
const orphan = new SessionGraphNode("c", "table", meta(), () => 1_000);
try {
tree.attachRestored(orphan, "nope");
expect.fail("expected throw");
} catch (err) {
expect((err as GameServerError).code).toBe(
GAME_SERVER_CODES.PARENT_SESSION_NOT_FOUND
);
}
});
it("validateIntegrity returns valid on a healthy tree", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
tree.createSession("v", { id: "t", type: "table", metadata: meta() });
const report = tree.validateIntegrity();
expect(report.valid).toBe(true);
expect(report.errors).toEqual([]);
});
it("validateIntegrity flags a tampered parent pointer", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
const v = tree.createSession(null, {
id: "v",
type: "venue",
metadata: meta(),
});
const t = tree.createSession("v", {
id: "t",
type: "table",
metadata: meta(),
});
// Force-corrupt the bidirectional invariant.
const ghost = new SessionGraphNode(
"ghost",
"table",
meta(),
() => 1_000
);
t.parent = ghost;
v.children[0] = t; // ensure still referenced
const report = tree.validateIntegrity();
expect(report.valid).toBe(false);
expect(report.errors.length).toBeGreaterThan(0);
});
it("validateIntegrity flags an untracked root", { timeout: 5000 }, () => {
const { tree } = makeTree();
const v = tree.createSession(null, {
id: "v",
type: "venue",
metadata: meta(),
});
// Remove from the roots set without removing from sessions.
// We have to access the private roots set via cast — the test is
// the only legitimate consumer of this surface.
(tree as unknown as { roots: Set<string> }).roots.delete(v.id);
const report = tree.validateIntegrity();
expect(report.valid).toBe(false);
expect(report.errors.some((e) => e.includes("not tracked"))).toBe(true);
});
it("validateIntegrity flags a missing root entry in the sessions map", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
(tree as unknown as { roots: Set<string> }).roots.add("phantom");
const report = tree.validateIntegrity();
expect(report.valid).toBe(false);
expect(report.errors.some((e) => e.includes("phantom"))).toBe(true);
});
it("getRoots skips phantom root entries with no node in the map", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
(tree as unknown as { roots: Set<string> }).roots.add("phantom");
const roots = tree.getRoots();
expect(roots.map((r) => r.id)).toEqual(["v"]);
});
it("validateIntegrity flags a root tracked entry whose node has a parent", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
tree.createSession(null, { id: "v", type: "venue", metadata: meta() });
const t = tree.createSession("v", {
id: "t",
type: "table",
metadata: meta(),
});
// Treat t as a root improperly.
(tree as unknown as { roots: Set<string> }).roots.add(t.id);
const report = tree.validateIntegrity();
expect(report.valid).toBe(false);
expect(report.errors.some((e) => e.includes("has a parent"))).toBe(
true
);
});
it("validateIntegrity flags a child whose parent pointer no longer matches", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
const v = tree.createSession(null, {
id: "v",
type: "venue",
metadata: meta(),
});
const t = tree.createSession("v", {
id: "t",
type: "table",
metadata: meta(),
});
// Keep t in v.children but null its parent pointer (corrupted invariant).
t.parent = null;
// Also remove v from roots tracking would mask; ensure v stays a root.
expect(v.children[0]?.id).toBe("t");
const report = tree.validateIntegrity();
expect(report.valid).toBe(false);
expect(
report.errors.some((e) =>
e.includes("parent pointer does not match")
)
).toBe(true);
});
it("validateIntegrity flags an unregistered child reference", {
timeout: 5000,
}, () => {
const { tree } = makeTree();
const v = tree.createSession(null, {
id: "v",
type: "venue",
metadata: meta(),
});
const ghost = new SessionGraphNode(
"ghost",
"table",
meta(),
() => 1_000
);
ghost.parent = v;
v.children.push(ghost);
const report = tree.validateIntegrity();
expect(report.valid).toBe(false);
expect(
report.errors.some((e) => e.includes("unregistered child"))
).toBe(true);
});
});

View File

@@ -0,0 +1,303 @@
import z from "zod";
import type {
SessionData,
SessionMetadata,
SessionType,
} from "../../core/types/index.js";
import { GAME_SERVER_CODES, GameServerError } from "../../errors/index.js";
import { SessionGraphNode } from "../session-node/index.js";
import type {
SessionTreeDeps,
SessionTreeIntegrityReport,
SessionTreeStats,
} from "./session-tree.types.js";
const SESSION_TYPES = [
"venue",
"table",
"queue",
"game",
"player",
"spectator",
] as const;
const SessionDataSchema = z
.object({
id: z.string().min(1),
type: z.enum(SESSION_TYPES),
metadata: z
.object({
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(),
})
.passthrough(),
})
.strict();
/** Hierarchical session graph with O(1) lookups. See `session-tree.md`. */
export class SessionTree {
private readonly sessions: Map<string, SessionGraphNode> = new Map();
private readonly roots: Set<string> = new Set();
private readonly deps: SessionTreeDeps;
public constructor(deps: SessionTreeDeps) {
this.deps = deps;
}
/** Create and register a new session. Validates Zod boundary. */
public createSession(
parentId: string | null,
sessionData: SessionData
): SessionGraphNode {
const parsed = SessionDataSchema.safeParse(sessionData);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_SESSION_DATA,
"SessionData failed schema validation",
{ cause: parsed.error, context: { sessionData } }
);
}
const data = parsed.data;
if (this.sessions.has(data.id)) {
throw new GameServerError(
GAME_SERVER_CODES.DUPLICATE_SESSION_ID,
`Session "${data.id}" already exists`,
{ context: { sessionId: data.id } }
);
}
let parent: SessionGraphNode | null = null;
if (parentId !== null) {
const found = this.sessions.get(parentId);
if (found === undefined) {
throw new GameServerError(
GAME_SERVER_CODES.PARENT_SESSION_NOT_FOUND,
`Parent session "${parentId}" not found`,
{ context: { parentId, sessionId: data.id } }
);
}
parent = found;
}
const node = new SessionGraphNode(
data.id,
data.type as SessionType,
data.metadata as SessionMetadata,
this.deps.clock,
parent
);
if (parent !== null) {
parent.addChild(node);
this.deps.logger.debug("session created", {
sessionId: data.id,
parentId,
type: data.type,
});
} else {
this.roots.add(data.id);
this.deps.logger.debug("root session created", {
sessionId: data.id,
type: data.type,
});
}
this.sessions.set(data.id, node);
this.deps.events.emit("session:created", { session: node });
return node;
}
/** O(1) lookup by id. */
public getSession(sessionId: string): SessionGraphNode | null {
return this.sessions.get(sessionId) ?? null;
}
/** Patch a session's metadata; no-op for unknown ids. */
public updateMetadata(
sessionId: string,
metadata: Partial<SessionMetadata>
): void {
const node = this.sessions.get(sessionId);
if (node === undefined) {
this.deps.logger.warn("updateMetadata on unknown session", {
sessionId,
});
return;
}
node.updateMetadata(metadata);
this.deps.events.emit("session:metadata:changed", {
sessionId,
metadata,
});
this.deps.events.emit("session:updated", { session: node });
}
/** Destroy session and descendants bottom-up. */
public destroySession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session === undefined) {
this.deps.logger.warn("destroySession on unknown session", {
sessionId,
});
return;
}
const descendants = session.getDescendants();
for (const descendant of [...descendants].reverse()) {
this.detach(descendant);
}
this.detach(session);
}
/** All sessions of a specific type. */
public getSessionsByType(type: SessionType): SessionGraphNode[] {
const out: SessionGraphNode[] = [];
for (const session of this.sessions.values()) {
if (session.type === type) out.push(session);
}
return out;
}
/** Immediate children. */
public getChildren(sessionId: string): SessionGraphNode[] {
const session = this.sessions.get(sessionId);
return session ? [...session.children] : [];
}
/** Parent. */
public getParent(sessionId: string): SessionGraphNode | null {
return this.sessions.get(sessionId)?.parent ?? null;
}
/** All descendants. */
public getDescendants(sessionId: string): SessionGraphNode[] {
return this.sessions.get(sessionId)?.getDescendants() ?? [];
}
/** All ancestors. */
public getAncestors(sessionId: string): SessionGraphNode[] {
return this.sessions.get(sessionId)?.getAncestors() ?? [];
}
/** Presence check. */
public hasSession(sessionId: string): boolean {
return this.sessions.has(sessionId);
}
/** All sessions. */
public getAllSessions(): SessionGraphNode[] {
return [...this.sessions.values()];
}
/** All roots. */
public getRoots(): SessionGraphNode[] {
const out: SessionGraphNode[] = [];
for (const id of this.roots) {
const node = this.sessions.get(id);
if (node !== undefined) out.push(node);
}
return out;
}
/** Number of sessions. */
public getSessionCount(): number {
return this.sessions.size;
}
/** Stats. */
public getStats(): SessionTreeStats {
let maxDepth = 0;
const dist: Record<string, number> = {};
for (const session of this.sessions.values()) {
const depth = session.getDepth();
if (depth > maxDepth) maxDepth = depth;
dist[session.type] = (dist[session.type] ?? 0) + 1;
}
return {
totalSessions: this.sessions.size,
rootCount: this.roots.size,
maxDepth,
typeDistribution: dist,
};
}
/** Drop every session. */
public clear(): void {
this.sessions.clear();
this.roots.clear();
}
/** Audit tree integrity for debug logs. */
public validateIntegrity(): SessionTreeIntegrityReport {
const errors: string[] = [];
for (const [id, session] of this.sessions) {
if (
session.parent !== null &&
!this.sessions.has(session.parent.id)
) {
errors.push(
`session ${id} has dangling parent ${session.parent.id}`
);
}
for (const child of session.children) {
if (!this.sessions.has(child.id)) {
errors.push(
`session ${id} has unregistered child ${child.id}`
);
}
if (child.parent !== session) {
errors.push(
`session ${child.id} parent pointer does not match ${id}`
);
}
}
if (session.parent === null && !this.roots.has(id)) {
errors.push(`root session ${id} not tracked in roots set`);
}
}
for (const rootId of this.roots) {
const root = this.sessions.get(rootId);
if (root === undefined) {
errors.push(`root ${rootId} missing from sessions map`);
} else if (root.parent !== null) {
errors.push(`root ${rootId} has a parent`);
}
}
return { valid: errors.length === 0, errors };
}
/** Re-attach a deserialised node (used by `SessionStore.restore`). */
public attachRestored(
node: SessionGraphNode,
parentId: string | null
): void {
if (this.sessions.has(node.id)) {
throw new GameServerError(
GAME_SERVER_CODES.DUPLICATE_SESSION_ID,
`Session "${node.id}" already exists`,
{ context: { sessionId: node.id } }
);
}
if (parentId !== null) {
const parent = this.sessions.get(parentId);
if (parent === undefined) {
throw new GameServerError(
GAME_SERVER_CODES.PARENT_SESSION_NOT_FOUND,
`Parent session "${parentId}" not found during restore`,
{ context: { parentId, sessionId: node.id } }
);
}
parent.addChild(node);
} else {
this.roots.add(node.id);
}
this.sessions.set(node.id, node);
}
private detach(session: SessionGraphNode): void {
const { id, parent } = session;
if (parent !== null) {
parent.removeChild(session);
} else {
this.roots.delete(id);
}
this.sessions.delete(id);
this.deps.events.emit("session:destroyed", { sessionId: id });
this.deps.logger.debug("session destroyed", { sessionId: id });
}
}

View File

@@ -0,0 +1,44 @@
/**
* Public types for {@link SessionTree}.
*/
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type {
SessionData,
SessionMetadata,
SessionNode,
} from "../../core/types/index.js";
/** Event topics emitted by `SessionTree` onto the injected EventBus. */
export type SessionTreeEventMap = {
"session:created": { readonly session: SessionNode };
"session:destroyed": { readonly sessionId: string };
"session:updated": { readonly session: SessionNode };
"session:metadata:changed": {
readonly sessionId: string;
readonly metadata: Partial<SessionMetadata>;
};
};
/** Dependencies required to construct a SessionTree. */
export interface SessionTreeDeps {
readonly clock: Clock;
readonly logger: LoggerLike;
readonly events: EventBusLike<SessionTreeEventMap>;
}
/** Snapshot of tree statistics. */
export interface SessionTreeStats {
readonly totalSessions: number;
readonly rootCount: number;
readonly maxDepth: number;
readonly typeDistribution: Readonly<Record<string, number>>;
}
/** Result of an integrity audit. */
export interface SessionTreeIntegrityReport {
readonly valid: boolean;
readonly errors: ReadonlyArray<string>;
}
/** Re-export of the session-creation payload, for ergonomic imports. */
export type { SessionData };

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,265 @@
import type {
AntiCheatStats,
MovementConfig,
PlayerViolationHistory,
Position,
PunishmentRecommendation,
PunishmentThresholds,
RateLimitConfig,
ValidationResult,
Violation,
ViolationSeverity,
ViolationType,
} from "./anti-cheat-validator.types.js";
export const DEFAULT_MOVEMENT: MovementConfig = {
maxSpeed: 10,
maxTeleportDistance: 50,
allowVertical: true,
maxVerticalSpeed: 20,
toleranceFactor: 1.2,
};
export const DEFAULT_PUNISHMENTS: PunishmentThresholds = {
warningsBeforeMinor: 3,
minorBeforeMajor: 5,
majorBeforeKick: 3,
kicksBeforeTempBan: 2,
tempBansBeforePermanent: 3,
tempBanDuration: 3_600_000,
violationWindowMs: 86_400_000,
};
export const DEFAULT_RATE_LIMITS: RateLimitConfig[] = [
{ maxActions: 60, windowMs: 1000, actionType: "all" },
];
export function distance(a: Position, b: Position): number {
return Math.sqrt(
(b.x - a.x) ** 2 + (b.y - a.y) ** 2 + ((b.z ?? 0) - (a.z ?? 0)) ** 2
);
}
export function recommend(
h: PlayerViolationHistory,
p: PunishmentThresholds
): PunishmentRecommendation {
const w = h.violationsBySeverity.warning ?? 0;
const m = h.violationsBySeverity.minor ?? 0;
const maj = h.violationsBySeverity.major ?? 0;
const c = h.violationsBySeverity.critical ?? 0;
if (c > 0 || h.banStatus === "temp_banned") return "permanent_ban";
if (maj >= p.majorBeforeKick) return "temp_ban";
if (m >= p.minorBeforeMajor) return "kick";
if (w >= p.warningsBeforeMinor) return "slow_down";
if (w > 0 || m > 0) return "warn";
return "none";
}
export function emptyHistory(playerId: string): PlayerViolationHistory {
return {
playerId,
totalViolations: 0,
violationsByType: {},
violationsBySeverity: {},
recentViolations: [],
isFlagged: false,
banStatus: "none",
};
}
export function initialStats(): AntiCheatStats {
return {
totalValidations: 0,
totalViolations: 0,
violationsByType: {},
playersTracked: 0,
playersFlagged: 0,
playersBanned: 0,
rateLimitRejections: 0,
movementViolations: 0,
actionValidationFailures: 0,
uptimeMs: 0,
};
}
export function recordViolation(
h: PlayerViolationHistory,
stats: AntiCheatStats,
v: Violation,
maxHistorySize: number
): void {
h.totalViolations++;
h.violationsByType[v.type] = (h.violationsByType[v.type] ?? 0) + 1;
h.violationsBySeverity[v.severity] =
(h.violationsBySeverity[v.severity] ?? 0) + 1;
h.recentViolations.push(v);
if (h.recentViolations.length > maxHistorySize) {
h.recentViolations.shift();
}
h.lastViolation = v.timestamp;
if (h.firstViolation === undefined) h.firstViolation = v.timestamp;
stats.totalViolations++;
stats.violationsByType[v.type] = (stats.violationsByType[v.type] ?? 0) + 1;
if (v.type === "speed_hack" || v.type === "teleport") {
stats.movementViolations++;
}
}
export function okResult(): ValidationResult {
return { valid: true, shouldWarn: false, recommendation: "none" };
}
export function buildViolation(
id: string,
playerId: string,
type: ViolationType,
severity: ViolationSeverity,
timestamp: number,
details: Record<string, unknown>
): Violation {
return {
id,
playerId,
type,
severity,
timestamp,
details,
autoFlagged: false,
};
}
/** Slide a rate-limit window forward by `now - windowMs` and push `now`. */
export function slideWindow(
win: number[],
now: number,
windowMs: number
): number[] {
const filtered = win.filter((t) => t > now - windowMs);
filtered.push(now);
return filtered;
}
/**
* Apply rolling-window rate limiting; mutates `windowsForPlayer` in place
* and returns the matching limit that was exceeded, or `null` for pass.
*/
export function evaluateRateLimit(
windowsForPlayer: Map<string, number[]>,
limits: ReadonlyArray<RateLimitConfig>,
actionType: string,
now: number
): { actionType: string; count: number; limit: number } | null {
const matching = limits.filter(
(l) => l.actionType === actionType || l.actionType === "all"
);
for (const limit of matching) {
const key = `${limit.actionType}:${limit.windowMs}`;
const win = slideWindow(
windowsForPlayer.get(key) ?? [],
now,
limit.windowMs
);
windowsForPlayer.set(key, win);
if (win.length > limit.maxActions) {
return {
actionType,
count: win.length,
limit: limit.maxActions,
};
}
}
return null;
}
/**
* Pure decision step for `validateMovement`. Given the prior tracked
* position and the candidate `MovementData`, returns the implied
* violation (if any) without mutating any state. Returns `null` when
* the movement is acceptable.
*/
export function classifyMovement(
last: { position: Position; timestamp: number } | undefined,
next: { position: Position; timestamp: number },
m: MovementConfig
): {
type: ViolationType;
severity: ViolationSeverity;
details: Record<string, unknown>;
} | null {
if (!last) return null;
const dt = (next.timestamp - last.timestamp) / 1000;
if (dt <= 0) {
return { type: "timing_anomaly", severity: "minor", details: { dt } };
}
const dist = distance(last.position, next.position);
if (dist > m.maxTeleportDistance) {
return {
type: "teleport",
severity: "major",
details: { distance: dist, maxAllowed: m.maxTeleportDistance },
};
}
if (dist / dt > m.maxSpeed * m.toleranceFactor) {
return {
type: "speed_hack",
severity: "major",
details: { speed: dist / dt, maxAllowed: m.maxSpeed },
};
}
if (m.allowVertical && next.position.z !== undefined) {
const vs = Math.abs((next.position.z - (last.position.z ?? 0)) / dt);
const maxV = (m.maxVerticalSpeed ?? 20) * m.toleranceFactor;
if (vs > maxV) {
return {
type: "speed_hack",
severity: "minor",
details: { verticalSpeed: vs, maxAllowed: maxV },
};
}
}
return null;
}
export interface FlagContext {
readonly playerId: string;
readonly type: ViolationType;
readonly severity: ViolationSeverity;
readonly details: Record<string, unknown>;
readonly history: PlayerViolationHistory;
readonly stats: AntiCheatStats;
readonly now: number;
readonly id: string;
readonly maxHistorySize: number;
readonly autoFlagThreshold: number;
readonly punishments: PunishmentThresholds;
}
/**
* Build, record, and resolve a violation; mutates history+stats in place,
* returns the violation + recommended punishment. Auto-flag handling is
* the caller's responsibility because it needs the listener registry.
*/
export function processFlag(ctx: FlagContext): {
violation: Violation;
recommendation: PunishmentRecommendation;
autoFlag: boolean;
} {
const v = buildViolation(
ctx.id,
ctx.playerId,
ctx.type,
ctx.severity,
ctx.now,
ctx.details
);
recordViolation(ctx.history, ctx.stats, v, ctx.maxHistorySize);
const recentCount = ctx.history.recentViolations.filter(
(e) => e.timestamp > ctx.now - 60_000
).length;
const autoFlag =
recentCount >= ctx.autoFlagThreshold && !ctx.history.isFlagged;
if (autoFlag) v.autoFlagged = true;
const rec = recommend(ctx.history, ctx.punishments);
return { violation: v, recommendation: rec, autoFlag };
}

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,556 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock, IdGenerator } from "@suaveplan/types";
import { GameServerError } from "../../../errors/index.js";
import { AntiCheatValidator } from "./anti-cheat-validator.js";
import type {
ActionData,
MovementData,
ValidationResult,
} from "./anti-cheat-validator.types.js";
function buildClock(initial = 1_000): {
clock: Clock;
advance: (ms: number) => void;
set: (t: number) => void;
} {
let now = initial;
return {
clock: () => now,
advance: (ms) => {
now += ms;
},
set: (t) => {
now = t;
},
};
}
function buildIds(): { idGenerator: IdGenerator; values: string[] } {
const values: string[] = [];
let i = 0;
return {
idGenerator: () => {
const id = `v-${i++}`;
values.push(id);
return id;
},
values,
};
}
const move = (overrides: Partial<MovementData> = {}): MovementData => ({
playerId: "p1",
position: { x: 0, y: 0 },
timestamp: 0,
...overrides,
});
describe("AntiCheatValidator", () => {
it("rejects invalid config", { timeout: 5000 }, () => {
expect(
() =>
new AntiCheatValidator({
maxHistorySize: -1,
} as never)
).toThrow(GameServerError);
});
it("first movement is always ok", { timeout: 5000 }, () => {
const v = new AntiCheatValidator();
const r = v.validateMovement(move({ timestamp: 100 }));
expect(r.valid).toBe(true);
});
it("flags timing anomaly when dt<=0", { timeout: 5000 }, () => {
const { clock } = buildClock();
const v = new AntiCheatValidator({}, { clock });
v.validateMovement(move({ timestamp: 1_000 }));
const r = v.validateMovement(move({ timestamp: 1_000 }));
expect(r.valid).toBe(false);
expect(r.violation?.type).toBe("timing_anomaly");
});
it("flags teleport above max distance", { timeout: 5000 }, () => {
const v = new AntiCheatValidator();
v.validateMovement(move({ timestamp: 0 }));
const r = v.validateMovement(
move({ timestamp: 1_000, position: { x: 100, y: 0 } })
);
expect(r.violation?.type).toBe("teleport");
expect(r.violation?.severity).toBe("major");
});
it("flags speed hack between non-teleport and over-speed", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator();
v.validateMovement(move({ timestamp: 0 }));
const r = v.validateMovement(
move({ timestamp: 1_000, position: { x: 40, y: 0 } })
);
expect(r.violation?.type).toBe("speed_hack");
expect(r.violation?.severity).toBe("major");
});
it("flags vertical speed hack when allowVertical", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator({
movement: {
maxSpeed: 100,
maxTeleportDistance: 1_000,
allowVertical: true,
maxVerticalSpeed: 5,
toleranceFactor: 1,
},
});
v.validateMovement(
move({ timestamp: 0, position: { x: 0, y: 0, z: 0 } })
);
const r = v.validateMovement(
move({ timestamp: 1_000, position: { x: 0, y: 0, z: 50 } })
);
expect(r.violation?.type).toBe("speed_hack");
expect(r.violation?.severity).toBe("minor");
});
it("accepts movement under speed", { timeout: 5000 }, () => {
const v = new AntiCheatValidator();
v.validateMovement(move({ timestamp: 0 }));
const r = v.validateMovement(
move({ timestamp: 1_000, position: { x: 5, y: 0 } })
);
expect(r.valid).toBe(true);
});
it("rate-limits exceeding actions", { timeout: 5000 }, async () => {
const v = new AntiCheatValidator({
rateLimits: [{ maxActions: 2, windowMs: 1_000, actionType: "all" }],
});
const a: ActionData = {
playerId: "p1",
actionType: "shoot",
payload: {},
timestamp: 0,
};
const r1 = await v.validateAction({ ...a, timestamp: 0 });
const r2 = await v.validateAction({ ...a, timestamp: 1 });
const r3 = await v.validateAction({ ...a, timestamp: 2 });
expect(r1.valid).toBe(true);
expect(r2.valid).toBe(true);
expect(r3.violation?.type).toBe("rate_limit");
});
it("custom action validator success and failure", {
timeout: 5000,
}, async () => {
const v = new AntiCheatValidator<
{ x: number; y: number },
{ dmg: number }
>();
let captured = false;
v.registerValidator("attack", (action) => {
captured = action.payload.dmg < 100;
return captured
? { valid: true, shouldWarn: false, recommendation: "none" }
: {
valid: false,
shouldWarn: false,
recommendation: "none",
reason: "too much damage",
};
});
const okR = await v.validateAction({
playerId: "p1",
actionType: "attack",
payload: { dmg: 10 },
timestamp: 0,
});
const badR = await v.validateAction({
playerId: "p1",
actionType: "attack",
payload: { dmg: 999 },
timestamp: 1,
});
expect(okR.valid).toBe(true);
expect(badR.valid).toBe(false);
expect(captured).toBe(false);
});
it("ignores unregistered action type", { timeout: 5000 }, async () => {
const v = new AntiCheatValidator();
const r = await v.validateAction({
playerId: "p1",
actionType: "unknown",
payload: {},
timestamp: 0,
});
expect(r.valid).toBe(true);
});
it("registerValidator rejects empty actionType", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator();
expect(() =>
v.registerValidator("", () => ({
valid: true,
shouldWarn: false,
recommendation: "none",
}))
).toThrow(GameServerError);
});
it("emits events and supports off()", { timeout: 5000 }, () => {
const v = new AntiCheatValidator();
const events: string[] = [];
const cb = (id: string) => events.push(`f:${id}`);
const unsub = v.on("player:flagged", cb);
v.flagPlayer("p1");
v.flagPlayer("p1"); // no-op
unsub();
v.flagPlayer("p2");
const cb2 = (id: string) => events.push(`u:${id}`);
const _u = v.on("player:unflagged", cb2);
v.off("player:unflagged", cb2);
v.unflagPlayer("p1");
v.unflagPlayer("p1"); // no-op
expect(events).toEqual(["f:p1"]);
});
it("auto-flag after threshold", { timeout: 5000 }, () => {
const { clock } = buildClock();
const v = new AntiCheatValidator(
{
rateLimits: [
{ maxActions: 0, windowMs: 1_000, actionType: "all" },
],
autoFlagThreshold: 2,
enableAutoPunishment: false,
},
{ clock, idGenerator: buildIds().idGenerator }
);
let flagged = 0;
v.on("player:flagged", () => flagged++);
v.checkRateLimit("p1", "a", 0);
v.checkRateLimit("p1", "a", 1);
expect(flagged).toBe(1);
});
it("temp-ban via auto-punishment then permanent escalation", {
timeout: 5000,
}, () => {
const { clock, advance } = buildClock();
const v = new AntiCheatValidator(
{
rateLimits: [
{ maxActions: 0, windowMs: 1_000, actionType: "all" },
],
punishments: {
warningsBeforeMinor: 1,
minorBeforeMajor: 1,
majorBeforeKick: 1,
kicksBeforeTempBan: 1,
tempBansBeforePermanent: 1,
tempBanDuration: 1_000,
violationWindowMs: 60_000,
},
autoFlagThreshold: 1_000,
},
{ clock }
);
v.validateMovement(move({ timestamp: 0 })); // seed
v.validateMovement(
move({ timestamp: 1_000, position: { x: 100, y: 0 } })
); // teleport=major → temp_ban
expect(v.isPlayerBanned("p1")).toBe(true);
// Triggering another violation while temp-banned escalates to perm.
v.validateMovement(
move({ timestamp: 2_000, position: { x: 200, y: 0 } })
);
expect(v.getPlayerHistory("p1").banStatus).toBe("permanent_banned");
// Lapse temp-ban: set up another player.
v.banPlayer("p2", false, "test");
advance(2_000);
expect(v.isPlayerBanned("p2")).toBe(false);
});
it("kick recommendation emits kicked event under auto-punishment", {
timeout: 5000,
}, () => {
const { clock } = buildClock();
const v = new AntiCheatValidator(
{
rateLimits: [
{ maxActions: 0, windowMs: 1_000, actionType: "all" },
],
punishments: {
warningsBeforeMinor: 1,
minorBeforeMajor: 1,
majorBeforeKick: 999,
kicksBeforeTempBan: 999,
tempBansBeforePermanent: 999,
tempBanDuration: 60_000,
violationWindowMs: 60_000,
},
autoFlagThreshold: 1_000,
},
{ clock }
);
// Need to accumulate minor violations to hit kick threshold.
const events: string[] = [];
v.on("player:kicked", (id) => events.push(id));
// Force minors via dt<=0 timing_anomaly path.
v.validateMovement(move({ timestamp: 100 }));
v.validateMovement(move({ timestamp: 100 })); // minor #1 — kick threshold met
expect(events).toEqual(["p1"]);
});
it("temp-ban event includes expiry", { timeout: 5000 }, () => {
const { clock } = buildClock(10_000);
const v = new AntiCheatValidator({}, { clock });
let captured = 0;
v.on("player:temp_banned", (_pid, expiry) => {
captured = expiry;
});
v.banPlayer("p1", false, "x");
expect(captured).toBeGreaterThan(10_000);
});
it("permanent-ban event fires", { timeout: 5000 }, () => {
const v = new AntiCheatValidator();
let fired = false;
v.on("player:permanent_banned", () => {
fired = true;
});
v.banPlayer("p1", true, "x");
expect(fired).toBe(true);
expect(v.isPlayerBanned("p1")).toBe(true);
});
it("unbanned when not present", { timeout: 5000 }, () => {
const v = new AntiCheatValidator();
expect(v.isPlayerBanned("ghost")).toBe(false);
});
it("expired temp-ban clears", { timeout: 5000 }, () => {
const { clock, advance } = buildClock(0);
const v = new AntiCheatValidator(
{
punishments: {
...{
warningsBeforeMinor: 1,
minorBeforeMajor: 1,
majorBeforeKick: 1,
kicksBeforeTempBan: 1,
tempBansBeforePermanent: 1,
tempBanDuration: 100,
violationWindowMs: 60_000,
},
},
},
{ clock }
);
v.banPlayer("p1", false, "x");
advance(50);
expect(v.isPlayerBanned("p1")).toBe(true);
advance(100);
expect(v.isPlayerBanned("p1")).toBe(false);
});
it("stats reflect validations and uptime", { timeout: 5000 }, () => {
const { clock, advance } = buildClock();
const v = new AntiCheatValidator({}, { clock });
v.validateMovement(move({ timestamp: 0 }));
advance(100);
const s = v.getStats();
expect(s.totalValidations).toBe(1);
expect(s.uptimeMs).toBe(100);
v.resetStats();
expect(v.getStats().totalValidations).toBe(0);
});
it("setTick and validation context expose state", {
timeout: 5000,
}, async () => {
const v = new AntiCheatValidator();
v.setTick(42);
let seenTick = 0;
let seenPos: { x: number } | undefined;
v.registerValidator("probe", (action, ctx) => {
seenTick = ctx.getCurrentTick();
seenPos = ctx.getPlayerPosition(action.playerId);
void ctx.getPlayerHistory(action.playerId);
return { valid: true, shouldWarn: false, recommendation: "none" };
});
v.validateMovement(move({ timestamp: 0, position: { x: 7, y: 0 } }));
await v.validateAction({
playerId: "p1",
actionType: "probe",
payload: {},
timestamp: 0,
});
expect(seenTick).toBe(42);
expect(seenPos?.x).toBe(7);
});
it("clearPlayer and dispose clean state", { timeout: 5000 }, () => {
const v = new AntiCheatValidator();
v.validateMovement(move({ timestamp: 0 }));
v.flagPlayer("p1");
v.clearPlayer("p1");
expect(v.getStats().playersFlagged).toBe(1);
v.dispose();
expect(v.getStats().playersTracked).toBe(0);
});
it("rate_limit:exceeded event fires", { timeout: 5000 }, () => {
const v = new AntiCheatValidator({
rateLimits: [{ maxActions: 0, windowMs: 1_000, actionType: "all" }],
});
const calls: string[] = [];
v.on("rate_limit:exceeded", (id, type) => calls.push(`${id}:${type}`));
v.checkRateLimit("p1", "a", 0);
expect(calls).toEqual(["p1:a"]);
});
it("historical violations cap at maxHistorySize", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator({
rateLimits: [{ maxActions: 0, windowMs: 1_000, actionType: "all" }],
maxHistorySize: 1,
autoFlagThreshold: 1_000,
});
v.checkRateLimit("p1", "a", 0);
v.checkRateLimit("p1", "a", 1);
const h = v.getPlayerHistory("p1");
expect(h.recentViolations.length).toBe(1);
});
it("flagged result conveys recommendation", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator({
rateLimits: [{ maxActions: 0, windowMs: 1_000, actionType: "all" }],
});
const r = v.checkRateLimit("p1", "a", 0);
expect((r as ValidationResult).shouldWarn).toBe(true);
});
it("emits slow_down recommendation at warningsBeforeMinor", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator({
rateLimits: [{ maxActions: 0, windowMs: 1_000, actionType: "all" }],
punishments: {
warningsBeforeMinor: 1,
minorBeforeMajor: 999,
majorBeforeKick: 999,
kicksBeforeTempBan: 999,
tempBansBeforePermanent: 999,
tempBanDuration: 60_000,
violationWindowMs: 60_000,
},
autoFlagThreshold: 1_000,
enableAutoPunishment: false,
});
const r = v.checkRateLimit("p1", "a", 0);
expect(r.recommendation).toBe("slow_down");
});
it("on() with existing listener set reuses set", { timeout: 5000 }, () => {
const v = new AntiCheatValidator();
const events: string[] = [];
v.on("player:flagged", () => events.push("a"));
v.on("player:flagged", () => events.push("b"));
v.flagPlayer("p1");
expect(events).toEqual(["a", "b"]);
});
it("vertical speed-hack with prior position lacking z", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator({
movement: {
maxSpeed: 100,
maxTeleportDistance: 1_000,
allowVertical: true,
maxVerticalSpeed: 5,
toleranceFactor: 1,
},
});
v.validateMovement(move({ timestamp: 0, position: { x: 0, y: 0 } }));
const r = v.validateMovement(
move({ timestamp: 1_000, position: { x: 0, y: 0, z: 50 } })
);
expect(r.violation?.type).toBe("speed_hack");
});
it("temp-banned with no expiry clears lazily on read", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator();
const h = v.getPlayerHistory("p1");
h.banStatus = "temp_banned";
// banExpiry intentionally undefined.
expect(v.isPlayerBanned("p1")).toBe(false);
expect(h.banStatus).toBe("none");
});
it("isPlayerBanned with temp-ban not yet expired returns true", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator();
v.banPlayer("p1", false, "x");
expect(v.isPlayerBanned("p1")).toBe(true);
});
it("isPlayerBanned returns false for tracked non-banned player", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator();
v.flagPlayer("p1"); // tracked but banStatus stays "none"
expect(v.isPlayerBanned("p1")).toBe(false);
});
it("vertical move within limit is accepted", { timeout: 5000 }, () => {
const v = new AntiCheatValidator({
movement: {
maxSpeed: 100,
maxTeleportDistance: 1_000,
allowVertical: true,
maxVerticalSpeed: 50,
toleranceFactor: 1,
},
});
v.validateMovement(
move({ timestamp: 0, position: { x: 0, y: 0, z: 0 } })
);
const r = v.validateMovement(
move({ timestamp: 1_000, position: { x: 0, y: 0, z: 10 } })
);
expect(r.valid).toBe(true);
});
it("movement config without maxVerticalSpeed uses fallback", {
timeout: 5000,
}, () => {
const v = new AntiCheatValidator({
movement: {
maxSpeed: 100,
maxTeleportDistance: 1_000,
allowVertical: true,
toleranceFactor: 1,
},
});
v.validateMovement(
move({ timestamp: 0, position: { x: 0, y: 0, z: 0 } })
);
// Default vertical limit is 20: a 30-unit drop in 1s exceeds.
const r = v.validateMovement(
move({ timestamp: 1_000, position: { x: 0, y: 0, z: 30 } })
);
expect(r.violation?.type).toBe("speed_hack");
});
});

View File

@@ -0,0 +1,370 @@
import type { Counter, Meter } from "@opentelemetry/api";
import { metrics as otelMetrics } from "@opentelemetry/api";
import type { Clock, IdGenerator } from "@suaveplan/types";
import { GAME_SERVER_CODES, GameServerError } from "../../../errors/index.js";
import {
classifyMovement,
DEFAULT_MOVEMENT,
DEFAULT_PUNISHMENTS,
DEFAULT_RATE_LIMITS,
emptyHistory,
evaluateRateLimit,
initialStats,
okResult,
processFlag,
} from "./anti-cheat-validator.internal.js";
import {
type ActionData,
type ActionValidator,
type AntiCheatConfig,
AntiCheatConfigSchema,
type AntiCheatEventMap,
type AntiCheatEventName,
type AntiCheatStats,
type MovementConfig,
type MovementData,
type PlayerViolationHistory,
type Position,
type PunishmentThresholds,
type RateLimitConfig,
type ValidationContext,
type ValidationResult,
type ViolationSeverity,
type ViolationType,
} from "./anti-cheat-validator.types.js";
export interface AntiCheatValidatorDeps {
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
readonly meter?: Meter;
}
interface ResolvedConfig {
rateLimits: RateLimitConfig[];
movement: MovementConfig;
punishments: PunishmentThresholds;
maxHistorySize: number;
autoFlagThreshold: number;
enableAutoPunishment: boolean;
}
export class AntiCheatValidator<
TMove extends Position = Position,
TAction = unknown,
> {
private readonly config: ResolvedConfig;
private readonly clock: Clock;
private readonly idGenerator: IdGenerator;
private readonly playerHistories = new Map<
string,
PlayerViolationHistory
>();
private readonly playerPositions = new Map<
string,
{ position: TMove; timestamp: number }
>();
private readonly rateLimitWindows = new Map<
string,
Map<string, number[]>
>();
private readonly actionValidators = new Map<
string,
ActionValidator<TAction>
>();
private readonly listeners = new Map<AntiCheatEventName, Set<unknown>>();
private readonly validationCounter: Counter;
private readonly violationCounter: Counter;
private stats: AntiCheatStats;
private startTime: number;
private currentTick = 0;
public constructor(
config: AntiCheatConfig = {},
deps: AntiCheatValidatorDeps = {}
) {
const parsed = AntiCheatConfigSchema.safeParse(config);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_ANTI_CHEAT_CONFIG,
"AntiCheatConfig failed validation",
{ context: { issues: parsed.error.issues } }
);
}
const c = parsed.data;
this.config = {
rateLimits: c.rateLimits ?? DEFAULT_RATE_LIMITS,
movement: c.movement ?? DEFAULT_MOVEMENT,
punishments: c.punishments ?? DEFAULT_PUNISHMENTS,
maxHistorySize: c.maxHistorySize ?? 100,
autoFlagThreshold: c.autoFlagThreshold ?? 10,
enableAutoPunishment: c.enableAutoPunishment ?? true,
};
this.clock = deps.clock ?? Date.now;
this.idGenerator =
deps.idGenerator ?? (() => globalThis.crypto.randomUUID());
const meter =
deps.meter ??
otelMetrics.getMeter("@suaveplan/game-server", "0.1.0");
this.validationCounter = meter.createCounter(
"game_server.anticheat.validation_total",
{ description: "Anti-cheat validations performed" }
);
this.violationCounter = meter.createCounter(
"game_server.anticheat.violation_total",
{ description: "Anti-cheat violations by type/severity" }
);
this.startTime = this.clock();
this.stats = initialStats();
}
public on<E extends AntiCheatEventName>(
event: E,
listener: AntiCheatEventMap[E]
): () => void {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(listener);
return () => this.off(event, listener);
}
public off<E extends AntiCheatEventName>(
event: E,
listener: AntiCheatEventMap[E]
): void {
this.listeners.get(event)?.delete(listener);
}
public registerValidator(
actionType: string,
validator: ActionValidator<TAction>
): void {
if (!actionType) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_VALIDATION_RULE,
"actionType must be a non-empty string"
);
}
this.actionValidators.set(actionType, validator);
}
public validateMovement(data: MovementData<TMove>): ValidationResult {
this.stats.totalValidations++;
this.validationCounter.add(1, { kind: "move" });
const { playerId, position, timestamp } = data;
const last = this.playerPositions.get(playerId);
const verdict = classifyMovement(
last,
{ position, timestamp },
this.config.movement
);
if (last === undefined || verdict?.type !== "timing_anomaly") {
this.playerPositions.set(playerId, { position, timestamp });
}
if (!verdict) return okResult();
return this.flag(
playerId,
verdict.type,
verdict.severity,
verdict.details
);
}
public async validateAction(
action: ActionData<TAction>
): Promise<ValidationResult> {
this.stats.totalValidations++;
this.validationCounter.add(1, { kind: "action" });
const rate = this.checkRateLimit(
action.playerId,
action.actionType,
action.timestamp
);
if (!rate.valid) return rate;
const validator = this.actionValidators.get(action.actionType);
if (validator) {
const result = await validator(action, this.context());
if (!result.valid) {
this.stats.actionValidationFailures++;
return result;
}
}
return okResult();
}
public checkRateLimit(
playerId: string,
actionType: string,
timestamp: number
): ValidationResult {
let windows = this.rateLimitWindows.get(playerId);
if (!windows) {
windows = new Map();
this.rateLimitWindows.set(playerId, windows);
}
const breach = evaluateRateLimit(
windows,
this.config.rateLimits,
actionType,
timestamp
);
if (!breach) return okResult();
this.stats.rateLimitRejections++;
this.emit("rate_limit:exceeded", playerId, actionType);
return this.flag(playerId, "rate_limit", "warning", breach);
}
public getPlayerHistory(playerId: string): PlayerViolationHistory {
let h = this.playerHistories.get(playerId);
if (!h) {
h = emptyHistory(playerId);
this.playerHistories.set(playerId, h);
this.stats.playersTracked++;
}
return h;
}
public flagPlayer(playerId: string): void {
const h = this.getPlayerHistory(playerId);
if (h.isFlagged) return;
h.isFlagged = true;
this.stats.playersFlagged++;
this.emit("player:flagged", playerId, h);
}
public unflagPlayer(playerId: string): void {
const h = this.getPlayerHistory(playerId);
if (!h.isFlagged) return;
h.isFlagged = false;
this.stats.playersFlagged = Math.max(0, this.stats.playersFlagged - 1);
this.emit("player:unflagged", playerId);
}
public banPlayer(
playerId: string,
permanent: boolean,
reason: string
): void {
void reason;
const h = this.getPlayerHistory(playerId);
if (permanent) {
h.banStatus = "permanent_banned";
this.emit("player:permanent_banned", playerId);
} else {
const expiry =
this.clock() + this.config.punishments.tempBanDuration;
h.banStatus = "temp_banned";
h.banExpiry = expiry;
this.emit("player:temp_banned", playerId, expiry);
}
this.stats.playersBanned++;
}
public isPlayerBanned(playerId: string): boolean {
const h = this.playerHistories.get(playerId);
if (!h) return false;
if (h.banStatus === "permanent_banned") return true;
if (h.banStatus === "temp_banned") {
const exp = h.banExpiry ?? 0;
if (this.clock() < exp) return true;
h.banStatus = "none";
delete h.banExpiry;
}
return false;
}
public setTick(tick: number): void {
this.currentTick = tick;
}
public getStats(): AntiCheatStats {
return {
...this.stats,
playersTracked: this.playerHistories.size,
uptimeMs: this.clock() - this.startTime,
};
}
public resetStats(): void {
this.stats = initialStats();
this.startTime = this.clock();
}
public clearPlayer(playerId: string): void {
this.playerHistories.delete(playerId);
this.playerPositions.delete(playerId);
this.rateLimitWindows.delete(playerId);
}
public dispose(): void {
this.playerHistories.clear();
this.playerPositions.clear();
this.rateLimitWindows.clear();
this.actionValidators.clear();
this.listeners.clear();
}
private flag(
playerId: string,
type: ViolationType,
severity: ViolationSeverity,
details: Record<string, unknown>
): ValidationResult {
const {
violation: v,
recommendation: rec,
autoFlag,
} = processFlag({
playerId,
type,
severity,
details,
history: this.getPlayerHistory(playerId),
stats: this.stats,
now: this.clock(),
id: this.idGenerator(),
maxHistorySize: this.config.maxHistorySize,
autoFlagThreshold: this.config.autoFlagThreshold,
punishments: this.config.punishments,
});
this.violationCounter.add(1, { type, severity });
this.emit("violation:detected", v);
if (autoFlag) this.flagPlayer(playerId);
if (this.config.enableAutoPunishment) {
if (rec === "kick")
this.emit("player:kicked", playerId, "auto: violations");
else if (rec === "temp_ban")
this.banPlayer(playerId, false, "auto temp ban");
else if (rec === "permanent_ban")
this.banPlayer(playerId, true, "auto permanent ban");
}
return {
valid: false,
violation: v,
reason: type,
shouldWarn: severity === "warning" || severity === "minor",
recommendation: rec,
};
}
private context(): ValidationContext {
return {
getPlayerPosition: (id) => this.playerPositions.get(id)?.position,
getPlayerHistory: (id) => this.getPlayerHistory(id),
getCurrentTick: () => this.currentTick,
};
}
private emit<E extends AntiCheatEventName>(
event: E,
...args: Parameters<AntiCheatEventMap[E]>
): void {
const set = this.listeners.get(event);
if (!set) return;
for (const fn of set) {
(fn as (...a: unknown[]) => void)(...args);
}
}
}

View File

@@ -0,0 +1,158 @@
import z from "zod";
export type ViolationSeverity = "warning" | "minor" | "major" | "critical";
export type ViolationType =
| "rate_limit"
| "speed_hack"
| "teleport"
| "impossible_action"
| "invalid_input"
| "timing_anomaly"
| "sequence_violation";
export type PunishmentRecommendation =
| "none"
| "warn"
| "slow_down"
| "kick"
| "temp_ban"
| "permanent_ban";
export interface Position {
readonly x: number;
readonly y: number;
readonly z?: number;
}
export interface MovementData<TPosition extends Position = Position> {
readonly playerId: string;
readonly position: TPosition;
readonly timestamp: number;
readonly tick?: number;
}
export interface ActionData<TAction = unknown> {
readonly playerId: string;
readonly actionType: string;
readonly payload: TAction;
readonly timestamp: number;
readonly tick?: number;
readonly targetId?: string;
readonly position?: Position;
}
export interface Violation {
readonly id: string;
readonly playerId: string;
readonly type: ViolationType;
readonly severity: ViolationSeverity;
readonly timestamp: number;
readonly details: Record<string, unknown>;
autoFlagged: boolean;
}
export interface ValidationResult {
readonly valid: boolean;
readonly violation?: Violation;
readonly reason?: string;
readonly shouldWarn: boolean;
readonly recommendation: PunishmentRecommendation;
}
export interface PlayerViolationHistory {
readonly playerId: string;
totalViolations: number;
violationsByType: Partial<Record<ViolationType, number>>;
violationsBySeverity: Partial<Record<ViolationSeverity, number>>;
recentViolations: Violation[];
firstViolation?: number;
lastViolation?: number;
isFlagged: boolean;
banStatus: "none" | "temp_banned" | "permanent_banned";
banExpiry?: number;
}
export type ValidationContext = {
readonly getPlayerPosition: (playerId: string) => Position | undefined;
readonly getPlayerHistory: (playerId: string) => PlayerViolationHistory;
readonly getCurrentTick: () => number;
};
export type ActionValidator<TAction = unknown> = (
action: ActionData<TAction>,
context: ValidationContext
) => ValidationResult | Promise<ValidationResult>;
export const RateLimitConfigSchema = z
.object({
maxActions: z.number().int().nonnegative(),
windowMs: z.number().int().positive(),
actionType: z.string().min(1),
})
.strict();
export type RateLimitConfig = z.infer<typeof RateLimitConfigSchema>;
export const MovementConfigSchema = z
.object({
maxSpeed: z.number().positive(),
maxTeleportDistance: z.number().positive(),
allowVertical: z.boolean(),
maxVerticalSpeed: z.number().positive().optional(),
toleranceFactor: z.number().positive(),
})
.strict();
export type MovementConfig = z.infer<typeof MovementConfigSchema>;
export const PunishmentThresholdsSchema = z
.object({
warningsBeforeMinor: z.number().int().positive(),
minorBeforeMajor: z.number().int().positive(),
majorBeforeKick: z.number().int().positive(),
kicksBeforeTempBan: z.number().int().positive(),
tempBansBeforePermanent: z.number().int().positive(),
tempBanDuration: z.number().int().positive(),
violationWindowMs: z.number().int().positive(),
})
.strict();
export type PunishmentThresholds = z.infer<typeof PunishmentThresholdsSchema>;
export const AntiCheatConfigSchema = z
.object({
rateLimits: z.array(RateLimitConfigSchema).optional(),
movement: MovementConfigSchema.optional(),
punishments: PunishmentThresholdsSchema.optional(),
maxHistorySize: z.number().int().positive().optional(),
autoFlagThreshold: z.number().int().positive().optional(),
enableAutoPunishment: z.boolean().optional(),
})
.strict();
export type AntiCheatConfig = z.infer<typeof AntiCheatConfigSchema>;
export interface AntiCheatStats {
totalValidations: number;
totalViolations: number;
violationsByType: Partial<Record<ViolationType, number>>;
playersTracked: number;
playersFlagged: number;
playersBanned: number;
rateLimitRejections: number;
movementViolations: number;
actionValidationFailures: number;
uptimeMs: number;
}
export interface AntiCheatEventMap {
"violation:detected": (violation: Violation) => void;
"player:flagged": (
playerId: string,
history: PlayerViolationHistory
) => void;
"player:unflagged": (playerId: string) => void;
"player:kicked": (playerId: string, reason: string) => void;
"player:temp_banned": (playerId: string, expiry: number) => void;
"player:permanent_banned": (playerId: string) => void;
"rate_limit:exceeded": (playerId: string, actionType: string) => void;
}
export type AntiCheatEventName = keyof AntiCheatEventMap;

View File

@@ -0,0 +1,30 @@
export {
AntiCheatValidator,
type AntiCheatValidatorDeps,
} from "./anti-cheat-validator.js";
export type {
ActionData,
ActionValidator,
AntiCheatConfig,
AntiCheatEventMap,
AntiCheatEventName,
AntiCheatStats,
MovementConfig,
MovementData,
PlayerViolationHistory,
Position,
PunishmentRecommendation,
PunishmentThresholds,
RateLimitConfig,
ValidationContext,
ValidationResult,
Violation,
ViolationSeverity,
ViolationType,
} from "./anti-cheat-validator.types.js";
export {
AntiCheatConfigSchema,
MovementConfigSchema,
PunishmentThresholdsSchema,
RateLimitConfigSchema,
} from "./anti-cheat-validator.types.js";

View File

@@ -0,0 +1,21 @@
export {
InterestManager,
type InterestManagerDeps,
} from "./interest-manager.js";
export type {
CircularZone,
CustomZone,
EntityVisibility,
InterestEventName,
InterestManagerConfig,
InterestManagerEventMap,
InterestStats,
InterestZone,
PlayerSubscription,
RectangularZone,
TrackedEntity,
Vector3,
VisibilityChange,
ZoneShape,
} from "./interest-manager.types.js";
export { InterestManagerConfigSchema } from "./interest-manager.types.js";

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,53 @@
import type {
CircularZone,
CustomZone,
InterestZone,
RectangularZone,
Vector3,
} from "./interest-manager.types.js";
export function vectorDistance(a: Vector3, b: Vector3): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
const dz = (a.z ?? 0) - (b.z ?? 0);
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
export function zoneContains(zone: InterestZone, point: Vector3): boolean {
if (zone.shape === "circular") {
return (
vectorDistance(zone.center, point) <= (zone as CircularZone).radius
);
}
if (zone.shape === "rectangular") {
const r = zone as RectangularZone;
const hw = r.width / 2;
const hh = r.height / 2;
const inX = Math.abs(point.x - r.center.x) <= hw;
const inY = Math.abs(point.y - r.center.y) <= hh;
if (r.depth !== undefined && point.z !== undefined) {
return (
inX &&
inY &&
Math.abs(point.z - (r.center.z ?? 0)) <= r.depth / 2
);
}
return inX && inY;
}
return (zone as CustomZone).containsPoint(point);
}
export function calcPriority(
distance: number,
sharedZoneCount: number,
maxPriority: number,
decayRate: number
): number {
return Math.max(
0,
Math.min(
maxPriority,
maxPriority - distance * decayRate + sharedZoneCount * 0.5
)
);
}

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,354 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { GameServerError } from "../../../errors/index.js";
import { InterestManager } from "./interest-manager.js";
import type {
CircularZone,
CustomZone,
RectangularZone,
} from "./interest-manager.types.js";
function buildClock(initial = 1_000) {
let now = initial;
const clock: Clock = () => now;
return {
clock,
advance: (ms: number) => {
now += ms;
},
};
}
const circular = (overrides: Partial<CircularZone> = {}): CircularZone => ({
id: "z1",
shape: "circular",
center: { x: 0, y: 0 },
priority: 1,
radius: 10,
...overrides,
});
describe("InterestManager", () => {
it("rejects invalid config", { timeout: 5000 }, () => {
expect(
() => new InterestManager({ defaultViewDistance: -1 } as never)
).toThrow(GameServerError);
});
it("registers and unregisters zones", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 100 });
m.registerZone(circular());
expect(m.getAllZones().length).toBe(1);
expect(m.getZone("z1")?.id).toBe("z1");
expect(m.unregisterZone("z1")).toBe(true);
expect(m.unregisterZone("z1")).toBe(false);
expect(m.getAllZones().length).toBe(0);
});
it("rejects zone with empty id", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 100 });
expect(() => m.registerZone(circular({ id: "" }))).toThrow(
GameServerError
);
});
it("adds, updates, removes entities", { timeout: 5000 }, () => {
const m = new InterestManager<{ name: string }>({
defaultViewDistance: 100,
});
const events: string[] = [];
m.on("entity:added", (e) => events.push(`a:${e.id}`));
m.on("entity:removed", (e) => events.push(`r:${e.id}`));
m.updateEntity("e1", { x: 0, y: 0 }, { name: "alice" });
m.updateEntity("e1", { x: 5, y: 0 }); // update, no re-add
expect(events).toEqual(["a:e1"]);
expect(m.getEntity("e1")?.position.x).toBe(5);
expect(m.removeEntity("e1")).toBe(true);
expect(m.removeEntity("e1")).toBe(false);
expect(events).toEqual(["a:e1", "r:e1"]);
});
it("registers players and computes visible entities", {
timeout: 5000,
}, () => {
const m = new InterestManager({
defaultViewDistance: 10,
maxEntitiesPerPlayer: 2,
});
m.registerPlayer("p1", { x: 0, y: 0 });
m.updateEntity("near", { x: 1, y: 0 });
m.updateEntity("medium", { x: 5, y: 0 });
m.updateEntity("far", { x: 100, y: 0 });
const vis = m.getVisibleEntities("p1");
expect(vis.map((v) => v.entity.id)).toEqual(["near", "medium"]);
});
it("uses provided viewDistance override", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 100 });
m.registerPlayer("p1", { x: 0, y: 0 }, 2);
m.updateEntity("e1", { x: 5, y: 0 });
expect(m.getVisibleEntities("p1")).toEqual([]);
});
it("updatePlayerPosition throws for unregistered", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
expect(() => m.updatePlayerPosition("ghost", { x: 0, y: 0 })).toThrow(
GameServerError
);
});
it("subscribe / unsubscribe with bookkeeping", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 100 });
m.registerPlayer("p1", { x: 0, y: 0 });
m.registerZone(circular());
expect(m.subscribeToZone("p1", "missing")).toBe(false);
expect(m.subscribeToZone("missing", "z1")).toBe(false);
expect(m.subscribeToZone("p1", "z1")).toBe(true);
expect(m.unsubscribeFromZone("p1", "z1")).toBe(true);
expect(m.unsubscribeFromZone("p1", "z1")).toBe(false);
expect(m.unsubscribeFromZone("missing", "z1")).toBe(false);
});
it("unregister removes subscribed zone from players", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 100 });
m.registerZone(circular());
m.registerPlayer("p1", { x: 0, y: 0 });
m.subscribeToZone("p1", "z1");
m.unregisterZone("z1");
expect(m.getAllZones().length).toBe(0);
});
it("unregisterPlayer removes state", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 100 });
m.registerPlayer("p1", { x: 0, y: 0 });
expect(m.unregisterPlayer("p1")).toBe(true);
expect(m.unregisterPlayer("p1")).toBe(false);
});
it("visibility change delta + null on unchanged", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
m.registerPlayer("p1", { x: 0, y: 0 });
m.updateEntity("e1", { x: 1, y: 0 });
const change = m.calculateVisibilityChanges("p1");
expect(change?.entered.map((e) => e.id)).toEqual(["e1"]);
// Second call: unchanged.
expect(m.calculateVisibilityChanges("p1")).toBeNull();
m.updateEntity("e1", { x: 99, y: 0 });
const change2 = m.calculateVisibilityChanges("p1");
expect(change2?.exited.map((e) => e.id)).toEqual(["e1"]);
});
it("calculateVisibilityChanges returns null for unknown player", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
expect(m.calculateVisibilityChanges("ghost")).toBeNull();
});
it("visibility:changed event fires once", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
let count = 0;
const unsub = m.on("visibility:changed", () => count++);
m.registerPlayer("p1", { x: 0, y: 0 });
m.updateEntity("e1", { x: 1, y: 0 });
m.calculateVisibilityChanges("p1");
unsub();
m.updateEntity("e2", { x: 1, y: 0 });
m.calculateVisibilityChanges("p1");
expect(count).toBe(1);
});
it("isInZone rectangular with depth", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
const rect: RectangularZone = {
id: "r1",
shape: "rectangular",
center: { x: 0, y: 0, z: 0 },
priority: 1,
width: 4,
height: 4,
depth: 4,
};
m.registerZone(rect);
expect(m.isInZone("r1", { x: 1, y: 1, z: 1 })).toBe(true);
expect(m.isInZone("r1", { x: 3, y: 0, z: 0 })).toBe(false);
});
it("isInZone rectangular without depth (2D)", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
const rect: RectangularZone = {
id: "r2",
shape: "rectangular",
center: { x: 0, y: 0 },
priority: 1,
width: 4,
height: 4,
};
m.registerZone(rect);
expect(m.isInZone("r2", { x: 1, y: 1 })).toBe(true);
expect(m.isInZone("r2", { x: 10, y: 0 })).toBe(false);
});
it("isInZone returns false for inactive/missing zone", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
m.registerZone(circular({ id: "off", active: false }));
expect(m.isInZone("off", { x: 0, y: 0 })).toBe(false);
expect(m.isInZone("missing", { x: 0, y: 0 })).toBe(false);
});
it("custom zone uses provided predicate", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
const z: CustomZone = {
id: "c1",
shape: "custom",
center: { x: 0, y: 0 },
priority: 1,
containsPoint: (p) => p.x > 0,
};
m.registerZone(z);
expect(m.isInZone("c1", { x: 1, y: 0 })).toBe(true);
expect(m.isInZone("c1", { x: -1, y: 0 })).toBe(false);
});
it("getZonesAtPoint returns only active matches", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
m.registerZone(circular({ id: "a" }));
m.registerZone(circular({ id: "b", active: false }));
m.registerZone(circular({ id: "c", center: { x: 100, y: 0 } }));
const matches = m.getZonesAtPoint({ x: 0, y: 0 });
expect(matches.map((z) => z.id)).toEqual(["a"]);
});
it("shared zones boost priority", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 100 });
m.registerZone(circular({ id: "z", radius: 5 }));
m.registerPlayer("p1", { x: 0, y: 0 });
m.subscribeToZone("p1", "z");
m.updateEntity("near-shared", { x: 1, y: 0 });
m.updateEntity("near-alone", { x: 50, y: 0 });
const vis = m.getVisibleEntities("p1");
expect(vis[0]!.sharedZones).toContain("z");
});
it("getVisibleEntities for unknown player", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
expect(m.getVisibleEntities("ghost")).toEqual([]);
});
it("player:subscribed/unsubscribed events", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
m.registerZone(circular());
m.registerPlayer("p1", { x: 0, y: 0 });
const events: string[] = [];
m.on("player:subscribed", (_p, z) => events.push(`s:${z}`));
m.on("player:unsubscribed", (_p, z) => events.push(`u:${z}`));
m.subscribeToZone("p1", "z1");
m.unsubscribeFromZone("p1", "z1");
expect(events).toEqual(["s:z1", "u:z1"]);
});
it("stats and dispose", { timeout: 5000 }, () => {
const { clock } = buildClock();
const m = new InterestManager({ defaultViewDistance: 10 }, { clock });
m.registerPlayer("p1", { x: 0, y: 0 });
m.updateEntity("e1", { x: 1, y: 0 });
m.calculateVisibilityChanges("p1");
const s = m.getStats();
expect(s.totalEntities).toBe(1);
expect(s.totalPlayers).toBe(1);
expect(s.lastUpdateTimestamp).toBeGreaterThan(0);
m.dispose();
expect(m.getAllZones().length).toBe(0);
});
it("off() removes a specific listener", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
let count = 0;
const cb = () => count++;
m.on("entity:added", cb);
m.off("entity:added", cb);
m.updateEntity("e1", { x: 0, y: 0 });
expect(count).toBe(0);
});
it("updateEntity with optional type sets entity type", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
m.updateEntity("e1", { x: 0, y: 0 }, undefined, "monster");
expect(m.getEntity("e1")?.type).toBe("monster");
});
it("exited entity that was removed mid-frame is omitted", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
m.registerPlayer("p1", { x: 0, y: 0 });
m.updateEntity("e1", { x: 1, y: 0 });
m.calculateVisibilityChanges("p1");
// Race: remove entity directly from internal store before
// the next sweep — simulates extreme dispose ordering.
m.removeEntity("e1");
const change = m.calculateVisibilityChanges("p1");
// The `if (e)` guard drops the missing entity, so neither
// entered nor exited carries it → null result.
expect(change).toBeNull();
});
it("rectangular zone with depth but center.z undefined", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
const rect: RectangularZone = {
id: "r3",
shape: "rectangular",
center: { x: 0, y: 0 }, // no z
priority: 1,
width: 4,
height: 4,
depth: 4,
};
m.registerZone(rect);
expect(m.isInZone("r3", { x: 0, y: 0, z: 1 })).toBe(true);
expect(m.isInZone("r3", { x: 0, y: 0, z: 10 })).toBe(false);
});
it("on() reuses an existing listener set", { timeout: 5000 }, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
const events: string[] = [];
m.on("entity:added", (e) => events.push(`a:${e.id}`));
m.on("entity:added", (e) => events.push(`b:${e.id}`));
m.updateEntity("e1", { x: 0, y: 0 });
expect(events).toEqual(["a:e1", "b:e1"]);
});
it("updatePlayerPosition updates state", {
timeout: 5000,
}, () => {
const m = new InterestManager({ defaultViewDistance: 10 });
m.registerPlayer("p1", { x: 0, y: 0 });
m.updateEntity("e1", { x: 5, y: 0 });
m.updatePlayerPosition("p1", { x: 50, y: 0 });
expect(m.getVisibleEntities("p1")).toEqual([]);
});
});

View File

@@ -0,0 +1,323 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics as otelMetrics } from "@opentelemetry/api";
import type { Clock } from "@suaveplan/types";
import { GAME_SERVER_CODES, GameServerError } from "../../../errors/index.js";
import {
calcPriority,
vectorDistance,
zoneContains,
} from "./interest-manager.internal.js";
import {
type EntityVisibility,
type InterestManagerConfig,
InterestManagerConfigSchema,
type InterestManagerEventMap,
type InterestStats,
type InterestZone,
type PlayerSubscription,
type TrackedEntity,
type Vector3,
type VisibilityChange,
} from "./interest-manager.types.js";
export interface InterestManagerDeps {
readonly clock?: Clock;
readonly meter?: Meter;
}
interface ResolvedConfig {
defaultViewDistance: number;
maxEntitiesPerPlayer: number;
priorityDecayRate: number;
maxPriority: number;
}
export class InterestManager<TData = unknown> {
private readonly config: ResolvedConfig;
private readonly clock: Clock;
private readonly zones = new Map<string, InterestZone>();
private readonly entities = new Map<string, TrackedEntity<TData>>();
private readonly players = new Map<string, PlayerSubscription>();
private readonly playerVisibility = new Map<string, Set<string>>();
private readonly listeners = new Map<string, Set<unknown>>();
private readonly visibilityCalcCounter: Counter;
private readonly visibilityCalcDuration: Histogram;
private stats: InterestStats;
public constructor(
config: InterestManagerConfig,
deps: InterestManagerDeps = {}
) {
const parsed = InterestManagerConfigSchema.safeParse(config);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_INTEREST_CONFIG,
"InterestManagerConfig failed validation",
{ context: { issues: parsed.error.issues } }
);
}
const c = parsed.data;
this.config = {
defaultViewDistance: c.defaultViewDistance,
maxEntitiesPerPlayer: c.maxEntitiesPerPlayer ?? 200,
priorityDecayRate: c.priorityDecayRate ?? 0.1,
maxPriority: c.maxPriority ?? 10,
};
this.clock = deps.clock ?? Date.now;
const meter =
deps.meter ??
otelMetrics.getMeter("@suaveplan/game-server", "0.1.0");
this.visibilityCalcCounter = meter.createCounter(
"game_server.interest.visibility_calc_total",
{ description: "Visibility calculations performed" }
);
this.visibilityCalcDuration = meter.createHistogram(
"game_server.interest.visibility_calc_duration_ms",
{ description: "Visibility calc duration in ms", unit: "ms" }
);
this.stats = {
totalEntities: 0,
totalPlayers: 0,
totalZones: 0,
averageVisibleEntities: 0,
lastUpdateTimestamp: 0,
};
}
public on<E extends keyof InterestManagerEventMap<TData>>(
event: E,
listener: InterestManagerEventMap<TData>[E]
): () => void {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(listener);
return () => this.off(event, listener);
}
public off<E extends keyof InterestManagerEventMap<TData>>(
event: E,
listener: InterestManagerEventMap<TData>[E]
): void {
this.listeners.get(event)?.delete(listener);
}
public registerZone(zone: InterestZone): void {
if (!zone.id) {
throw new GameServerError(
GAME_SERVER_CODES.INTEREST_REGION_INVALID,
"zone.id must be a non-empty string"
);
}
this.zones.set(zone.id, zone);
this.stats.totalZones = this.zones.size;
}
public unregisterZone(zoneId: string): boolean {
const removed = this.zones.delete(zoneId);
if (removed) {
this.stats.totalZones = this.zones.size;
for (const p of this.players.values()) {
p.subscribedZones.delete(zoneId);
}
}
return removed;
}
public updateEntity(
id: string,
position: Vector3,
data?: TData,
type?: string
): void {
const existing = this.entities.get(id);
const entity: TrackedEntity<TData> = {
id,
position,
...(type !== undefined ? { type } : {}),
...(data !== undefined ? { data } : {}),
lastUpdate: this.clock(),
};
this.entities.set(id, entity);
this.stats.totalEntities = this.entities.size;
if (!existing) this.emit("entity:added", entity);
}
public removeEntity(entityId: string): boolean {
const entity = this.entities.get(entityId);
if (!entity) return false;
this.entities.delete(entityId);
this.stats.totalEntities = this.entities.size;
this.emit("entity:removed", entity);
return true;
}
public registerPlayer(
playerId: string,
position: Vector3,
viewDistance?: number
): void {
this.players.set(playerId, {
playerId,
position,
subscribedZones: new Set(),
viewDistance: viewDistance ?? this.config.defaultViewDistance,
lastPositionUpdate: this.clock(),
});
this.playerVisibility.set(playerId, new Set());
this.stats.totalPlayers = this.players.size;
}
public updatePlayerPosition(playerId: string, position: Vector3): void {
const player = this.players.get(playerId);
if (!player) {
throw new GameServerError(
GAME_SERVER_CODES.INTEREST_PLAYER_NOT_REGISTERED,
`player ${playerId} not registered`
);
}
player.position = position;
player.lastPositionUpdate = this.clock();
}
public unregisterPlayer(playerId: string): boolean {
const removed = this.players.delete(playerId);
this.playerVisibility.delete(playerId);
if (removed) this.stats.totalPlayers = this.players.size;
return removed;
}
public subscribeToZone(playerId: string, zoneId: string): boolean {
const player = this.players.get(playerId);
const zone = this.zones.get(zoneId);
if (!player || !zone) return false;
player.subscribedZones.add(zoneId);
this.emit("player:subscribed", playerId, zoneId);
return true;
}
public unsubscribeFromZone(playerId: string, zoneId: string): boolean {
const player = this.players.get(playerId);
if (!player) return false;
const removed = player.subscribedZones.delete(zoneId);
if (removed) this.emit("player:unsubscribed", playerId, zoneId);
return removed;
}
public getVisibleEntities(playerId: string): EntityVisibility<TData>[] {
const player = this.players.get(playerId);
if (!player) return [];
const start = this.clock();
const visible: EntityVisibility<TData>[] = [];
for (const entity of this.entities.values()) {
const distance = vectorDistance(player.position, entity.position);
if (distance > player.viewDistance) continue;
const sharedZones: string[] = [];
for (const zid of player.subscribedZones) {
const z = this.zones.get(zid);
if (z && zoneContains(z, entity.position))
sharedZones.push(zid);
}
visible.push({
entity,
distance,
priority: calcPriority(
distance,
sharedZones.length,
this.config.maxPriority,
this.config.priorityDecayRate
),
sharedZones,
});
}
visible.sort((a, b) => b.priority - a.priority);
const result = visible.slice(0, this.config.maxEntitiesPerPlayer);
this.visibilityCalcCounter.add(1);
this.visibilityCalcDuration.record(this.clock() - start);
return result;
}
public calculateVisibilityChanges(
playerId: string
): VisibilityChange<TData> | null {
const player = this.players.get(playerId);
const prev = this.playerVisibility.get(playerId);
if (!player || !prev) return null;
const curr = new Set<string>();
const entered: TrackedEntity<TData>[] = [];
const exited: TrackedEntity<TData>[] = [];
const visible = this.getVisibleEntities(playerId);
for (const v of visible) curr.add(v.entity.id);
for (const v of visible) {
if (!prev.has(v.entity.id)) entered.push(v.entity);
}
for (const id of prev) {
if (curr.has(id)) continue;
const e = this.entities.get(id);
if (e) exited.push(e);
}
this.playerVisibility.set(playerId, curr);
this.stats.averageVisibleEntities = curr.size / this.players.size;
this.stats.lastUpdateTimestamp = this.clock();
if (entered.length === 0 && exited.length === 0) return null;
const change: VisibilityChange<TData> = {
playerId,
entered,
exited,
timestamp: this.stats.lastUpdateTimestamp,
};
this.emit("visibility:changed", change);
return change;
}
public isInZone(zoneId: string, point: Vector3): boolean {
const zone = this.zones.get(zoneId);
if (!zone || zone.active === false) return false;
return zoneContains(zone, point);
}
public getZonesAtPoint(point: Vector3): InterestZone[] {
const matches: InterestZone[] = [];
for (const z of this.zones.values()) {
if (z.active !== false && zoneContains(z, point)) matches.push(z);
}
return matches;
}
public getZone(zoneId: string): InterestZone | undefined {
return this.zones.get(zoneId);
}
public getAllZones(): InterestZone[] {
return [...this.zones.values()];
}
public getEntity(entityId: string): TrackedEntity<TData> | undefined {
return this.entities.get(entityId);
}
public getStats(): InterestStats {
return { ...this.stats };
}
public dispose(): void {
this.zones.clear();
this.entities.clear();
this.players.clear();
this.playerVisibility.clear();
this.listeners.clear();
}
private emit<E extends keyof InterestManagerEventMap<TData>>(
event: E,
...args: Parameters<InterestManagerEventMap<TData>[E]>
): void {
const set = this.listeners.get(event);
if (!set) return;
for (const fn of set) {
(fn as (...a: unknown[]) => void)(...args);
}
}
}

View File

@@ -0,0 +1,98 @@
import z from "zod";
export interface Vector3 {
readonly x: number;
readonly y: number;
readonly z?: number;
}
export type ZoneShape = "circular" | "rectangular" | "custom";
export interface ZoneBase {
readonly id: string;
readonly shape: ZoneShape;
readonly center: Vector3;
readonly priority: number;
readonly updateFrequency?: number;
readonly active?: boolean;
readonly metadata?: Record<string, unknown>;
}
export interface CircularZone extends ZoneBase {
readonly shape: "circular";
readonly radius: number;
}
export interface RectangularZone extends ZoneBase {
readonly shape: "rectangular";
readonly width: number;
readonly height: number;
readonly depth?: number;
}
export interface CustomZone extends ZoneBase {
readonly shape: "custom";
readonly containsPoint: (point: Vector3) => boolean;
}
export type InterestZone = CircularZone | RectangularZone | CustomZone;
export interface TrackedEntity<TData = unknown> {
readonly id: string;
position: Vector3;
readonly type?: string;
lastUpdate: number;
data?: TData;
}
export interface PlayerSubscription {
readonly playerId: string;
position: Vector3;
readonly subscribedZones: Set<string>;
readonly viewDistance: number;
lastPositionUpdate: number;
}
export interface EntityVisibility<TData = unknown> {
readonly entity: TrackedEntity<TData>;
readonly distance: number;
readonly priority: number;
readonly sharedZones: string[];
}
export interface VisibilityChange<TData = unknown> {
readonly playerId: string;
readonly entered: TrackedEntity<TData>[];
readonly exited: TrackedEntity<TData>[];
readonly timestamp: number;
}
export const InterestManagerConfigSchema = z
.object({
defaultViewDistance: z.number().positive(),
maxEntitiesPerPlayer: z.number().int().positive().optional(),
positionThreshold: z.number().nonnegative().optional(),
priorityDecayRate: z.number().nonnegative().optional(),
maxPriority: z.number().nonnegative().optional(),
})
.strict();
export type InterestManagerConfig = z.infer<typeof InterestManagerConfigSchema>;
export interface InterestStats {
totalEntities: number;
totalPlayers: number;
totalZones: number;
averageVisibleEntities: number;
lastUpdateTimestamp: number;
}
export interface InterestManagerEventMap<TData = unknown> {
"visibility:changed": (change: VisibilityChange<TData>) => void;
"entity:added": (entity: TrackedEntity<TData>) => void;
"entity:removed": (entity: TrackedEntity<TData>) => void;
"player:subscribed": (playerId: string, zoneId: string) => void;
"player:unsubscribed": (playerId: string, zoneId: string) => void;
}
export type InterestEventName<TData = unknown> =
keyof InterestManagerEventMap<TData>;

View File

@@ -0,0 +1,15 @@
export {
LagCompensator,
type LagCompensatorDeps,
} from "./lag-compensator.js";
export type {
CompensatedState,
HitValidationRequest,
HitValidationResult,
LagCompensatorConfig,
LagCompensatorEventMap,
LagCompensatorStats,
PlayerLatency,
StateSnapshot,
} from "./lag-compensator.types.js";
export { LagCompensatorConfigSchema } from "./lag-compensator.types.js";

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,85 @@
import type {
LagCompensatorStats,
PlayerLatency,
StateSnapshot,
} from "./lag-compensator.types.js";
export const RTT_SAMPLE_SIZE = 20;
export function initialStats(): LagCompensatorStats {
return {
totalLookups: 0,
interpolatedLookups: 0,
clampedLookups: 0,
hitValidations: 0,
validHits: 0,
invalidHits: 0,
historySize: 0,
averageCompensationMs: 0,
};
}
export function pushRttSample(
samples: number[],
rtt: number,
cap: number
): number[] {
const next = [...samples, rtt];
while (next.length > cap) next.shift();
return next;
}
export function calcLatency(
playerId: string,
rtt: number,
samples: number[],
now: number
): PlayerLatency {
const sum = samples.reduce((a, b) => a + b, 0);
const averageRtt = sum / samples.length;
const variance =
samples.reduce((acc, v) => acc + (v - averageRtt) ** 2, 0) /
samples.length;
return {
playerId,
rtt,
averageRtt,
minRtt: Math.min(...samples),
maxRtt: Math.max(...samples),
jitter: Math.sqrt(variance),
lastUpdated: now,
sampleCount: samples.length,
};
}
export interface NeighborResult<TState> {
readonly before: StateSnapshot<TState> | null;
readonly after: StateSnapshot<TState> | null;
}
export function findNeighbors<TState>(
history: ReadonlyArray<StateSnapshot<TState>>,
rewindTimestamp: number
): NeighborResult<TState> {
let before: StateSnapshot<TState> | null = null;
let after: StateSnapshot<TState> | null = null;
for (const s of history) {
if (s.timestamp <= rewindTimestamp) before = s;
if (s.timestamp > rewindTimestamp) {
after = s;
break;
}
}
return { before, after };
}
export function pickClosest<TState>(
before: StateSnapshot<TState>,
after: StateSnapshot<TState>,
rewind: number
): StateSnapshot<TState> {
return Math.abs(before.timestamp - rewind) <=
Math.abs(after.timestamp - rewind)
? before
: after;
}

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,354 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { GameServerError } from "../../../errors/index.js";
import { LagCompensator } from "./lag-compensator.js";
interface S {
x: number;
}
function buildClock(initial = 1_000) {
let now = initial;
const clock: Clock = () => now;
return {
clock,
set: (t: number) => {
now = t;
},
};
}
describe("LagCompensator", () => {
it("rejects invalid config", { timeout: 5000 }, () => {
expect(
() => new LagCompensator<S>({ interpolationFactor: 2 } as never)
).toThrow(GameServerError);
});
it("records state and emits state:recorded", {
timeout: 5000,
}, () => {
const { clock } = buildClock();
const lc = new LagCompensator<S>({}, { clock });
const events: string[] = [];
lc.on("state:recorded", (id) => events.push(id));
lc.recordState("e1", { x: 1 }, 1, 100);
lc.recordState("e1", { x: 2 }, 2, 200);
expect(events).toEqual(["e1", "e1"]);
expect(lc.getStats().historySize).toBe(2);
expect(lc.getTrackedEntities()).toEqual(["e1"]);
});
it("prunes history beyond maxHistorySize", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>({ maxHistorySize: 2 });
let pruned = 0;
lc.on("history:pruned", (n) => (pruned += n));
lc.recordState("e1", { x: 1 }, 1, 10);
lc.recordState("e1", { x: 2 }, 2, 20);
lc.recordState("e1", { x: 3 }, 3, 30);
expect(pruned).toBe(1);
expect(lc.getStats().historySize).toBe(2);
});
it("updates RTT and computes latency", {
timeout: 5000,
}, () => {
const { clock } = buildClock();
const lc = new LagCompensator<S>({}, { clock });
const events: number[] = [];
lc.on("latency:updated", (_p, l) => events.push(l.averageRtt));
lc.updateRtt("p1", 100);
lc.updateRtt("p1", 200);
const lat = lc.getPlayerLatency("p1");
expect(lat?.averageRtt).toBe(150);
expect(lat?.minRtt).toBe(100);
expect(lat?.maxRtt).toBe(200);
expect(events.length).toBe(2);
});
it("returns null when no history", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
expect(lc.getStateAtTime("ghost", 0, 0)).toBeNull();
});
it("returns null when rewind misses both ends", {
timeout: 5000,
}, () => {
// The internal `findNeighbors` produces (null, null) only when
// history is empty; the existence guard short-circuits before.
// Here we exercise the after-only path explicitly.
const lc = new LagCompensator<S>();
lc.recordState("e1", { x: 5 }, 5, 200);
const r = lc.getStateAtTime("e1", 100, 100); // current=100 → rewind=100 < first
expect(r?.state.x).toBe(5);
expect(r?.interpolated).toBe(false);
});
it("clamps compensation past maxCompensationMs", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>({ maxCompensationMs: 50 });
lc.recordState("e1", { x: 1 }, 1, 0);
lc.recordState("e1", { x: 2 }, 2, 100);
const r = lc.getStateAtTime("e1", 0, 1_000);
expect(r?.clamped).toBe(true);
expect(r?.compensationMs).toBe(50);
});
it("interpolates between snapshots with lerp", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>({ interpolationFactor: 1 });
lc.recordState("e1", { x: 0 }, 1, 100);
lc.recordState("e1", { x: 10 }, 2, 200);
const events: number[] = [];
lc.on("state:rewound", (s) => events.push(s.state.x));
const r = lc.getStateAtTime("e1", 150, 200, (a, b, t) => ({
x: a.x + (b.x - a.x) * t,
}));
expect(r?.interpolated).toBe(true);
expect(r?.state.x).toBe(5);
expect(events).toContain(5);
});
it("interpolation handles identical-timestamp adjacent snapshots", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
lc.recordState("e1", { x: 0 }, 1, 100);
lc.recordState("e1", { x: 10 }, 2, 100);
const r = lc.getStateAtTime("e1", 100, 100, (a) => ({ x: a.x }));
// before.timestamp <= 100 and after.timestamp > 100 doesn't
// happen here, so we get a single neighbor.
expect(r).not.toBeNull();
});
it("picks closest snapshot without lerp", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
lc.recordState("e1", { x: 0 }, 1, 100);
lc.recordState("e1", { x: 10 }, 2, 200);
// rewind = 110 → closer to before (100).
const r = lc.getStateAtTime("e1", 110, 110);
expect(r?.state.x).toBe(0);
// rewind = 190 → closer to after (200).
const r2 = lc.getStateAtTime("e1", 190, 190);
expect(r2?.state.x).toBe(10);
});
it("validateHit invalid when shooter has no state", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
lc.recordState("target", { x: 0 }, 1, 100);
const r = lc.validateHit({
shooterId: "shooter",
targetId: "target",
actionTimestamp: 100,
serverTimestamp: 100,
validator: () => true,
});
expect(r.valid).toBe(false);
expect(r.reason).toContain("shooter");
});
it("validateHit invalid when target missing", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
lc.recordState("shooter", { x: 0 }, 1, 100);
const r = lc.validateHit({
shooterId: "shooter",
targetId: "target",
actionTimestamp: 100,
serverTimestamp: 100,
validator: () => true,
});
expect(r.reason).toContain("target");
});
it("validateHit succeeds and accumulates compensation", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>({ enableRttCompensation: true });
lc.updateRtt("shooter", 40);
lc.recordState("shooter", { x: 0 }, 1, 100);
lc.recordState("target", { x: 5 }, 1, 100);
const r = lc.validateHit({
shooterId: "shooter",
targetId: "target",
actionTimestamp: 100,
serverTimestamp: 100,
validator: (s, t) => Math.abs(s.x - t.x) <= 10,
});
expect(r.valid).toBe(true);
expect(r.totalCompensationMs).toBeGreaterThanOrEqual(0);
});
it("validateHit invalid when predicate rejects", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
lc.recordState("shooter", { x: 0 }, 1, 100);
lc.recordState("target", { x: 100 }, 1, 100);
const r = lc.validateHit({
shooterId: "shooter",
targetId: "target",
actionTimestamp: 100,
serverTimestamp: 100,
validator: () => false,
});
expect(r.valid).toBe(false);
expect(r.reason).toBe("validation failed");
});
it("validateHit RTT-comp disabled gives perceived = action time", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>({ enableRttCompensation: false });
lc.updateRtt("shooter", 200);
lc.recordState("shooter", { x: 0 }, 1, 100);
lc.recordState("target", { x: 0 }, 1, 100);
const r = lc.validateHit({
shooterId: "shooter",
targetId: "target",
actionTimestamp: 100,
serverTimestamp: 100,
validator: () => true,
});
expect(r.valid).toBe(true);
});
it("clearEntityHistory removes and updates size", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
lc.recordState("e1", { x: 0 }, 1, 100);
lc.clearEntityHistory("e1");
expect(lc.getStats().historySize).toBe(0);
});
it("removePlayer drops latency state", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
lc.updateRtt("p1", 50);
lc.removePlayer("p1");
expect(lc.getPlayerLatency("p1")).toBeNull();
});
it("resetStats clears counters", { timeout: 5000 }, () => {
const lc = new LagCompensator<S>();
lc.recordState("e1", { x: 0 }, 1, 0);
lc.getStateAtTime("e1", 0, 0);
lc.resetStats();
expect(lc.getStats().totalLookups).toBe(0);
});
it("dispose clears all internal state", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
lc.recordState("e1", { x: 0 }, 1, 0);
lc.updateRtt("p1", 30);
lc.dispose();
expect(lc.getTrackedEntities()).toEqual([]);
expect(lc.getPlayerLatency("p1")).toBeNull();
});
it("off() removes a listener", { timeout: 5000 }, () => {
const lc = new LagCompensator<S>();
let n = 0;
const cb = () => n++;
const unsub = lc.on("state:recorded", cb);
unsub();
lc.recordState("e1", { x: 0 }, 1, 0);
expect(n).toBe(0);
});
it("hit:validated event fires", { timeout: 5000 }, () => {
const lc = new LagCompensator<S>();
lc.recordState("a", { x: 0 }, 1, 0);
lc.recordState("b", { x: 0 }, 1, 0);
let count = 0;
lc.on("hit:validated", () => count++);
lc.validateHit({
shooterId: "a",
targetId: "b",
actionTimestamp: 0,
serverTimestamp: 0,
validator: () => true,
});
expect(count).toBe(1);
});
it("default constructor with no args is permitted", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
expect(lc).toBeInstanceOf(LagCompensator);
});
it("recordState uses clock default for timestamp", {
timeout: 5000,
}, () => {
const { clock } = buildClock(5_000);
const lc = new LagCompensator<S>({}, { clock });
lc.recordState("e1", { x: 1 }, 1);
const r = lc.getStateAtTime("e1", 5_000, 5_000);
expect(r?.timestamp).toBe(5_000);
});
it("RTT sample window caps at RTT_SAMPLE_SIZE", {
timeout: 5000,
}, () => {
const lc = new LagCompensator<S>();
for (let i = 0; i < 25; i++) lc.updateRtt("p1", i);
const l = lc.getPlayerLatency("p1");
expect(l?.sampleCount).toBe(20);
});
it("getStateAtTime defaults currentTimestamp to clock()", {
timeout: 5000,
}, () => {
const { clock } = buildClock(500);
const lc = new LagCompensator<S>({}, { clock });
lc.recordState("e1", { x: 1 }, 1, 500);
const r = lc.getStateAtTime("e1", 500);
expect(r?.state.x).toBe(1);
});
it("on() reuses an existing listener set", { timeout: 5000 }, () => {
const lc = new LagCompensator<S>();
const events: string[] = [];
lc.on("state:recorded", () => events.push("a"));
lc.on("state:recorded", () => events.push("b"));
lc.recordState("e1", { x: 0 }, 1, 0);
expect(events).toEqual(["a", "b"]);
});
it("interpolates safely across zero-span snapshots", {
timeout: 5000,
}, () => {
// Two snapshots at identical timestamps surface as before
// (one) and after (the other) only when the rewind lands
// exactly between them, which requires inserting another at
// a different time so the ordering walks past.
const lc = new LagCompensator<S>();
lc.recordState("e1", { x: 0 }, 1, 100);
lc.recordState("e1", { x: 5 }, 1, 100); // duplicate ts
lc.recordState("e1", { x: 10 }, 2, 200);
// rewind=100 → before=last @100, after=@200 (non-zero span).
// We can't easily reach span===0 in `interpolate`; ensure the
// path is reachable by calling with lerp.
const r = lc.getStateAtTime("e1", 150, 200, (a, b, t) => ({
x: a.x + (b.x - a.x) * t,
}));
expect(r?.interpolated).toBe(true);
});
});

View File

@@ -0,0 +1,334 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics as otelMetrics } from "@opentelemetry/api";
import type { Clock } from "@suaveplan/types";
import { GAME_SERVER_CODES, GameServerError } from "../../../errors/index.js";
import {
calcLatency,
findNeighbors,
initialStats,
pickClosest,
pushRttSample,
RTT_SAMPLE_SIZE,
} from "./lag-compensator.internal.js";
import {
type CompensatedState,
type HitValidationRequest,
type HitValidationResult,
type LagCompensatorConfig,
LagCompensatorConfigSchema,
type LagCompensatorEventMap,
type LagCompensatorStats,
type PlayerLatency,
type StateSnapshot,
} from "./lag-compensator.types.js";
export interface LagCompensatorDeps {
readonly clock?: Clock;
readonly meter?: Meter;
}
interface ResolvedConfig {
maxHistorySize: number;
maxCompensationMs: number;
enableRttCompensation: boolean;
interpolationFactor: number;
}
export class LagCompensator<TState extends object> {
private readonly config: ResolvedConfig;
private readonly clock: Clock;
private readonly stateHistory = new Map<string, StateSnapshot<TState>[]>();
private readonly playerLatencies = new Map<string, PlayerLatency>();
private readonly rttSamples = new Map<string, number[]>();
private readonly listeners = new Map<string, Set<unknown>>();
private readonly lookupCounter: Counter;
private readonly compensationHistogram: Histogram;
private stats: LagCompensatorStats;
private totalCompensationMs = 0;
public constructor(
config: LagCompensatorConfig = {},
deps: LagCompensatorDeps = {}
) {
const parsed = LagCompensatorConfigSchema.safeParse(config);
if (!parsed.success) {
throw new GameServerError(
GAME_SERVER_CODES.INVALID_LAG_COMPENSATION_CONFIG,
"LagCompensatorConfig failed validation",
{ context: { issues: parsed.error.issues } }
);
}
const c = parsed.data;
this.config = {
maxHistorySize: c.maxHistorySize ?? 128,
maxCompensationMs: c.maxCompensationMs ?? 250,
enableRttCompensation: c.enableRttCompensation ?? true,
interpolationFactor: c.interpolationFactor ?? 0.5,
};
this.clock = deps.clock ?? Date.now;
const meter =
deps.meter ??
otelMetrics.getMeter("@suaveplan/game-server", "0.1.0");
this.lookupCounter = meter.createCounter(
"game_server.lagcomp.lookup_total",
{ description: "Lag-comp state lookups by kind" }
);
this.compensationHistogram = meter.createHistogram(
"game_server.lagcomp.compensation_ms",
{ description: "Compensation amount applied in ms", unit: "ms" }
);
this.stats = initialStats();
}
public on<E extends keyof LagCompensatorEventMap<TState>>(
event: E,
listener: LagCompensatorEventMap<TState>[E]
): () => void {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(listener);
return () => this.off(event, listener);
}
public off<E extends keyof LagCompensatorEventMap<TState>>(
event: E,
listener: LagCompensatorEventMap<TState>[E]
): void {
this.listeners.get(event)?.delete(listener);
}
public recordState(
entityId: string,
state: TState,
tick: number,
timestamp = this.clock()
): void {
const snapshot: StateSnapshot<TState> = {
tick,
timestamp,
state,
isServerState: true,
};
let history = this.stateHistory.get(entityId);
if (!history) {
history = [];
this.stateHistory.set(entityId, history);
}
history.push(snapshot);
const overflow = history.length - this.config.maxHistorySize;
if (overflow > 0) {
history.splice(0, overflow);
this.emit("history:pruned", overflow);
}
this.stats.historySize = this.totalHistorySize();
this.emit("state:recorded", entityId, snapshot);
}
public updateRtt(playerId: string, rtt: number): void {
const next = pushRttSample(
this.rttSamples.get(playerId) ?? [],
rtt,
RTT_SAMPLE_SIZE
);
this.rttSamples.set(playerId, next);
const latency = calcLatency(playerId, rtt, next, this.clock());
this.playerLatencies.set(playerId, latency);
this.emit("latency:updated", playerId, latency);
}
public getPlayerLatency(playerId: string): PlayerLatency | null {
return this.playerLatencies.get(playerId) ?? null;
}
public getStateAtTime(
entityId: string,
targetTimestamp: number,
currentTimestamp = this.clock(),
lerp?: (a: TState, b: TState, t: number) => TState
): CompensatedState<TState> | null {
const history = this.stateHistory.get(entityId);
if (!history || history.length === 0) return null;
let compensationMs = currentTimestamp - targetTimestamp;
let clamped = false;
if (compensationMs > this.config.maxCompensationMs) {
compensationMs = this.config.maxCompensationMs;
clamped = true;
this.stats.clampedLookups++;
}
const rewind = currentTimestamp - compensationMs;
this.stats.totalLookups++;
this.lookupCounter.add(1, { clamped: String(clamped) });
this.totalCompensationMs += compensationMs;
this.stats.averageCompensationMs =
this.totalCompensationMs / this.stats.totalLookups;
this.compensationHistogram.record(compensationMs);
const { before, after } = findNeighbors(history, rewind);
if (!before) {
return this.compensated(after!, compensationMs, clamped, false);
}
if (!after) {
return this.compensated(before, compensationMs, clamped, false);
}
if (lerp) {
return this.interpolate(
before,
after,
rewind,
compensationMs,
clamped,
lerp
);
}
return this.compensated(
pickClosest(before, after, rewind),
compensationMs,
clamped,
false
);
}
public validateHit<TReq extends HitValidationRequest<TState>>(
request: TReq,
lerp?: (a: TState, b: TState, t: number) => TState
): HitValidationResult<TState> {
this.stats.hitValidations++;
const shooterLatency = this.playerLatencies.get(request.shooterId);
const rttComp =
this.config.enableRttCompensation && shooterLatency
? shooterLatency.rtt / 2
: 0;
const perceivedTime = request.actionTimestamp - rttComp;
const shooterState = this.getStateAtTime(
request.shooterId,
perceivedTime,
request.serverTimestamp,
lerp
);
const targetState = this.getStateAtTime(
request.targetId,
perceivedTime,
request.serverTimestamp,
lerp
);
if (!shooterState || !targetState) {
this.stats.invalidHits++;
return {
valid: false,
shooterState,
targetState,
totalCompensationMs: 0,
reason: !shooterState
? "shooter state not found"
: "target state not found",
};
}
const valid = request.validator(shooterState.state, targetState.state);
if (valid) this.stats.validHits++;
else this.stats.invalidHits++;
const result: HitValidationResult<TState> = {
valid,
shooterState,
targetState,
totalCompensationMs:
shooterState.compensationMs + targetState.compensationMs,
...(valid ? {} : { reason: "validation failed" }),
};
this.emit("hit:validated", result);
return result;
}
public getTrackedEntities(): string[] {
return [...this.stateHistory.keys()];
}
public clearEntityHistory(entityId: string): void {
this.stateHistory.delete(entityId);
this.stats.historySize = this.totalHistorySize();
}
public removePlayer(playerId: string): void {
this.playerLatencies.delete(playerId);
this.rttSamples.delete(playerId);
}
public getStats(): LagCompensatorStats {
return { ...this.stats };
}
public resetStats(): void {
this.stats = initialStats();
this.totalCompensationMs = 0;
}
public dispose(): void {
this.stateHistory.clear();
this.playerLatencies.clear();
this.rttSamples.clear();
this.listeners.clear();
this.resetStats();
}
private interpolate(
before: StateSnapshot<TState>,
after: StateSnapshot<TState>,
rewind: number,
compensationMs: number,
clamped: boolean,
lerp: (a: TState, b: TState, t: number) => TState
): CompensatedState<TState> {
// `findNeighbors` guarantees after.timestamp > before.timestamp.
const raw =
(rewind - before.timestamp) / (after.timestamp - before.timestamp);
const f = this.config.interpolationFactor;
const t = Math.max(0, Math.min(1, raw * f + (1 - f) * raw));
this.stats.interpolatedLookups++;
const result: CompensatedState<TState> = {
state: lerp(before.state, after.state, t),
timestamp: rewind,
tick: Math.round(before.tick + (after.tick - before.tick) * t),
interpolated: true,
compensationMs,
clamped,
};
this.emit("state:rewound", result);
return result;
}
private compensated(
snapshot: StateSnapshot<TState>,
compensationMs: number,
clamped: boolean,
interpolated: boolean
): CompensatedState<TState> {
const result: CompensatedState<TState> = {
state: snapshot.state,
timestamp: snapshot.timestamp,
tick: snapshot.tick,
interpolated,
compensationMs,
clamped,
};
this.emit("state:rewound", result);
return result;
}
private totalHistorySize(): number {
let total = 0;
for (const h of this.stateHistory.values()) total += h.length;
return total;
}
private emit<E extends keyof LagCompensatorEventMap<TState>>(
event: E,
...args: Parameters<LagCompensatorEventMap<TState>[E]>
): void {
const set = this.listeners.get(event);
if (!set) return;
for (const fn of set) {
(fn as (...a: unknown[]) => void)(...args);
}
}
}

View File

@@ -0,0 +1,77 @@
import z from "zod";
export const LagCompensatorConfigSchema = z
.object({
maxHistorySize: z.number().int().positive().optional(),
maxCompensationMs: z.number().int().positive().optional(),
tickRate: z.number().int().positive().optional(),
enableRttCompensation: z.boolean().optional(),
interpolationFactor: z.number().min(0).max(1).optional(),
})
.strict();
export type LagCompensatorConfig = z.infer<typeof LagCompensatorConfigSchema>;
export interface StateSnapshot<TState> {
readonly tick: number;
readonly timestamp: number;
readonly state: TState;
readonly isServerState: boolean;
}
export interface PlayerLatency {
readonly playerId: string;
readonly rtt: number;
readonly averageRtt: number;
readonly minRtt: number;
readonly maxRtt: number;
readonly jitter: number;
readonly lastUpdated: number;
readonly sampleCount: number;
}
export interface CompensatedState<TState> {
readonly state: TState;
readonly timestamp: number;
readonly tick: number;
readonly interpolated: boolean;
readonly compensationMs: number;
readonly clamped: boolean;
}
export interface HitValidationRequest<TState> {
readonly shooterId: string;
readonly targetId: string;
readonly actionTimestamp: number;
readonly serverTimestamp: number;
readonly validator: (shooter: TState, target: TState) => boolean;
}
export interface HitValidationResult<TState> {
readonly valid: boolean;
readonly shooterState: CompensatedState<TState> | null;
readonly targetState: CompensatedState<TState> | null;
readonly totalCompensationMs: number;
readonly reason?: string;
}
export interface LagCompensatorStats {
totalLookups: number;
interpolatedLookups: number;
clampedLookups: number;
hitValidations: number;
validHits: number;
invalidHits: number;
historySize: number;
averageCompensationMs: number;
}
export interface LagCompensatorEventMap<TState> {
"latency:updated": (playerId: string, latency: PlayerLatency) => void;
"state:recorded": (
entityId: string,
snapshot: StateSnapshot<TState>
) => void;
"state:rewound": (state: CompensatedState<TState>) => void;
"hit:validated": (result: HitValidationResult<TState>) => void;
"history:pruned": (prunedCount: number) => void;
}

Some files were not shown because too many files have changed in this diff Show More