Initial Commit
This commit is contained in:
166
packages/gaming/games/game-server/README.md
Normal file
166
packages/gaming/games/game-server/README.md
Normal 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)
|
||||
|
||||
403
packages/gaming/games/game-server/package.json
Normal file
403
packages/gaming/games/game-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
# `AbstractGameLogic` — Template-Method base class for game rules
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AbstractGameLogic<TState, TMove>` is the abstract base every concrete
|
||||
game type extends. It implements the `IGameLogic` contract,
|
||||
orchestrates the `processMove` template method (validate → before hook →
|
||||
apply → state install → after hook → instrument), owns the player
|
||||
roster, and threads an injected `Clock` + `IdGenerator` so subclass move
|
||||
logic is deterministic by default. Every concrete game (chess, bingo,
|
||||
gem-crush, tic-tac-toe) fills in the five rule-specific abstract methods
|
||||
and optionally overrides the five lifecycle hooks; the base class
|
||||
handles the framework integration, instrumentation, and error
|
||||
discipline.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `IGameLogic` end-to-end — subclasses inherit `processMove`,
|
||||
`getGameState`, `dispose`, and player-roster utilities for free.
|
||||
- Injected `Clock` + `IdGenerator` — never calls `Date.now()` or
|
||||
`crypto.randomUUID()` directly; tests use fakes for determinism.
|
||||
- OTel instrumentation built in — every `processMove` records the
|
||||
`game_server.logic.tick_duration_ms` histogram and the
|
||||
`game_server.logic.moves_total` / `rejections_total` counters under the
|
||||
meter `@suaveplan/game-server` (override with `meterName`).
|
||||
- Typed errors — invalid moves, duplicate players, and missing players
|
||||
throw `GameServerError` with `INVALID_MOVE` / `PLAYER_ALREADY_EXISTS` /
|
||||
`PLAYER_NOT_FOUND` codes (no plain `Error`).
|
||||
- Lifecycle hooks (`beforeMoveProcessing`, `afterMoveProcessing`,
|
||||
`onPlayerAdded`, `onPlayerRemoved`, `onDispose`) — default no-ops;
|
||||
subclasses override only what they need.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbstractGameLogic,
|
||||
type GameResult,
|
||||
type MoveValidationResult,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
interface Counter { count: number; }
|
||||
type CounterMove = { delta: number };
|
||||
|
||||
class CounterGame extends AbstractGameLogic<Counter, CounterMove> {
|
||||
validateMove(_id: string, m: CounterMove): MoveValidationResult {
|
||||
return m.delta >= 0 ? this.valid() : this.invalid("negative");
|
||||
}
|
||||
protected applyMove(_id: string, m: CounterMove, s: Counter): Counter {
|
||||
return { count: s.count + m.delta };
|
||||
}
|
||||
isGameOver(s: Counter): boolean { return s.count >= 10; }
|
||||
getGameResult(s: Counter): GameResult {
|
||||
return { winner: s.count >= 10 ? "alice" : null };
|
||||
}
|
||||
initializeGame(): Counter { return { count: 0 }; }
|
||||
}
|
||||
|
||||
const game = new CounterGame({ initialState: { count: 0 } });
|
||||
game.addPlayer("alice");
|
||||
game.processMove("alice", { delta: 3 }, game.getGameState());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake clock and id generator for deterministic replay tests, and
|
||||
hook into the move-lifecycle to emit custom analytics:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbstractGameLogic,
|
||||
type GameResult,
|
||||
type MoveValidationResult,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
class ReplayableGame extends AbstractGameLogic<{ count: number }, { delta: number }> {
|
||||
private readonly moves: Array<{ id: string; delta: number; at: number }> = [];
|
||||
|
||||
validateMove(_id: string, m: { delta: number }): MoveValidationResult {
|
||||
return m.delta > 0 ? this.valid() : this.invalid("non-positive");
|
||||
}
|
||||
protected applyMove(_id: string, m: { delta: number }, s: { count: number }) {
|
||||
return { count: s.count + m.delta };
|
||||
}
|
||||
protected afterMoveProcessing(playerId: string, move: { delta: number }) {
|
||||
this.moves.push({
|
||||
id: this.nextId(),
|
||||
delta: move.delta,
|
||||
at: this.clock(),
|
||||
});
|
||||
}
|
||||
isGameOver(s: { count: number }) { return s.count >= 100; }
|
||||
getGameResult(s: { count: number }): GameResult {
|
||||
return { winner: s.count >= 100 ? "alice" : null };
|
||||
}
|
||||
initializeGame() { return { count: 0 }; }
|
||||
|
||||
getMoves() { return [...this.moves]; }
|
||||
}
|
||||
|
||||
let t = 0;
|
||||
const game = new ReplayableGame({
|
||||
initialState: { count: 0 },
|
||||
clock: () => ++t,
|
||||
idGenerator: () => `m-${t}`,
|
||||
});
|
||||
game.addPlayer("alice");
|
||||
game.processMove("alice", { delta: 5 }, game.getGameState());
|
||||
console.log(game.getMoves()); // [{ id: "m-1", delta: 5, at: 2 }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new AbstractGameLogic({ initialState, clock?, idGenerator?, meterName? })`
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `initialState` | required | Starting `TState`. |
|
||||
| `clock` | `() => Date.now()` | Injectable. |
|
||||
| `idGenerator` | `() => crypto.randomUUID()` | Injectable. |
|
||||
| `meterName` | `"@suaveplan/game-server"` | OTel meter. |
|
||||
|
||||
### Public methods
|
||||
|
||||
- `processMove(playerId, move, state): TState` — template method.
|
||||
- `getGameState(): TState`
|
||||
- `addPlayer(playerId, data?): void`
|
||||
- `removePlayer(playerId): void`
|
||||
- `getPlayer(playerId): unknown`
|
||||
- `hasPlayer(playerId): boolean`
|
||||
- `getPlayerIds(): ReadonlyArray<string>`
|
||||
- `getPlayerCount(): number`
|
||||
- `dispose(): void`
|
||||
|
||||
### Abstract methods (subclass MUST implement)
|
||||
|
||||
- `validateMove(playerId, move, state): MoveValidationResult`
|
||||
- `applyMove(playerId, move, state): TState` (protected)
|
||||
- `isGameOver(state): boolean`
|
||||
- `getGameResult(state): GameResult`
|
||||
- `initializeGame(config): TState`
|
||||
|
||||
### Hooks (subclass MAY override)
|
||||
|
||||
- `beforeMoveProcessing(playerId, move)`
|
||||
- `afterMoveProcessing(playerId, move, newState)`
|
||||
- `onPlayerAdded(playerId, data?)`
|
||||
- `onPlayerRemoved(playerId, data?)`
|
||||
- `onDispose()`
|
||||
|
||||
### Errors thrown
|
||||
|
||||
| Code | Cause |
|
||||
| --- | --- |
|
||||
| `GAME_SERVER.INVALID_MOVE` | `validateMove` returned `{ valid: false }`. |
|
||||
| `GAME_SERVER.PLAYER_ALREADY_EXISTS` | `addPlayer` on a known id. |
|
||||
| `GAME_SERVER.PLAYER_NOT_FOUND` | `removePlayer` on an unknown id. |
|
||||
|
||||
### OTel instruments
|
||||
|
||||
| Name | Kind | Attributes |
|
||||
| --- | --- | --- |
|
||||
| `game_server.logic.tick_duration_ms` | Histogram | none |
|
||||
| `game_server.logic.moves_total` | Counter | none |
|
||||
| `game_server.logic.rejections_total` | Counter | `reason` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`processMove` clamps the elapsed time with `Math.max(0, ...)` so a
|
||||
non-monotonic clock (fake or wall clock during NTP step-back) never
|
||||
records a negative histogram value. Instruments are cached by `meterName`
|
||||
— passing different names rebinds them, passing the same name reuses the
|
||||
existing meter so multiple `AbstractGameLogic` instances share one set
|
||||
of counters. `cloneState` uses JSON round-trip by default — subclasses
|
||||
holding non-JSON-safe state (Maps, Sets, Dates, class instances) should
|
||||
override it.
|
||||
@@ -0,0 +1,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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AbstractGameLogic } from "./abstract-game-logic.js";
|
||||
export type { AbstractGameLogicOptions } from "./abstract-game-logic.types.js";
|
||||
@@ -0,0 +1,132 @@
|
||||
# `GameEventEmitter` — typed event emitter for the game framework
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameEventEmitter` is the typed event hub every game-server subsystem
|
||||
publishes to and listens on. It exposes the standard `on`/`once`/`off`/
|
||||
`emit` surface, plus four ergonomic extensions (`safeEmit`, `waitFor`,
|
||||
`onMultiple`, `onN`) that solve the common patterns observed in upstream
|
||||
game-server code without forcing callers to hand-roll wrappers. Listener
|
||||
payloads are a single, named, type-safe object per event key
|
||||
(`GameEvents[K]`), not the variadic tuples upstream used — this fits
|
||||
`@suaveplan/events`-style single-payload listeners and keeps the surface
|
||||
predictable when wiring into reactive bindings.
|
||||
|
||||
## Features
|
||||
|
||||
- Strongly-typed `on`/`once`/`emit` — payload type is inferred from the
|
||||
event key, no casts at call sites.
|
||||
- `safeEmit(event, payload)` — listener exceptions are caught and re-
|
||||
emitted on the `error` channel; the original emission keeps running for
|
||||
the remaining listeners.
|
||||
- `waitFor(event, timeoutMs?)` — returns a `Promise` that resolves with
|
||||
the next payload or rejects with `GameServerError(EVENT_WAIT_TIMEOUT)`
|
||||
after the timeout.
|
||||
- `onN(event, count, listener)` — auto-unsubscribes after `count` fires.
|
||||
- `onMultiple(events, listener)` — single listener observes multiple keys
|
||||
and receives the event name as its first arg.
|
||||
- Configurable per-key listener cap (default 100) — throws
|
||||
`GameServerError(LISTENER_LIMIT_EXCEEDED)` on overflow.
|
||||
- Mid-emit safety: listeners can call `off(...)` on themselves without
|
||||
shifting the iteration index of in-flight listeners.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
|
||||
const events = new GameEventEmitter();
|
||||
const unsub = events.on("game:started", ({ sessionId }) => {
|
||||
console.log("started", sessionId);
|
||||
});
|
||||
events.emit("game:started", { sessionId: "s1" });
|
||||
unsub();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wait for a `game:ended` event with a 30s ceiling, and use `safeEmit` so a
|
||||
faulty analytics listener can't bring down the rest of the pipeline:
|
||||
|
||||
```ts
|
||||
import {
|
||||
GameEventEmitter,
|
||||
GAME_SERVER_CODES,
|
||||
GameServerError,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
const events = new GameEventEmitter({ maxListenersPerKey: 250 });
|
||||
|
||||
events.on("error", ({ error }) => console.error("listener crashed", error));
|
||||
|
||||
events.on("game:ended", ({ sessionId, result }) => {
|
||||
if (Math.random() < 0.001) throw new Error("analytics flaked");
|
||||
console.log(`${sessionId} winner: ${result.winner}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const { sessionId } = await events.waitFor("game:ended", 30_000);
|
||||
console.log("game ended:", sessionId);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.EVENT_WAIT_TIMEOUT
|
||||
) {
|
||||
console.warn("no game ended in 30s — timing out");
|
||||
}
|
||||
}
|
||||
|
||||
// Elsewhere in the engine — safeEmit so analytics crash doesn't propagate.
|
||||
events.safeEmit("game:ended", {
|
||||
sessionId: "s1",
|
||||
result: { winner: "alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new GameEventEmitter(options?: GameEventEmitterOptions)`
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `maxListenersPerKey` | `100` | Set to `Infinity` to disable. |
|
||||
|
||||
### Methods
|
||||
|
||||
- `on(event, listener): Unsubscribe`
|
||||
- `once(event, listener): Unsubscribe`
|
||||
- `off(event, listener): void`
|
||||
- `emit(event, payload): boolean` — `true` iff at least one listener was invoked.
|
||||
- `safeEmit(event, payload): boolean` — listener errors emit on `"error"`.
|
||||
- `onMultiple(events, listener): Unsubscribe` — listener `(event, payload)`.
|
||||
- `waitFor(event, timeoutMs?): Promise<GameEvents[K]>`
|
||||
- `onN(event, count, listener): Unsubscribe` — `count > 0` required.
|
||||
- `listenerCount(event): number`
|
||||
- `removeAllListeners(): void`
|
||||
|
||||
### Errors thrown
|
||||
|
||||
| Code | Cause |
|
||||
| --- | --- |
|
||||
| `GAME_SERVER.LISTENER_LIMIT_EXCEEDED` | More than `maxListenersPerKey` listeners on one key. |
|
||||
| `GAME_SERVER.INVALID_LISTENER_COUNT` | `onN(count <= 0)`. |
|
||||
| `GAME_SERVER.EVENT_WAIT_TIMEOUT` | `waitFor(..., timeoutMs)` elapsed. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`emit` and `safeEmit` iterate a shallow snapshot (`[...list]`) so a
|
||||
listener that calls `off` for itself mid-fire does not cause the loop to
|
||||
skip the next listener. `safeEmit` deliberately does not recurse through
|
||||
`safeEmit("error", ...)` — it emits the `error` event directly and
|
||||
swallows secondary failures, because re-routing a failed error handler
|
||||
into the same `safeEmit` pipeline would mask the original cause.
|
||||
`waitFor(timeoutMs)` only schedules a timer when `timeoutMs` is a positive
|
||||
number — passing `0` is treated as "no timeout" so callers don't get a
|
||||
race condition between the immediate timeout-reject and the
|
||||
resolution path.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { GameEventEmitter } from "./game-event-emitter.js";
|
||||
export type {
|
||||
GameEventEmitterOptions,
|
||||
GameEventListener,
|
||||
GameEventUnsubscribe,
|
||||
} from "./game-event-emitter.types.js";
|
||||
@@ -0,0 +1,105 @@
|
||||
# `GameEvents` map and `GAME_EVENT_KEYS` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the typed event-payload map every game-server consumer
|
||||
keys off — players, sessions, game lifecycle, moves, and errors — plus
|
||||
the closed array `GAME_EVENT_KEYS` and predicate `isGameEventKey` that
|
||||
let downstream code enumerate or validate event names without hard-coding
|
||||
strings everywhere. suaveplan collapses upstream's variadic `[a, b]` tuples
|
||||
to single object payloads so the map satisfies `@suaveplan/events`'
|
||||
`EventMap = Record<string, unknown>` contract and listeners receive a
|
||||
single, named, shape-safe argument.
|
||||
|
||||
## Features
|
||||
|
||||
- Compile-time typed map (`GameEvents` interface in
|
||||
`../../types/core.types.ts`) — every key has a single payload shape.
|
||||
- Runtime registry (`GAME_EVENT_KEYS`) — readonly array of all 25
|
||||
well-known event names, in declaration order.
|
||||
- Predicate (`isGameEventKey(value)`) — narrows a `string` to a
|
||||
`keyof GameEvents` for safe wire-input validation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
GAME_EVENT_KEYS,
|
||||
isGameEventKey,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
// Enumerate every event for analytics setup
|
||||
for (const key of GAME_EVENT_KEYS) {
|
||||
console.log("registering listener for", key);
|
||||
}
|
||||
|
||||
// Validate untrusted input
|
||||
function onWireMessage(raw: { event: string }): void {
|
||||
if (!isGameEventKey(raw.event)) {
|
||||
throw new Error(`unknown event: ${raw.event}`);
|
||||
}
|
||||
// raw.event is now `keyof GameEvents`
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the registry to mass-wire per-event counters at server startup so
|
||||
every emitted event is recorded without copy-pasting `metricsCounter`
|
||||
registrations:
|
||||
|
||||
```ts
|
||||
import {
|
||||
GAME_EVENT_KEYS,
|
||||
GameEventEmitter,
|
||||
} from "@suaveplan/game-server";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
export function instrumentEvents(emitter: GameEventEmitter): void {
|
||||
const meter = metrics.getMeter("@suaveplan/game-server");
|
||||
for (const key of GAME_EVENT_KEYS) {
|
||||
const counter = meter.createCounter(
|
||||
`game_server.events.${key.replace(/:/g, "_")}.total`,
|
||||
{ description: `Total ${key} events` }
|
||||
);
|
||||
emitter.on(key, () => counter.add(1));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `GAME_EVENT_KEYS: ReadonlyArray<keyof GameEvents>`
|
||||
|
||||
Frozen array literal. Declaration order matches the source-of-truth in
|
||||
`core.types.ts` so callers can build deterministic indexes off the array
|
||||
position.
|
||||
|
||||
### `isGameEventKey(value: string): value is keyof GameEvents`
|
||||
|
||||
Returns `true` iff `value` is a member of `GAME_EVENT_KEYS`. Narrows the
|
||||
input on the truthy branch.
|
||||
|
||||
### `GameEvents` (re-exported from `@suaveplan/game-server`)
|
||||
|
||||
| Group | Keys |
|
||||
| --- | --- |
|
||||
| Player | `player:connected`, `player:disconnected`, `player:reconnected`, `player:timeout`, `player:heartbeat`, `player:joined`, `player:left` |
|
||||
| Session | `session:created`, `session:destroyed`, `session:updated`, `session:metadata:changed` |
|
||||
| Game | `game:initialized`, `game:started`, `game:ended`, `game:paused`, `game:resumed`, `game:state:changed` |
|
||||
| Move | `move:requested`, `move:validated`, `move:processed`, `move:rejected` |
|
||||
| Error | `error`, `error:game`, `error:network`, `error:state` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The keys are deliberately declared in a single `const` array rather than
|
||||
derived via `Object.keys(...)` so the runtime registry stays exhaustive
|
||||
even if a future type-level edit adds a key without touching the array.
|
||||
The `conformance` gate covers the parity between
|
||||
`GAME_EVENT_KEYS` and the `GameEvents` interface — a key added to one
|
||||
must be added to the other, and missing entries surface as broken
|
||||
conformance tests rather than runtime silent failures.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GAME_EVENT_KEYS, isGameEventKey } from "./game-events.js";
|
||||
@@ -0,0 +1,122 @@
|
||||
# `IGameLogic` — strategy contract for pluggable game rules
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IGameLogic` is the strategy-pattern contract every concrete game type plugs
|
||||
into the universal game server through. The interface defines the seven
|
||||
methods (`validateMove`, `processMove`, `getGameState`, `isGameOver`,
|
||||
`getGameResult`, `initializeGame`, `dispose`) that the engine, session
|
||||
manager, and event pipeline all call against; concrete games — chess,
|
||||
bingo, tic-tac-toe, gem-crush — implement these methods to expose their
|
||||
rules to the framework. The companion `runIGameLogicConformance` harness
|
||||
gives migrators and contract testers a runtime probe that exercises every
|
||||
method against a caller-supplied factory and returns a structured pass/fail
|
||||
result list — interfaces alone are types-only and contribute no runtime
|
||||
coverage, so the harness is the bridge that makes the contract testable.
|
||||
|
||||
## Features
|
||||
|
||||
- Fully generic on `TState` and `TMove` — no `any` escape hatches; callers
|
||||
thread their own state and move types end-to-end.
|
||||
- Synchronous surface — every method returns directly so move processing
|
||||
can be driven by tight loops (matchmaking ticks, replay scrubbing).
|
||||
- Side-effect-free contract — `validateMove` MUST NOT mutate state; only
|
||||
`processMove` and `initializeGame` install state.
|
||||
- Conformance harness `runIGameLogicConformance(factory, fixtures)` runs
|
||||
the eight scenarios listed below and reports each as a
|
||||
`ConformanceCheckResult`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IGameLogic } from "@suaveplan/game-server";
|
||||
|
||||
class Counter implements IGameLogic<{ count: number }, { delta: number }> {
|
||||
private state = { count: 0 };
|
||||
initializeGame(): { count: number } {
|
||||
this.state = { count: 0 };
|
||||
return this.state;
|
||||
}
|
||||
getGameState() { return this.state; }
|
||||
validateMove(_id: string, move: { delta: number }) {
|
||||
return move.delta >= 0
|
||||
? { valid: true as const }
|
||||
: { valid: false as const, reason: "negative" };
|
||||
}
|
||||
processMove(id: string, move: { delta: number }, state: { count: number }) {
|
||||
const v = this.validateMove(id, move, state);
|
||||
if (!v.valid) throw new Error(v.reason);
|
||||
this.state = { count: state.count + move.delta };
|
||||
return this.state;
|
||||
}
|
||||
isGameOver(s: { count: number }) { return s.count >= 10; }
|
||||
getGameResult(s: { count: number }) {
|
||||
return { winner: s.count >= 10 ? "p1" : null };
|
||||
}
|
||||
dispose(): void { /* release resources */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Verify a third-party game implementation against the contract before
|
||||
plugging it into the engine:
|
||||
|
||||
```ts
|
||||
import { runIGameLogicConformance } from "@suaveplan/game-server";
|
||||
|
||||
const results = runIGameLogicConformance(() => new Counter(), {
|
||||
config: { start: 0 },
|
||||
playerId: "p1",
|
||||
validMove: { delta: 1 },
|
||||
invalidMove: { delta: -1 },
|
||||
});
|
||||
|
||||
const failures = results.filter((r) => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Implementation does not satisfy IGameLogic: ${failures
|
||||
.map((r) => `${r.scenario} (${r.detail ?? "no detail"})`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IGameLogic<TState, TMove>`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `validateMove(playerId, move, state)` | `MoveValidationResult` | Side-effect-free. |
|
||||
| `processMove(playerId, move, state)` | `TState` | MAY throw if validation fails. |
|
||||
| `getGameState()` | `TState` | Returns the implementation's installed state. |
|
||||
| `isGameOver(state)` | `boolean` | Pure predicate over `state`. |
|
||||
| `getGameResult(state)` | `GameResult` | Called once `isGameOver` returns true. |
|
||||
| `initializeGame(config)` | `TState` | Resets state from `Record<string, unknown>`. |
|
||||
| `dispose()` | `void` | Idempotent cleanup. |
|
||||
|
||||
### `runIGameLogicConformance(factory, fixtures)`
|
||||
|
||||
- `factory: IGameLogicFactory<TState, TMove>` — zero-arg factory producing
|
||||
a fresh impl. Called once per harness run.
|
||||
- `fixtures: IGameLogicFixtures<TState, TMove>` — `config`, `playerId`,
|
||||
`validMove`, `invalidMove`.
|
||||
- Returns `ConformanceCheckResult[]` — 8 entries, one per scenario.
|
||||
- Does not throw. Failures are captured as `{ passed: false, detail }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness deliberately runs scenarios in dependency order:
|
||||
`initializeGame` → `getGameState` → `validateMove` truthy → `validateMove`
|
||||
falsy → `processMove` → `isGameOver` → `getGameResult` → `dispose`. Each
|
||||
step uses the state returned by the previous one so the harness mirrors a
|
||||
real game tick. Because `processMove` is the only mutating call, the
|
||||
harness does not assert internal-state invariants beyond what `getGameState`
|
||||
returns — implementations that internally cache state with non-trivial
|
||||
semantics (CRDTs, event-sourced) should add their own integration tests
|
||||
in addition to running the conformance harness.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type ConformanceCheckResult,
|
||||
type IGameLogic,
|
||||
type IGameLogicFactory,
|
||||
type IGameLogicFixtures,
|
||||
runIGameLogicConformance,
|
||||
} from "./i-game-logic.js";
|
||||
@@ -0,0 +1,97 @@
|
||||
# `IPlayerManager` — player connection and lifecycle contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IPlayerManager` is the contract every connection-tracking implementation
|
||||
must satisfy so the engine, session manager, matchmaker, and analytics
|
||||
layer can address players uniformly. It owns the eleven lifecycle methods
|
||||
(`connect`, `disconnect`, `getConnection`, `getSessionPlayers`,
|
||||
`getAllConnections`, `heartbeat`, `checkTimeouts`, `updateMetadata`,
|
||||
`getPlayer`, `startHeartbeatMonitor`, `stopHeartbeatMonitor`) and the
|
||||
companion conformance harness `runIPlayerManagerConformance` that
|
||||
exercises each one end-to-end. Implementations are free to back the
|
||||
records with in-memory maps, Redis, SurrealDB, or any transport-specific
|
||||
store as long as the contract holds.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel-agnostic — `metadata` is `Record<string, unknown>` so transports
|
||||
(WebSocket, WebRTC, HTTP long-poll) can attach their own session shape.
|
||||
- Heartbeat-aware — `heartbeat`, `checkTimeouts`, `startHeartbeatMonitor`,
|
||||
`stopHeartbeatMonitor` give the lifecycle owner full timing control.
|
||||
- Session-aware — `getSessionPlayers(sessionId)` and the `sessionId` field
|
||||
let session managers do cheap per-session lookups.
|
||||
- Readonly returns — `getSessionPlayers` and `getAllConnections` return
|
||||
`ReadonlyArray<PlayerConnection>` so callers cannot accidentally mutate
|
||||
internal storage.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IPlayerManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: IPlayerManager;
|
||||
|
||||
const conn = mgr.connect("alice", "table:42", { region: "eu" });
|
||||
mgr.heartbeat("alice");
|
||||
mgr.updateMetadata("alice", { region: "us" });
|
||||
mgr.disconnect("alice", "client closed");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness as part of an integration test for a new
|
||||
SurrealDB-backed player manager:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runIPlayerManagerConformance } from "@suaveplan/game-server";
|
||||
import { SurrealPlayerManager } from "./surreal-player-manager.js";
|
||||
|
||||
describe("SurrealPlayerManager", () => {
|
||||
it("passes the IPlayerManager contract", { timeout: 5000 }, () => {
|
||||
const results = runIPlayerManagerConformance(
|
||||
() => new SurrealPlayerManager({ url: process.env.SURREAL_URL! })
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IPlayerManager`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `connect(playerId, sessionId, metadata?)` | `PlayerConnection` | Replaces any existing record for `playerId`. |
|
||||
| `disconnect(playerId, reason)` | `void` | Implementations MAY drop or mark records — the harness accepts either. |
|
||||
| `getConnection(playerId)` | `PlayerConnection \| null` | |
|
||||
| `getSessionPlayers(sessionId)` | `ReadonlyArray<PlayerConnection>` | |
|
||||
| `getAllConnections()` | `ReadonlyArray<PlayerConnection>` | |
|
||||
| `heartbeat(playerId)` | `void` | Updates `lastHeartbeat`. |
|
||||
| `checkTimeouts()` | `void` | MAY transition stale records to `timeout`. |
|
||||
| `updateMetadata(playerId, metadata)` | `void` | Merges into the record's `metadata`. |
|
||||
| `getPlayer(playerId)` | `PlayerInfo \| null` | |
|
||||
| `startHeartbeatMonitor(intervalMs)` | `void` | |
|
||||
| `stopHeartbeatMonitor()` | `void` | |
|
||||
|
||||
### `runIPlayerManagerConformance(factory)`
|
||||
|
||||
- `factory: IPlayerManagerFactory` — produces a fresh `IPlayerManager`.
|
||||
- Returns `IPlayerManagerCheckResult[]` (11 entries) — each carries the
|
||||
scenario name, a pass/fail flag, and optional detail.
|
||||
- Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness intentionally calls `startHeartbeatMonitor` followed
|
||||
immediately by `stopHeartbeatMonitor` and `checkTimeouts` so
|
||||
implementations that schedule background work via `setInterval` can be
|
||||
verified without leaking handles. The harness uses synthetic ids (`p1`,
|
||||
`s1`) — implementations that require validated id formats should expose
|
||||
their parsing as a pre-step or accept the harness fixture ids.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type IPlayerManager,
|
||||
type IPlayerManagerCheckResult,
|
||||
type IPlayerManagerFactory,
|
||||
runIPlayerManagerConformance,
|
||||
} from "./i-player-manager.js";
|
||||
@@ -0,0 +1,102 @@
|
||||
# `ISessionManager` — hierarchical session contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ISessionManager` is the contract every session-tree implementation must
|
||||
satisfy. The session hierarchy is the core organising structure of the
|
||||
game server — a `venue` contains `table`s, a `table` contains `queue`s, a
|
||||
`queue` contains `game`s, a `game` contains `player`s and `spectator`s —
|
||||
and the engine, matchmaker, and persistence layer all walk this tree
|
||||
through the methods defined here. The companion conformance harness
|
||||
`runISessionManagerConformance` exercises every method against a caller-
|
||||
supplied factory and reports per-scenario results.
|
||||
|
||||
## Features
|
||||
|
||||
- Arbitrary nesting depth — children reference parents, parents reference
|
||||
children, both directions are queryable in O(1) for a Map-backed impl.
|
||||
- Async persistence — `persist()` / `restore()` return promises so
|
||||
implementations can hit Redis, SurrealDB, or local storage without
|
||||
bending the contract.
|
||||
- Read-only result arrays — `getSessionsByType`, `getChildren`, and
|
||||
`getAllSessions` return `ReadonlyArray<SessionNode>` so callers cannot
|
||||
mutate internal structures.
|
||||
- Cascading destroy — `destroySession(id)` MUST recursively destroy every
|
||||
descendant. The conformance harness specifically checks this.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ISessionManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: ISessionManager;
|
||||
|
||||
const venue = mgr.createSession(null, {
|
||||
id: "v1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: Date.now(), updatedAt: Date.now() },
|
||||
});
|
||||
const table = mgr.createSession("v1", {
|
||||
id: "t1",
|
||||
type: "table",
|
||||
metadata: { createdAt: Date.now(), updatedAt: Date.now() },
|
||||
});
|
||||
mgr.destroySession("v1"); // removes table too
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness inside a vitest suite to verify a SurrealDB-
|
||||
backed session manager honours the cascade contract:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runISessionManagerConformance } from "@suaveplan/game-server";
|
||||
import { SurrealSessionManager } from "./surreal-session-manager.js";
|
||||
|
||||
describe("SurrealSessionManager", () => {
|
||||
it("passes the ISessionManager contract", { timeout: 30000 }, async () => {
|
||||
const results = await runISessionManagerConformance(
|
||||
() => new SurrealSessionManager({ url: process.env.SURREAL_URL! })
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ISessionManager`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `createSession(parentId, data)` | `SessionNode` | `parentId === null` for top-level. |
|
||||
| `getSession(id)` | `SessionNode \| null` | |
|
||||
| `destroySession(id)` | `void` | Cascades to descendants. |
|
||||
| `getSessionsByType(type)` | `ReadonlyArray<SessionNode>` | |
|
||||
| `getChildren(id)` | `ReadonlyArray<SessionNode>` | Immediate children only. |
|
||||
| `getParent(id)` | `SessionNode \| null` | |
|
||||
| `updateMetadata(id, partial)` | `void` | Shallow merge into metadata. |
|
||||
| `getAllSessions()` | `ReadonlyArray<SessionNode>` | Whole tree, flat. |
|
||||
| `persist()` | `Promise<void>` | |
|
||||
| `restore()` | `Promise<void>` | |
|
||||
|
||||
### `runISessionManagerConformance(factory)`
|
||||
|
||||
- `factory: ISessionManagerFactory` — produces a fresh `ISessionManager`.
|
||||
- Returns `Promise<ISessionManagerCheckResult[]>` (11 entries).
|
||||
- Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness creates one `venue` and one `table` to verify both top-level
|
||||
and nested creation paths. The cascade scenario destroys the root `venue1`
|
||||
and then checks that the previously-created child `table1` is also gone —
|
||||
implementations that leak children when their parent is destroyed will
|
||||
fail this scenario. `persist` and `restore` are called against an in-
|
||||
flight tree (i.e. without prior data) so implementations that require
|
||||
warm caches need to seed their own tests in addition to the harness.
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type ISessionManager,
|
||||
type ISessionManagerCheckResult,
|
||||
type ISessionManagerFactory,
|
||||
runISessionManagerConformance,
|
||||
} from "./i-session-manager.js";
|
||||
@@ -0,0 +1,104 @@
|
||||
# `IStateManager` — immutable state contract with observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IStateManager<TState>` is the contract every state-management
|
||||
implementation in the game server stack must satisfy. State managers are
|
||||
the canonical home for per-session game data — board position, score,
|
||||
turn order, replay buffer — and the engine, persistence layer, and
|
||||
sync engine all observe state transitions through `subscribe`. suaveplan
|
||||
returns `Readonly<TState>` instead of `DeepReadonly<TState>` from
|
||||
`getState` because the deep mapped type interacts poorly with Zod-derived
|
||||
shapes and method-bearing objects; callers needing recursive readonly
|
||||
guarantees can layer their own typing on top.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic on `TState extends object` — no escape hatches; every method is
|
||||
fully typed.
|
||||
- Observability built-in — `subscribe(listener)` receives
|
||||
`StateChangeEvent<TState>` with previous + current + timestamp.
|
||||
- Replayable history — `getHistory()` returns the recorded transitions;
|
||||
`clearHistory()` empties them.
|
||||
- Granular updates — `update(key, value)`, `updateMany(partial)`, or
|
||||
`setState(replacement)` cover single-key, multi-key, and total
|
||||
replacement.
|
||||
- `reset()` reverts to the initial state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IStateManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: IStateManager<{ score: number; turn: string }>;
|
||||
|
||||
const unsub = mgr.subscribe((e) => {
|
||||
console.log("transition", e.previousState, "->", e.currentState);
|
||||
});
|
||||
mgr.update("score", 10);
|
||||
mgr.updateMany({ score: 12, turn: "p2" });
|
||||
mgr.setState({ score: 0, turn: "p1" });
|
||||
unsub();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness against a third-party Immer-backed state
|
||||
manager and surface any contract violations:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runIStateManagerConformance } from "@suaveplan/game-server";
|
||||
import { ImmerStateManager } from "./immer-state-manager.js";
|
||||
|
||||
describe("ImmerStateManager", () => {
|
||||
it("passes the IStateManager contract", { timeout: 5000 }, () => {
|
||||
const results = runIStateManagerConformance(
|
||||
() => new ImmerStateManager({ score: 0, turn: "p1" }),
|
||||
{
|
||||
updateKey: "score",
|
||||
updateValue: 5,
|
||||
partialUpdate: { turn: "p2" },
|
||||
replacement: { score: 99, turn: "p1" },
|
||||
}
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IStateManager<TState extends object>`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `getState()` | `Readonly<TState>` | |
|
||||
| `update<K>(key, value)` | `void` | Emits `StateChangeEvent`. |
|
||||
| `updateMany(partial)` | `void` | Single batched event. |
|
||||
| `setState(next)` | `void` | Full replacement. |
|
||||
| `subscribe(listener)` | `() => void` | Returns unsubscribe. |
|
||||
| `reset()` | `void` | Reverts to constructor state. |
|
||||
| `getHistory()` | `ReadonlyArray<StateChangeEvent<TState>>` | |
|
||||
| `clearHistory()` | `void` | |
|
||||
|
||||
### `runIStateManagerConformance(factory, fixtures)`
|
||||
|
||||
- `factory: IStateManagerFactory<TState>` — fresh manager per run.
|
||||
- `fixtures: IStateManagerFixtures<TState>` — drives the harness.
|
||||
- Returns `IStateManagerCheckResult[]` (9 entries).
|
||||
- Does not throw; reports failures in-band.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness counts observed events per scenario rather than asserting on
|
||||
specific payload shapes because `update` / `updateMany` / `setState` may
|
||||
internally coalesce or batch — what matters is that at least one event is
|
||||
delivered per call. The unsubscribe scenario asserts strict equality
|
||||
(`observed === 3`) on the post-unsubscribe call to catch implementations
|
||||
that return a no-op cleanup function. Reset is the last call so it does
|
||||
not interfere with history-buffer scenarios.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type IStateManager,
|
||||
type IStateManagerCheckResult,
|
||||
type IStateManagerFactory,
|
||||
type IStateManagerFixtures,
|
||||
runIStateManagerConformance,
|
||||
} from "./i-state-manager.js";
|
||||
144
packages/gaming/games/game-server/src/core/types/core.types.ts
Normal file
144
packages/gaming/games/game-server/src/core/types/core.types.ts
Normal 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 };
|
||||
12
packages/gaming/games/game-server/src/core/types/index.ts
Normal file
12
packages/gaming/games/game-server/src/core/types/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
ConnectionStatus,
|
||||
GameEvents,
|
||||
GameResultRef,
|
||||
PlayerConnection,
|
||||
PlayerInfo,
|
||||
SessionData,
|
||||
SessionMetadata,
|
||||
SessionNode,
|
||||
SessionType,
|
||||
StateChangeEvent,
|
||||
} from "./core.types.js";
|
||||
@@ -0,0 +1,131 @@
|
||||
# DIContainer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DIContainer` is a small, type-safe service locator that the broader
|
||||
`@suaveplan/game-server` engine uses to thread shared dependencies — a
|
||||
`Clock`, a `LoggerLike`, an `EventBus`, networking adapters, plugin
|
||||
registries — through component boundaries without forcing every call
|
||||
site to take a deep parameter list. It is intentionally minimal: register
|
||||
a factory under a string or symbol identifier, resolve to either a cached
|
||||
singleton or a fresh transient instance, and inspect what is registered
|
||||
for testing.
|
||||
|
||||
This file is the first feature migrated from the reference branch
|
||||
`claude/review-game-engine-arch-Kaykr`
|
||||
(`src/engine/DIContainer.ts`). The port replaces the upstream's
|
||||
plain `throw new Error(...)` on unresolved identifiers with a typed
|
||||
`GameServerError` carrying the stable code
|
||||
`GAME_SERVER.SERVICE_NOT_REGISTERED`, so consumers can branch on
|
||||
`error.code` rather than scraping message strings.
|
||||
|
||||
## Features
|
||||
|
||||
- **Singleton lifecycle** — factory invoked once; the instance is cached.
|
||||
- **Transient lifecycle** — factory invoked on every `resolve`.
|
||||
- **Typed identifiers** — generic parameter on `ServiceIdentifier<T>` carries through `register` and `resolve`.
|
||||
- **Fluent API** — `register` returns `this` for chaining.
|
||||
- **Re-register invalidates cache** — calling `register` again clears any cached singleton instance (improvement over upstream).
|
||||
- **Typed error on miss** — `GameServerError(GAME_SERVER.SERVICE_NOT_REGISTERED)`.
|
||||
- **Introspection** — `has`, `getRegisteredServices`, and `clear` round out the surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { DIContainer } from "@suaveplan/game-server/engine/di-container";
|
||||
|
||||
const container = new DIContainer();
|
||||
|
||||
container.register("logger", () => new ConsoleLogger(), "singleton");
|
||||
container.register("requestId", () => crypto.randomUUID(), "transient");
|
||||
|
||||
const logger1 = container.resolve<ConsoleLogger>("logger");
|
||||
const logger2 = container.resolve<ConsoleLogger>("logger");
|
||||
console.log(logger1 === logger2); // true — singleton
|
||||
|
||||
const r1 = container.resolve<string>("requestId");
|
||||
const r2 = container.resolve<string>("requestId");
|
||||
console.log(r1 === r2); // false — transient
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { DIContainer } from "@suaveplan/game-server/engine/di-container";
|
||||
import { GAME_SERVER_CODES, GameServerError } from "@suaveplan/game-server/errors";
|
||||
|
||||
interface IClock { now(): number }
|
||||
const CLOCK = Symbol("Clock") as ServiceIdentifier<IClock>;
|
||||
|
||||
const container = new DIContainer()
|
||||
.register<IClock>(CLOCK, () => ({ now: () => Date.now() }))
|
||||
.register("events", () => new EventBus());
|
||||
|
||||
try {
|
||||
container.resolve("nope");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.SERVICE_NOT_REGISTERED
|
||||
) {
|
||||
// surface a typed missing-dep diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
// Re-registering invalidates the cached singleton:
|
||||
container.register<IClock>(CLOCK, () => new TestClock());
|
||||
const freshClock = container.resolve<IClock>(CLOCK); // new TestClock instance
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DIContainer`
|
||||
|
||||
#### `register<T>(identifier, factory, lifecycle = "singleton"): this`
|
||||
|
||||
Register a service. Re-registering an identifier replaces the factory
|
||||
and discards any cached singleton instance.
|
||||
|
||||
#### `resolve<T>(identifier): T`
|
||||
|
||||
Resolve a registered service. Throws `GameServerError` with code
|
||||
`GAME_SERVER.SERVICE_NOT_REGISTERED` if the identifier is unknown.
|
||||
|
||||
#### `has(identifier): boolean`
|
||||
|
||||
#### `unregister(identifier): boolean`
|
||||
|
||||
Returns `true` when the identifier was previously registered.
|
||||
|
||||
#### `clear(): void`
|
||||
|
||||
Remove every registration and cached singleton.
|
||||
|
||||
#### `getRegisteredServices(): ServiceIdentifier[]`
|
||||
|
||||
### Types
|
||||
|
||||
- `ServiceIdentifier<T = unknown>` — `string | symbol`.
|
||||
- `ServiceLifecycle` — `"singleton" | "transient"`.
|
||||
- `ServiceDefinition<T>` — internal record holding identifier, factory, singleton flag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Re-register-invalidates-cache is the only behavioural change versus the
|
||||
upstream. The upstream kept the stale singleton after a second
|
||||
`register` call, which masked test-double swaps — code that worked in
|
||||
unit tests because the test installed its fake first, but failed in
|
||||
integration because production startup happened to register the real
|
||||
service first. Discarding the cached instance on re-register matches the
|
||||
"last writer wins" semantics every other suaveplan registry uses
|
||||
(`@suaveplan/events`, `@suaveplan/config`).
|
||||
|
||||
The unresolved-identifier path raises `GameServerError`, not a plain
|
||||
`Error`, because everything in this package is expected to be
|
||||
discriminable via `error.code`. `String(identifier)` is included in
|
||||
both the message and the `context` bag so symbols and plain strings
|
||||
surface identically in logs.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DIContainer } from "./di-container.js";
|
||||
export type {
|
||||
ServiceDefinition,
|
||||
ServiceIdentifier,
|
||||
ServiceLifecycle,
|
||||
} from "./di-container.types.js";
|
||||
@@ -0,0 +1,153 @@
|
||||
# GameEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameEngine` is the top-level orchestrator for `@suaveplan/game-server`. It owns a `DIContainer`, a `GameLoop`, and a `PluginManager`, plus opaque structural slots (`sessionManager`, `playerManager`, `stateManager`, `gameLogic`) that downstream waves fill in. The engine never reaches into those slots — it only registers them in the container and disposes them on shutdown — so this Wave B-1 slice can land before the concrete session/state/network packages are ready and stay stable as they arrive.
|
||||
|
||||
The class enforces a strict lifecycle (`start → tick* → stop → dispose`) and rejects out-of-order calls with the corresponding `GameServerError`: `ENGINE_NOT_STARTED`, `ENGINE_ALREADY_STARTED`, `ENGINE_DISPOSED`. Every transition emits a structured log line, a typed event onto the injected `EventBusLike`, and an OTel `game_server.engine.lifecycle_total{op}` counter sample. Construction Zod-validates the entire options bag at the boundary so callers see a `INVALID_ENGINE_OPTIONS` error immediately rather than later silent misbehaviour.
|
||||
|
||||
## Features
|
||||
|
||||
- Lifecycle FSM with `ENGINE_NOT_STARTED` / `ENGINE_ALREADY_STARTED` / `ENGINE_DISPOSED` guards.
|
||||
- Constructor-time DI registration under `ENGINE_SERVICE_IDS` so plugins and downstream packages can resolve dependencies through a single shared container.
|
||||
- Zod-validated options (`heartbeatIntervalMs`, `playerTimeoutMs`, `autoStartLoop`, `loop`) with `INVALID_ENGINE_OPTIONS` on failure.
|
||||
- `registerPlugin` / `getPlugin` delegates to the underlying `PluginManager`.
|
||||
- `getStats()` returns the union of engine state, loop stats, plugin stats, and session/player counts.
|
||||
- `dispose()` is idempotent and tears down loop, plugins, then opaque slots in reverse-construction order.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { GameEngine } from "@suaveplan/game-server/engine/game-engine";
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const loop = new GameLoop({ clock, timers, logger, events });
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
|
||||
const engine = new GameEngine(
|
||||
{
|
||||
clock,
|
||||
timers,
|
||||
idGenerator,
|
||||
logger,
|
||||
events,
|
||||
gameLoop: loop,
|
||||
pluginManager: plugins,
|
||||
},
|
||||
{ autoStartLoop: true, loop: { mode: "event-driven" } },
|
||||
);
|
||||
|
||||
engine.start();
|
||||
await engine.tick();
|
||||
await engine.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { GameEngine, ENGINE_SERVICE_IDS } from "@suaveplan/game-server/engine/game-engine";
|
||||
|
||||
const engine = new GameEngine(
|
||||
{
|
||||
clock,
|
||||
timers,
|
||||
idGenerator,
|
||||
logger,
|
||||
events,
|
||||
gameLoop,
|
||||
pluginManager,
|
||||
sessionManager,
|
||||
playerManager,
|
||||
stateManager,
|
||||
gameLogic,
|
||||
},
|
||||
{ heartbeatIntervalMs: 5_000, playerTimeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
// Cross-cutting plugin resolves shared services through the engine container:
|
||||
await engine.registerPlugin({
|
||||
name: "telemetry",
|
||||
version: "1.0.0",
|
||||
async onInstall(ctx) {
|
||||
const log = engine.container.resolve(ENGINE_SERVICE_IDS.logger);
|
||||
log.info("telemetry plugin online", { ts: clock() });
|
||||
},
|
||||
});
|
||||
|
||||
engine.start();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await engine.tick();
|
||||
}
|
||||
engine.stop();
|
||||
await engine.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GameEngine`
|
||||
|
||||
#### `new GameEngine(deps, options?)`
|
||||
|
||||
`deps`: `{ clock, timers, idGenerator, logger, events, gameLoop,
|
||||
pluginManager, sessionManager?, playerManager?, stateManager?,
|
||||
gameLogic? }`. Throws `INVALID_ENGINE_OPTIONS` on schema failure.
|
||||
|
||||
#### `start(): void` / `stop(): void` / `tick(): Promise<void>`
|
||||
|
||||
Lifecycle FSM. Wrong-state calls throw the corresponding code.
|
||||
|
||||
#### `registerPlugin(plugin): Promise<void>` / `getPlugin(name): EnginePlugin`
|
||||
|
||||
Delegates to the underlying `PluginManager`.
|
||||
|
||||
#### `getStats(): GameEngineStats`
|
||||
|
||||
Union of `{ running, disposed, loop, plugins, sessions, players }`.
|
||||
|
||||
#### `isRunning(): boolean` / `isDisposed(): boolean`
|
||||
|
||||
Read-only state probes.
|
||||
|
||||
#### `dispose(): Promise<void>`
|
||||
|
||||
Idempotent shutdown. Future state-changing calls throw `ENGINE_DISPOSED`.
|
||||
|
||||
### Service identifiers
|
||||
|
||||
`ENGINE_SERVICE_IDS` — `logger`, `events`, `clock`, `timers`,
|
||||
`idGenerator`, `gameLoop`, `pluginManager`, `sessionManager`,
|
||||
`playerManager`, `stateManager`, `gameLogic`.
|
||||
|
||||
### Types
|
||||
|
||||
`GameEngineOptions`, `GameEngineOptionsInput`, `GameEngineStats`,
|
||||
`GameEngineDeps`, `GameEngineEventMap`, `ISessionManagerLike`,
|
||||
`IPlayerManagerLike`, `IStateManagerLike`, `IGameLogicLike`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The decision to expose `.container` publicly is deliberate — plugins
|
||||
need a way to reach shared services, and the upstream contract
|
||||
guarantees this. Hiding the container behind a `getService(name)`
|
||||
delegate would buy nothing and force every consumer to add an extra
|
||||
indirection.
|
||||
|
||||
`dispose()` traps any error from the chained cleanup steps and
|
||||
re-raises a `GameServerError` so callers can still discriminate via
|
||||
`code`. The first thing `dispose()` does on the error path is flip
|
||||
`disposed = true` so subsequent calls cannot accidentally re-enter
|
||||
shutdown mid-failure. This is the only place in the engine where state
|
||||
changes happen outside the strict success-path order, and it is
|
||||
documented because the failure mode (partial cleanup) is the worst kind
|
||||
of state.
|
||||
|
||||
The opaque session/player/state/game-logic slots are typed as `*Like`
|
||||
interfaces with `dispose?` as their only structural requirement. This
|
||||
matches the upstream contract while keeping Wave B-1 independent of
|
||||
Wave B-2's concrete shapes — downstream waves can extend these
|
||||
interfaces with whatever else they need without rebreaking the engine.
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -0,0 +1,139 @@
|
||||
# GameLoop
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameLoop` drives a deterministic, injection-friendly game loop with three operating modes (`tick-based`, `event-driven`, `hybrid`). Every wall-clock read routes through an injected `Clock` and every scheduled interval routes through an injected `TimersLike`, which is what makes the loop testable end-to-end under a `FakeClock` without resorting to `vi.useFakeTimers()` or other runner-pinned helpers.
|
||||
|
||||
The loop owns no state other than its callback registry and a small counter set (`tickCount`, `frameCount`, `totalTime`). Callers register update and fixed-update callbacks, then either start the loop in `tick-based`/`hybrid` mode so it schedules itself, or stay in `event-driven` mode and drive `tick()` / `triggerUpdate()` from their own dispatcher. Either way the loop emits a `loop:started` / `loop:stopped` / `loop:paused` / `loop:resumed` event onto the injected `EventBusLike`, and surfaces callback failures as `loop:error` so a host process can route them to its preferred error pipeline without the loop itself terminating.
|
||||
|
||||
## Features
|
||||
|
||||
- Three modes: `tick-based`, `event-driven`, `hybrid`.
|
||||
- `Clock` + `TimersLike` injection — no direct `Date.now()` or `setInterval`.
|
||||
- `Zod`-validated `GameLoopOptions` rejecting on `INVALID_GAME_LOOP_OPTIONS`.
|
||||
- Lifecycle errors as `GameServerError` (`GAME_LOOP_NOT_RUNNING`, `GAME_LOOP_ALREADY_RUNNING`).
|
||||
- Pause/resume refreshes `lastTickTime` so resumes never see a giant delta.
|
||||
- Callback errors surface via the injected event bus; the loop keeps running.
|
||||
- OTel `game_server.loop.tick_duration_ms` histogram sample per tick.
|
||||
- `dispose()` is idempotent and safely halts a running loop.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
|
||||
const loop = new GameLoop(
|
||||
{ clock: () => Date.now(), timers, logger, events },
|
||||
{ mode: "tick-based", tickRate: 30 },
|
||||
);
|
||||
const off = loop.onFixedUpdate((tick) => world.step(tick));
|
||||
loop.start();
|
||||
// later …
|
||||
off();
|
||||
loop.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
import { FakeClock } from "@suaveplan/testing/fake-clock";
|
||||
|
||||
const fake = new FakeClock(0);
|
||||
const handles = new Map<number, { cb: () => void; due: number }>();
|
||||
let next = 1;
|
||||
const timers = {
|
||||
setTimer(cb: () => void, delayMs: number) {
|
||||
const h = next++;
|
||||
handles.set(h, { cb, due: fake.now() + delayMs });
|
||||
return h;
|
||||
},
|
||||
clearTimer(h: number) {
|
||||
handles.delete(h);
|
||||
},
|
||||
};
|
||||
|
||||
const loop = new GameLoop(
|
||||
{ clock: () => fake.now(), timers, logger, events },
|
||||
{ mode: "tick-based", tickRate: 60, maxDeltaTime: 50 },
|
||||
);
|
||||
loop.onUpdate(async (delta) => physics.advance(delta));
|
||||
loop.start();
|
||||
|
||||
// Drive the loop deterministically:
|
||||
fake.advance(16);
|
||||
for (const [, entry] of [...handles.entries()].filter(([, e]) => e.due <= fake.now())) {
|
||||
entry.cb();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GameLoop`
|
||||
|
||||
#### `new GameLoop(deps: GameLoopDeps, options?: GameLoopOptionsInput)`
|
||||
|
||||
`deps`: `{ clock, timers, logger, events }`. Zod-validates `options` and
|
||||
freezes them on the `.options` field. Throws
|
||||
`GameServerError(GAME_SERVER.INVALID_GAME_LOOP_OPTIONS)` on any schema
|
||||
failure.
|
||||
|
||||
#### `start(): void`
|
||||
|
||||
Begins the loop. Throws `GAME_LOOP_ALREADY_RUNNING` if called twice.
|
||||
|
||||
#### `stop(): void` / `pause(): void` / `resume(): void`
|
||||
|
||||
Throw `GAME_LOOP_NOT_RUNNING` if called before `start()`. `pause()` and
|
||||
`resume()` are no-ops when called repeatedly in the same state.
|
||||
|
||||
#### `tick(): Promise<void>` / `triggerUpdate(): Promise<void>`
|
||||
|
||||
Run a single tick or update phase. Both update `lastTickTime`, clamp
|
||||
delta to `maxDeltaTime`, and emit `game_server.loop.tick_duration_ms`.
|
||||
|
||||
#### `onUpdate(cb): () => void` / `onFixedUpdate(cb): () => void`
|
||||
|
||||
Register callbacks. Both return unsubscribe functions.
|
||||
|
||||
#### `getStats(): GameLoopStats`
|
||||
|
||||
Returns mode, running/paused flags, tick rate, totalTime, tickCount,
|
||||
frameCount, callback counts, averageTickMs.
|
||||
|
||||
#### `clearCallbacks(): void` / `dispose(): void`
|
||||
|
||||
`dispose()` is idempotent — calling it twice is safe.
|
||||
|
||||
### Types
|
||||
|
||||
`GameLoopMode`, `GameLoopOptions`, `GameLoopOptionsInput`, `GameLoopStats`,
|
||||
`GameLoopDeps`, `UpdateCallback`, `FixedUpdateCallback`, `GameLoopEventMap`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The scheduler uses `TimersLike.setTimer` recursively (one-shot, then
|
||||
re-schedule from inside the callback) rather than `setInterval`. This makes
|
||||
the loop's timing precisely additive — the next tick's delay starts from
|
||||
when the previous tick's callback completed, not from when the previous
|
||||
tick's timer fired. With a real timer that introduces a small per-tick
|
||||
jitter; with a `FakeClock`-driven timer it makes deterministic test
|
||||
playback trivial.
|
||||
|
||||
`runFixedUpdates` and `runUpdates` iterate the callback `Set` in insertion
|
||||
order (which `Set` guarantees) and catch each callback's error
|
||||
independently. A throwing callback never blocks its siblings, and the
|
||||
loop never terminates from a callback failure — both behaviours match the
|
||||
upstream contract documented in `../suaveplan/.../GameLoop.ts`.
|
||||
|
||||
`dispose()` is structurally idempotent: it flips a `disposed` flag then
|
||||
short-circuits subsequent calls. After dispose, `tick()` and
|
||||
`triggerUpdate()` silently no-op rather than throw — this is the only
|
||||
deliberate lifecycle leniency in the class. The reason: `dispose()` is
|
||||
typically called from a host shutdown hook, and host hosts often race
|
||||
with in-flight tick scheduling. Throwing here would produce noisy
|
||||
shutdown errors that consumers cannot meaningfully act on.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,8 @@
|
||||
export { PlayerManager } from "./player-manager.js";
|
||||
export type {
|
||||
PlayerManagerDeps,
|
||||
PlayerManagerEventMap,
|
||||
PlayerManagerOptions,
|
||||
PlayerManagerOptionsInput,
|
||||
PlayerManagerStats,
|
||||
} from "./player-manager.types.js";
|
||||
@@ -0,0 +1,132 @@
|
||||
# PlayerManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PlayerManager` is the `IPlayerManager` implementation backing the player
|
||||
lifecycle in `@suaveplan/game-server`. It tracks every player's connection
|
||||
state (`connected`, `disconnected`, `reconnecting`, `timeout`), the session
|
||||
each player is bound to, the heartbeat-driven liveness signal, and the
|
||||
reconnection-grace window that distinguishes a transient disconnect from a
|
||||
permanent departure. Every wall-clock observation routes through the
|
||||
injected `Clock`, and the optional heartbeat-monitor loop schedules itself
|
||||
via the injected `TimersLike` — there are no `Date.now()` or `setInterval`
|
||||
calls.
|
||||
|
||||
The manager emits a typed `PlayerManagerEventMap` onto the injected
|
||||
EventBus so other subsystems (matchmaking, presence, audit) can react to
|
||||
join/leave/timeout signals without polling. Every state mutation is also
|
||||
mirrored to OTel via `recordPlayerLifecycle` so dashboards see the full
|
||||
counter set.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's canonical `IPlayerManager` contract.
|
||||
- Injectable `Clock` and `TimersLike` — fully deterministic under fakes.
|
||||
- Zod-validated options boundary (`timeoutThresholdMs`,
|
||||
`reconnectionGraceMs`) with stable `INVALID_PLAYER_MANAGER_OPTIONS`
|
||||
failure code.
|
||||
- `INVALID_HEARTBEAT_INTERVAL` for non-positive integer interval inputs.
|
||||
- Reconnection path: a `connect` against an existing disconnected /
|
||||
timeout entry transitions to `reconnecting`/`connected` rather than
|
||||
building a fresh record (preserves `connectedAt`).
|
||||
- Grace-window cleanup: in `checkTimeouts`, disconnected entries older
|
||||
than `reconnectionGraceMs` are evicted so a future `connect` builds a
|
||||
fresh record.
|
||||
- Defensive copies — public API returns frozen plain-object snapshots
|
||||
rather than mutable internals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { PlayerManager } from "../player-manager/index.js";
|
||||
|
||||
const mgr = new PlayerManager({
|
||||
clock: () => Date.now(),
|
||||
timers,
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
mgr.connect("alice", "table-1", { tier: "gold" });
|
||||
mgr.heartbeat("alice");
|
||||
console.log(mgr.getConnection("alice")?.status); // "connected"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Custom timeout + grace window, with the heartbeat monitor running
|
||||
// via the injected timers source.
|
||||
const mgr = new PlayerManager(deps, {
|
||||
timeoutThresholdMs: 60_000,
|
||||
reconnectionGraceMs: 5_000,
|
||||
});
|
||||
|
||||
mgr.startHeartbeatMonitor(1_000); // sweep every second
|
||||
mgr.connect("alice", "table-1");
|
||||
// ...network drop...
|
||||
mgr.disconnect("alice", "network_lost");
|
||||
// 5s pass without reconnect — checkTimeouts evicts the entry.
|
||||
// A later connect("alice", "table-1") builds a fresh record.
|
||||
mgr.stopHeartbeatMonitor();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new PlayerManager(deps, options?)` where `deps` is
|
||||
`{ clock, timers, logger, events }`. Options:
|
||||
- `timeoutThresholdMs` (default 30000) — ms without heartbeat before a
|
||||
connected player is marked `timeout`.
|
||||
- `reconnectionGraceMs` (default 10000) — ms after a disconnect during
|
||||
which a `connect()` is treated as a reconnection.
|
||||
|
||||
### IPlayerManager surface (11 methods)
|
||||
|
||||
`connect`, `disconnect`, `getConnection`, `getSessionPlayers`,
|
||||
`getAllConnections`, `heartbeat`, `checkTimeouts`, `updateMetadata`,
|
||||
`getPlayer`, `startHeartbeatMonitor`, `stopHeartbeatMonitor`.
|
||||
|
||||
### Extras
|
||||
|
||||
- `getStats()` — connection-status histogram + average duration.
|
||||
- `dispose()` — stop monitor + drop every connection.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_PLAYER_MANAGER_OPTIONS` — Zod failure on constructor.
|
||||
- `INVALID_HEARTBEAT_INTERVAL` — non-positive integer to
|
||||
`startHeartbeatMonitor`.
|
||||
|
||||
### Events
|
||||
|
||||
`player:connected`, `player:disconnected`, `player:reconnected`,
|
||||
`player:timeout`, `player:heartbeat`, `player:joined`, `player:left`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The heartbeat monitor is implemented via `TimersLike.setTimer` chained in
|
||||
a self-rescheduling tick rather than via `setInterval`. That makes the
|
||||
behaviour deterministic under `FakeTimers` — the test suite explicitly
|
||||
fires due timers rather than depending on real wall-clock progression.
|
||||
The chain self-cancels by checking `heartbeatHandle !== undefined` before
|
||||
scheduling the next tick — so a `stopHeartbeatMonitor` between ticks
|
||||
prevents the next tick from being scheduled.
|
||||
|
||||
Connection records are mutable internally for hot-path efficiency
|
||||
(`heartbeat` is called every frame in worst case) but every public
|
||||
accessor returns a `freeze(...)`-built defensive copy. The metadata bag
|
||||
in particular is shallow-copied per accessor call — callers can mutate
|
||||
the returned object without affecting the internal state.
|
||||
|
||||
The grace-window logic in `checkTimeouts` is what distinguishes a
|
||||
"reconnection" from a "fresh connection". A disconnected entry within
|
||||
the grace window is still resolvable via `getConnection`, so a later
|
||||
`connect()` call routes through the reconnection path; once the grace
|
||||
expires, the entry is evicted entirely and a future `connect()` builds a
|
||||
fresh record (with a new `connectedAt`).
|
||||
@@ -0,0 +1,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { PluginManager } from "./plugin-manager.js";
|
||||
export type {
|
||||
EnginePlugin,
|
||||
EnginePluginContext,
|
||||
PluginManagerDeps,
|
||||
PluginManagerEventMap,
|
||||
PluginManagerStats,
|
||||
} from "./plugin-manager.types.js";
|
||||
@@ -0,0 +1,145 @@
|
||||
# PluginManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PluginManager` is the typed plugin registry for `@suaveplan/game-server`. It owns the install/uninstall/enable/disable lifecycle, drives plugin dependency ordering via topological sort, and wraps every plugin lifecycle hook in a single discriminable error code (`PLUGIN_LIFECYCLE_FAILED`) so consumers can centralise their error handling without scraping cause chains.
|
||||
|
||||
The manager carries no global state — plugins are stored under their declared `name`, and the install order is captured separately so `disposeAll()` can tear plugins down in reverse. Every state-changing call (`install`, `uninstall`, `enable`, `disable`) emits both a structured log line and a typed event onto the injected `EventBusLike`, and bumps the `game_server.plugin.lifecycle_total` OTel counter with a low-cardinality `op`/`outcome` attribute pair.
|
||||
|
||||
## Features
|
||||
|
||||
- Typed `EnginePlugin` contract with optional `onInstall` / `onUninstall` / `onEnable` / `onDisable` hooks.
|
||||
- Zod-validated plugin shape on every registration path.
|
||||
- `DUPLICATE_PLUGIN` on second install with the same name; `PLUGIN_NOT_FOUND` on every unknown-name lookup; `PLUGIN_LIFECYCLE_FAILED` wrapping hook errors, missing deps, and cycles.
|
||||
- `installAll(plugins)` accepts an unordered batch and resolves dependencies via topological sort.
|
||||
- `disposeAll()` tears plugins down in reverse install order; `isEnabled` is checked before `onDisable` fires (so callers can call uninstall on a still-enabled plugin without an explicit disable first).
|
||||
- Structured logging + typed event emission on every state change.
|
||||
- OTel `game_server.plugin.lifecycle_total` counter incremented with `op`/`outcome` attributes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
await plugins.install({
|
||||
name: "metrics",
|
||||
version: "1.0.0",
|
||||
async onInstall(ctx) {
|
||||
ctx.logger.info("metrics plugin online");
|
||||
},
|
||||
});
|
||||
await plugins.enable("metrics");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
|
||||
await plugins.installAll([
|
||||
{ name: "metrics", version: "1.0.0" },
|
||||
{
|
||||
name: "leaderboard",
|
||||
version: "0.3.0",
|
||||
dependencies: ["metrics"],
|
||||
async onInstall(ctx) {
|
||||
ctx.events.emit("plugin:installed", {
|
||||
name: "leaderboard",
|
||||
version: "0.3.0",
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// `metrics` is installed first because `leaderboard.dependencies = ["metrics"]`.
|
||||
|
||||
await plugins.enable("leaderboard");
|
||||
await plugins.enable("metrics");
|
||||
|
||||
// Errors carry the offending plugin + hook name:
|
||||
try {
|
||||
await plugins.install({
|
||||
name: "broken",
|
||||
version: "0.1.0",
|
||||
async onInstall() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === "GAME_SERVER.PLUGIN_LIFECYCLE_FAILED") {
|
||||
console.log(err.context); // { name: "broken", hook: "onInstall" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class PluginManager`
|
||||
|
||||
#### `new PluginManager(deps)`
|
||||
|
||||
`deps`: `{ logger: LoggerLike; events: EventBusLike<PluginManagerEventMap> }`.
|
||||
|
||||
#### `install(plugin): Promise<void>` / `installAll(plugins): Promise<void>`
|
||||
|
||||
`install` rejects with `DUPLICATE_PLUGIN` on a re-install. `installAll`
|
||||
topologically orders the batch; a missing dependency or a cycle rejects
|
||||
with `PLUGIN_LIFECYCLE_FAILED` whose `context` carries either `{ missing, requestedBy }`
|
||||
or `{ cycle }`.
|
||||
|
||||
#### `uninstall(name): Promise<void>`
|
||||
|
||||
Disables the plugin first if it is currently enabled, then runs
|
||||
`onUninstall`. Rejects with `PLUGIN_NOT_FOUND` on unknown name.
|
||||
|
||||
#### `enable(name): Promise<void>` / `disable(name): Promise<void>`
|
||||
|
||||
Idempotent — calling `enable` on an already-enabled plugin is a no-op
|
||||
that does not run the hook again.
|
||||
|
||||
#### `get(name): EnginePlugin` / `has(name): boolean` / `isEnabled(name): boolean`
|
||||
|
||||
`get` and `isEnabled` throw `PLUGIN_NOT_FOUND` on unknown name; `has`
|
||||
returns a boolean.
|
||||
|
||||
#### `list(): readonly string[]` / `getStats(): PluginManagerStats`
|
||||
|
||||
`list()` returns the install order; `getStats()` adds `enabled`/`disabled`
|
||||
counts.
|
||||
|
||||
#### `disposeAll(): Promise<void>`
|
||||
|
||||
Uninstall every plugin in reverse install order.
|
||||
|
||||
### Types
|
||||
|
||||
`EnginePlugin`, `EnginePluginContext`, `PluginManagerStats`,
|
||||
`PluginManagerEventMap`, `PluginManagerDeps`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Topological sort uses the standard three-set Tarjan algorithm
|
||||
(`visited` / `visiting` plus a `path` accumulator) so cycles surface with
|
||||
the offending edge sequence rather than a generic message. The error
|
||||
carries the cycle string in `context.cycle` so log scrapers can group by
|
||||
the participating plugins without parsing free text.
|
||||
|
||||
The decision to fold "schema validation" failures into the
|
||||
`PLUGIN_LIFECYCLE_FAILED` code rather than a dedicated `INVALID_PLUGIN`
|
||||
code keeps the error surface narrow — every plugin-related failure has
|
||||
the same discriminable code, with the specific failure mode visible in
|
||||
`context.hook` or `context.missing` or `context.cycle`. Consumers that
|
||||
want to branch on the precise failure mode read those fields; the
|
||||
`code` field is enough for a catch-all log emit.
|
||||
|
||||
`disposeAll` walks the install order in reverse so plugins with declared
|
||||
dependencies tear down after their dependents. This mirrors the
|
||||
upstream contract: a logging plugin installed first must be the last to
|
||||
go, because every other plugin's `onUninstall` may still need to log.
|
||||
@@ -0,0 +1,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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { SessionLifecycle } from "./session-lifecycle.js";
|
||||
export type {
|
||||
SessionLifecycleDeps,
|
||||
SessionLifecycleEntry,
|
||||
SessionLifecycleEventMap,
|
||||
SessionState,
|
||||
SessionTransition,
|
||||
} from "./session-lifecycle.types.js";
|
||||
@@ -0,0 +1,128 @@
|
||||
# SessionLifecycle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionLifecycle` is the per-session finite-state machine that models
|
||||
each session's progression through the three high-level phases of its
|
||||
existence: `lobby` (open for players, not yet running), `active` (the game
|
||||
is in progress), and `ended` (no further transitions allowed). The class
|
||||
owns the FSM transition table, the per-session entry record, and a full
|
||||
audit history of every transition the manager has observed.
|
||||
|
||||
Each transition is validated against a fixed allow-table: `lobby → active`,
|
||||
`lobby → ended`, and `active → ended` are legal; every other pair is
|
||||
rejected with a stable error code. The manager emits a
|
||||
`session:transitioned` event onto the injected EventBus on every legal
|
||||
transition and records two OTel instruments: a counter for the transition
|
||||
itself and a histogram for the dwell time in the state being left.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed three-state vocabulary: `lobby`, `active`, `ended`.
|
||||
- Strict allow-table — illegal transitions throw
|
||||
`INVALID_SESSION_TRANSITION`; transitions out of the terminal `ended`
|
||||
state throw `SESSION_ALREADY_ENDED`.
|
||||
- Injectable `Clock` — every state-entry timestamp and dwell sample is
|
||||
deterministic under `FakeClock`.
|
||||
- `canTransition(id, to)` — non-throwing predicate for callers that want
|
||||
to branch on legality rather than catch.
|
||||
- Full audit history preserved across `forget(id)` — operations teams
|
||||
can post-mortem a session even after its registry entry is dropped.
|
||||
- OTel instruments: `game_server.session.lifecycle_total` counter (tags
|
||||
`from`/`to`/`outcome`) and `game_server.session.dwell_ms` histogram
|
||||
(tag `state`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionLifecycle } from "../session-lifecycle/index.js";
|
||||
|
||||
const fsm = new SessionLifecycle({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
fsm.register("table-1");
|
||||
fsm.transition("table-1", "active");
|
||||
// ...game plays out...
|
||||
fsm.transition("table-1", "ended", "game_complete");
|
||||
console.log(fsm.getState("table-1")); // "ended"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Defensive read before transitioning — useful from a UI controller.
|
||||
if (fsm.canTransition(id, "ended")) {
|
||||
fsm.transition(id, "ended", "host_cancelled");
|
||||
}
|
||||
|
||||
// Audit reporting — group transitions by session for a post-mortem.
|
||||
const history = fsm.getHistoryFor("table-1");
|
||||
for (const t of history) {
|
||||
console.log(`${t.at}: ${t.from} → ${t.to} ${t.reason ?? ""}`);
|
||||
}
|
||||
|
||||
// Re-using ids: forget the entry but keep the audit trail.
|
||||
fsm.forget("table-1");
|
||||
console.log(fsm.getState("table-1")); // null
|
||||
console.log(fsm.getHistoryFor("table-1").length); // > 0 (preserved)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionLifecycle(deps)` where `deps` is `{ clock, logger, events }`.
|
||||
|
||||
### Methods
|
||||
|
||||
- `register(id)` — initialise an entry in the `lobby` state. Throws
|
||||
`DUPLICATE_SESSION_ID` on collision.
|
||||
- `transition(id, to, reason?)` — validated mutation; emits
|
||||
`session:transitioned`; records OTel.
|
||||
- `getState(id)` / `getEntry(id)` — null for unknown ids.
|
||||
- `getAllEntries()` — defensive snapshot.
|
||||
- `canTransition(id, to)` — non-throwing legality predicate.
|
||||
- `getHistory()` / `getHistoryFor(id)` — defensive copies.
|
||||
- `forget(id)` — drop the entry; history is preserved.
|
||||
|
||||
### Errors
|
||||
|
||||
- `SESSION_NOT_FOUND` — `transition` on an unregistered id.
|
||||
- `SESSION_ALREADY_ENDED` — `transition` on an entry already in `ended`.
|
||||
- `INVALID_SESSION_TRANSITION` — `to` not in the allow-table for the
|
||||
current state.
|
||||
- `DUPLICATE_SESSION_ID` — `register` collision.
|
||||
|
||||
### Events
|
||||
|
||||
- `session:transitioned` — payload is the full `SessionTransition`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The allow-table is a `Readonly<Record<SessionState, ReadonlyArray<...>>>`
|
||||
constant — adding a new state requires updating both the
|
||||
`SessionState` union and the table, and every `transition` call walks the
|
||||
table at most twice (once for the rejection check, once for the
|
||||
recording). Forgetting to update the table would manifest as the FSM
|
||||
silently rejecting every transition out of the new state, which the test
|
||||
suite would catch.
|
||||
|
||||
The history is appended to a single shared array rather than
|
||||
per-session — this keeps the audit log monotonically ordered by wall
|
||||
clock, which matters when post-mortems span multiple sessions. The
|
||||
`getHistoryFor(id)` helper filters out the rest with a single linear
|
||||
walk; for production-scale histories, replace this in a follow-up with a
|
||||
per-id index. The current implementation is sized for the lifecycle
|
||||
flows of typical real-time games (single-digit transitions per session).
|
||||
|
||||
OTel emission uses two attributes on the counter (`from` and `to`) so a
|
||||
dashboard can aggregate by either side of the transition. The dwell
|
||||
histogram tags only the state being left — the duration is measured from
|
||||
when the session entered that state to when it left.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SessionGraphNode } from "./session-node.js";
|
||||
export type { SerializedSessionNode } from "./session-node.types.js";
|
||||
@@ -0,0 +1,126 @@
|
||||
# SessionGraphNode
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionGraphNode` is the internal mutable graph node powering the
|
||||
hierarchical session tree at the heart of `@suaveplan/game-server`. Every
|
||||
node holds a stable id, a closed-vocabulary session type, a metadata bag,
|
||||
the mutable bidirectional parent/children links that form the tree, and
|
||||
the two timestamps that mark when the node was created and last touched.
|
||||
|
||||
The class is named `SessionGraphNode` deliberately — Wave A's public
|
||||
`SessionNode` interface (in `core/types`) is a read-only structural view of
|
||||
exactly this shape. exposes the read-only interface to consumers
|
||||
and uses the mutable class internally, so callers cannot accidentally
|
||||
mutate the tree by reaching into `node.children.push(...)` through the
|
||||
public manager surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's `SessionNode` interface structurally (the class is
|
||||
the canonical implementation; the interface is the read-only view).
|
||||
- Injectable `Clock` — no `Date.now()` calls; the entire tree is
|
||||
deterministic under `FakeClock`.
|
||||
- Bidirectional parent/children link maintenance via `addChild` /
|
||||
`removeChild` with idempotent attach/detach semantics.
|
||||
- Tree-walking helpers: `getDepth`, `getDescendants` (DFS pre-order),
|
||||
`getAncestors`, `getRoot`, `isAncestorOf`, `isDescendantOf`.
|
||||
- `toJSON()` returning a `SerializedSessionNode` shape for `SessionStore`
|
||||
persistence.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
|
||||
const clock = () => 1700000000000;
|
||||
const root = new SessionGraphNode(
|
||||
"venue1",
|
||||
"venue",
|
||||
{ createdAt: 0, updatedAt: 0 },
|
||||
clock
|
||||
);
|
||||
|
||||
const table = new SessionGraphNode(
|
||||
"table1",
|
||||
"table",
|
||||
{ createdAt: 0, updatedAt: 0 },
|
||||
clock,
|
||||
root
|
||||
);
|
||||
root.addChild(table);
|
||||
console.log(table.getDepth()); // 1
|
||||
console.log(table.getRoot().id); // "venue1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
|
||||
// Persistence round-trip: serialise, then restore with original timestamps.
|
||||
const node = new SessionGraphNode("s1", "game", meta, clock);
|
||||
const json = node.toJSON();
|
||||
const restored = new SessionGraphNode(
|
||||
json.id,
|
||||
json.type,
|
||||
json.metadata,
|
||||
clock,
|
||||
null,
|
||||
{ createdAt: json.createdAt, updatedAt: json.updatedAt }
|
||||
);
|
||||
|
||||
// Removing a child clears the bidirectional link.
|
||||
root.removeChild(table);
|
||||
console.log(table.parent); // null
|
||||
console.log(root.children.length); // 0
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionGraphNode(id, type, metadata, clock, parent?, timestamps?)`
|
||||
|
||||
`timestamps` is optional — passed only when restoring from persistence to
|
||||
preserve the original `createdAt` / `updatedAt` stamps.
|
||||
|
||||
### Methods
|
||||
|
||||
- `addChild(child)` — attach a child; idempotent (re-adding is a no-op).
|
||||
- `removeChild(child)` — returns `true` if removed, `false` if not a
|
||||
child.
|
||||
- `updateMetadata(patch)` — shallow-merge patch; refreshes `updatedAt`.
|
||||
- `getDepth()` — 0 for a root, otherwise the distance to the root.
|
||||
- `getDescendants()` — depth-first pre-order array, excluding `this`.
|
||||
- `getAncestors()` — parent-to-root array.
|
||||
- `getRoot()` — the topmost ancestor (or `this`).
|
||||
- `isAncestorOf(other)` / `isDescendantOf(other)` — strict ancestry tests.
|
||||
- `toJSON()` — serialised plain object suitable for storage adapters.
|
||||
|
||||
### Read-only properties
|
||||
|
||||
`id`, `type`, `createdAt`, `updatedAt`.
|
||||
|
||||
### Mutable properties
|
||||
|
||||
`parent` (managed by `addChild`/`removeChild`), `children`, `metadata`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bidirectional parent/children invariant is held by `addChild` and
|
||||
`removeChild`. Direct mutation of `node.children` or `node.parent` is
|
||||
discouraged — callers should always go through these methods so the
|
||||
counterpart link stays in sync. The class is internal to the engine
|
||||
package and is not part of the public re-export surface; consumers should
|
||||
import `SessionNode` from `core/types` instead.
|
||||
|
||||
The constructor accepts an optional `timestamps` parameter solely for the
|
||||
persistence round-trip path — without it, a freshly restored node would
|
||||
have its `createdAt` set to the moment of restoration, losing the original
|
||||
creation time. With it, `SessionStore.restore` produces nodes that are
|
||||
byte-equivalent to the ones that were originally persisted.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { SessionStore } from "./session-store.js";
|
||||
export type {
|
||||
SessionPersistence,
|
||||
SessionStoreDeps,
|
||||
SessionStoreOptions,
|
||||
SessionStoreOptionsInput,
|
||||
} from "./session-store.types.js";
|
||||
@@ -0,0 +1,137 @@
|
||||
# SessionStore
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionStore` is the `ISessionManager` implementation that consumers wire
|
||||
into a `GameEngine`. It composes a `SessionTree` (the in-memory
|
||||
hierarchical graph) with an injectable `SessionPersistence` adapter so the
|
||||
session graph survives process restarts. Callers receive the read-only
|
||||
`SessionNode` shape from `core/types` — the mutable `SessionGraphNode` is
|
||||
internal to the engine package and cannot be mutated through the public
|
||||
manager surface.
|
||||
|
||||
The store accepts a Zod-validated options bundle (`storageKey` for
|
||||
namespacing the persistence blob) and an injectable persistence adapter.
|
||||
When no adapter is supplied, an in-memory default is used so callers can
|
||||
exercise the full lifecycle in tests and ephemeral environments without a
|
||||
real storage backend.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's canonical `ISessionManager` contract (10 methods).
|
||||
- Zod-validated options boundary with stable
|
||||
`INVALID_SESSION_STORE_OPTIONS` failure code.
|
||||
- Pluggable `SessionPersistence` adapter (`get`/`set`/`delete`) so callers
|
||||
can swap in any byte-store.
|
||||
- Default in-memory persistence for ephemeral runs.
|
||||
- Restore reconstructs parent-before-child ordering automatically from
|
||||
any record permutation.
|
||||
- Forwards every tree mutation to the injected EventBus via the
|
||||
composed `SessionTree`.
|
||||
- Exposes `getStats`, `hasSession`, `clear`, and `clearPersistence` as
|
||||
ergonomic extras over the bare `ISessionManager` contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionStore } from "../session-store/index.js";
|
||||
|
||||
const store = new SessionStore({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
store.createSession(null, {
|
||||
id: "venue1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
store.createSession("venue1", {
|
||||
id: "table1",
|
||||
type: "table",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
|
||||
await store.persist();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Inject a SurrealDB-backed persistence adapter for durability.
|
||||
const persistence: SessionPersistence = {
|
||||
async get(key) {
|
||||
const row = await db.select(key);
|
||||
return row?.payload ?? null;
|
||||
},
|
||||
async set(key, payload) {
|
||||
await db.upsert(key, { payload });
|
||||
},
|
||||
async delete(key) {
|
||||
await db.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
const store = new SessionStore(
|
||||
{ clock, logger, events, persistence, storageKey: "shard-eu-1" },
|
||||
{}
|
||||
);
|
||||
|
||||
// On startup: restore prior state, then begin accepting traffic.
|
||||
await store.restore();
|
||||
console.log(store.getAllSessions().length); // restored count
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionStore(deps, options?)` where `deps` is
|
||||
`{ clock, logger, events, persistence?, storageKey? }`. Options accept
|
||||
`{ storageKey? }` and default to `game_server:sessions`.
|
||||
|
||||
### ISessionManager surface
|
||||
|
||||
`createSession`, `getSession`, `destroySession`, `getSessionsByType`,
|
||||
`getChildren`, `getParent`, `updateMetadata`, `getAllSessions`, `persist`,
|
||||
`restore` — all forwarding to the composed `SessionTree` and adapter.
|
||||
|
||||
### Extras
|
||||
|
||||
- `getStats()` — aggregate counts.
|
||||
- `hasSession(id)` — O(1) presence.
|
||||
- `clear()` — drop in-memory only.
|
||||
- `clearPersistence()` — drop the persisted blob.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_SESSION_STORE_OPTIONS` — Zod failure at construction.
|
||||
- All `SessionTree`-thrown codes (`INVALID_SESSION_DATA`,
|
||||
`DUPLICATE_SESSION_ID`, `PARENT_SESSION_NOT_FOUND`) pass through.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`restore` performs a fixed-point walk: it repeatedly scans the remaining
|
||||
records and attaches any whose parent is already present, halting when
|
||||
either every record is attached or no progress was made in a full pass.
|
||||
Orphan records (a `parentId` referencing a session not in the payload)
|
||||
are logged as warnings rather than thrown — losing the parent shouldn't
|
||||
prevent the rest of the tree from coming online. Real persistence
|
||||
adapters that guarantee referential integrity will never trigger that
|
||||
warning path.
|
||||
|
||||
The default `InMemoryPersistence` is intentionally namespaced inside the
|
||||
store instance — a fresh `SessionStore` always gets a fresh map. This
|
||||
keeps test isolation trivial: callers don't need to manually clear
|
||||
persistence between tests. For production usage, the adapter is always
|
||||
supplied.
|
||||
|
||||
`persist` snapshots the current tree by walking `getAllSessions()` and
|
||||
calling `toJSON()` on each node. The order of records in the payload is
|
||||
preserved insertion-order from the internal `Map`, but `restore` does not
|
||||
rely on that order — the fixed-point walk handles arbitrary permutations.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { SessionTree } from "./session-tree.js";
|
||||
export type {
|
||||
SessionTreeDeps,
|
||||
SessionTreeEventMap,
|
||||
SessionTreeIntegrityReport,
|
||||
SessionTreeStats,
|
||||
} from "./session-tree.types.js";
|
||||
@@ -0,0 +1,132 @@
|
||||
# SessionTree
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionTree` is the hierarchical session graph backing
|
||||
`@suaveplan/game-server`. It owns the `Map<id, SessionGraphNode>` index plus
|
||||
the `Set<rootId>` that tracks parent-less sessions, and it exposes the
|
||||
mutation surface used by `SessionStore` to satisfy the public
|
||||
`ISessionManager` contract. The tree supports unlimited nesting
|
||||
(`venue → table → queue → game → player`) with O(1) lookups by id.
|
||||
|
||||
Every wall-clock observation routes through the injected `Clock`, every
|
||||
mutation emits a typed event onto the injected `EventBus`, and every input
|
||||
boundary that accepts session data is validated through Zod with a
|
||||
`GameServerError` thrown on rejection. The class is internal to the engine
|
||||
package — consumers reach it through `SessionStore`, never directly.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `getSession` lookup via a single `Map`.
|
||||
- Bidirectional parent/children link maintenance via `SessionGraphNode`.
|
||||
- Zod-validated `SessionData` boundary with stable `INVALID_SESSION_DATA`
|
||||
failure code.
|
||||
- Duplicate-id and dangling-parent guards (`DUPLICATE_SESSION_ID`,
|
||||
`PARENT_SESSION_NOT_FOUND`).
|
||||
- Bottom-up `destroySession` cascading through every descendant.
|
||||
- `attachRestored` re-attach path for `SessionStore.restore` that does NOT
|
||||
emit `session:created` (restoration is not creation).
|
||||
- `validateIntegrity` audit for debug logging.
|
||||
- Aggregate `getStats` reporting total session count, root count, max
|
||||
depth, and per-type distribution.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionTree } from "../session-tree/index.js";
|
||||
|
||||
const tree = new SessionTree({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
const venue = tree.createSession(null, {
|
||||
id: "venue1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
tree.createSession("venue1", {
|
||||
id: "table1",
|
||||
type: "table",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
|
||||
console.log(tree.getChildren("venue1").map((c) => c.id)); // ["table1"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Destroying a parent cascades through all descendants bottom-up.
|
||||
tree.destroySession("venue1");
|
||||
console.log(tree.getAllSessions()); // []
|
||||
|
||||
// Persistence round-trip — restore via attachRestored without firing
|
||||
// the "session:created" event (because restoration is not creation).
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
const restored = new SessionGraphNode("a", "venue", meta, clock, null, {
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
});
|
||||
tree.attachRestored(restored, null);
|
||||
console.log(tree.hasSession("a")); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionTree(deps)` where `deps` is `{ clock, logger, events }`.
|
||||
|
||||
### Methods
|
||||
|
||||
- `createSession(parentId, data)` — validates `data`, throws on duplicate
|
||||
id or missing parent; emits `session:created`.
|
||||
- `getSession(id)` / `hasSession(id)` — O(1) presence checks.
|
||||
- `updateMetadata(id, patch)` — shallow merge; emits
|
||||
`session:metadata:changed` and `session:updated`.
|
||||
- `destroySession(id)` — bottom-up cascade; emits `session:destroyed`
|
||||
once per detached node.
|
||||
- `getSessionsByType(type)` — filter by closed `SessionType` vocabulary.
|
||||
- `getChildren` / `getParent` / `getDescendants` / `getAncestors`.
|
||||
- `getAllSessions()` / `getRoots()` / `getSessionCount()`.
|
||||
- `getStats()` — aggregate counts + max depth + per-type distribution.
|
||||
- `clear()` — drop everything; used by `SessionStore.restore`.
|
||||
- `attachRestored(node, parentId)` — re-attach during restore; throws on
|
||||
duplicate id or missing parent.
|
||||
- `validateIntegrity()` — `{valid, errors}` audit.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_SESSION_DATA` — Zod validation failure on `createSession`.
|
||||
- `DUPLICATE_SESSION_ID` — id collision on `createSession` or
|
||||
`attachRestored`.
|
||||
- `PARENT_SESSION_NOT_FOUND` — `parentId` references an unknown session.
|
||||
|
||||
### Events
|
||||
|
||||
- `session:created` / `session:destroyed` / `session:updated` /
|
||||
`session:metadata:changed`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`destroySession` walks descendants depth-first and then reverses the array
|
||||
so deepest sessions detach first — this matters when the EventBus has
|
||||
subscribers that expect leaf-before-parent ordering. The reverse-iteration
|
||||
of `getDescendants()` is deliberate: `getDescendants()` returns pre-order,
|
||||
which makes the children-before-parent walk a single `reverse()` call
|
||||
rather than a separate post-order traversal.
|
||||
|
||||
`attachRestored` is a deliberate seam in the tree's surface — it accepts a
|
||||
pre-built `SessionGraphNode` (with its preserved `createdAt`/`updatedAt`
|
||||
timestamps) rather than `SessionData`. This is the only path that bypasses
|
||||
the Zod validation, because validation already happened at the original
|
||||
`createSession` call before persistence. Restoration uses the same
|
||||
duplicate-id and missing-parent guards as creation, but emits no events —
|
||||
listeners should subscribe to the `SessionStore.restore()` completion
|
||||
signal if they need a notification.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,71 @@
|
||||
# anti-cheat-validator.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal pure helpers backing `AntiCheatValidator`. Holds default
|
||||
configuration constants (movement, rate limits, punishment thresholds),
|
||||
the Euclidean `distance` calculator, the `recommend` punishment-escalation
|
||||
ladder, and the `emptyHistory` factory.
|
||||
|
||||
Splitting these out keeps `anti-cheat-validator.ts` below the 300-line code
|
||||
file limit while ensuring the helpers remain side-effect free, individually
|
||||
testable, and unit-coverable.
|
||||
|
||||
## Features
|
||||
|
||||
- Default `MovementConfig` (`maxSpeed`, `maxTeleportDistance`,
|
||||
`toleranceFactor`).
|
||||
- Default `PunishmentThresholds` (escalation ladder for warnings → minor →
|
||||
major → kick → temp/permanent ban).
|
||||
- Default `RateLimitConfig` array (60 actions / 1 s / all).
|
||||
- `distance(a, b)` Euclidean 3D distance (z defaults to 0).
|
||||
- `recommend(h, p)` punishment ladder driven by per-severity counts and
|
||||
current ban status.
|
||||
- `emptyHistory(playerId)` factory for new tracked players.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_MOVEMENT,
|
||||
distance,
|
||||
} from "./anti-cheat-validator.internal.js";
|
||||
|
||||
distance({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
|
||||
DEFAULT_MOVEMENT.maxSpeed; // 10
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
emptyHistory,
|
||||
recommend,
|
||||
DEFAULT_PUNISHMENTS,
|
||||
} from "./anti-cheat-validator.internal.js";
|
||||
|
||||
const h = emptyHistory("p1");
|
||||
h.violationsBySeverity.major = 3;
|
||||
recommend(h, DEFAULT_PUNISHMENTS); // "temp_ban"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_MOVEMENT: MovementConfig`
|
||||
- `DEFAULT_PUNISHMENTS: PunishmentThresholds`
|
||||
- `DEFAULT_RATE_LIMITS: RateLimitConfig[]`
|
||||
- `distance(a: Position, b: Position): number`
|
||||
- `recommend(h: PlayerViolationHistory, p: PunishmentThresholds):
|
||||
PunishmentRecommendation`
|
||||
- `emptyHistory(playerId: string): PlayerViolationHistory`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`distance` treats missing `z` as 0 so 2D positions interoperate with 3D
|
||||
checks. `recommend` short-circuits on the strictest applicable threshold
|
||||
(critical violation or active temp ban → permanent), then walks the ladder
|
||||
top-down. `emptyHistory` reserves an empty object for the per-type and
|
||||
per-severity tally maps so callers can use the safe `??` increment pattern
|
||||
without hitting `undefined` keys. Defaults match the migrated upstream
|
||||
configuration so the public observable behaviour of `AntiCheatValidator`
|
||||
is unchanged from the universal source.
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# anti-cheat-validator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AntiCheatValidator` performs server-authoritative validation for player
|
||||
movement and action streams in real-time multiplayer games. It tracks per-
|
||||
player position history, applies movement-speed and teleport thresholds,
|
||||
enforces rolling-window rate limits, runs caller-registered action
|
||||
validators, accumulates violation histories, and escalates punishments
|
||||
through a configurable warn → minor → major → kick → temp-ban → permanent-
|
||||
ban ladder.
|
||||
|
||||
The validator is dependency-injectable: a `Clock` provides wall-clock
|
||||
time, an `IdGenerator` mints violation ids, and an OpenTelemetry `Meter`
|
||||
provides metrics emission. Configuration is validated with Zod at the
|
||||
constructor boundary; invalid input raises
|
||||
`GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`.
|
||||
|
||||
## Features
|
||||
|
||||
- Movement validation: teleport (distance), horizontal speed-hack,
|
||||
optional vertical speed-hack, timing-anomaly detection.
|
||||
- Rolling-window rate limiting per action type plus a global `"all"`
|
||||
bucket.
|
||||
- Pluggable action validators with full validation context
|
||||
(`getPlayerPosition`, `getPlayerHistory`, `getCurrentTick`).
|
||||
- Auto-flagging at a configurable violations-per-minute threshold.
|
||||
- Automatic punishment ladder driven by per-severity tallies and current
|
||||
ban status.
|
||||
- Per-player history, statistics, OTel counters
|
||||
(`game_server.anticheat.validation_total`,
|
||||
`game_server.anticheat.violation_total`).
|
||||
- Typed listener registry (`on`/`off`) for `violation:detected`,
|
||||
`player:flagged`/`:unflagged`/`:kicked`/`:temp_banned`/`:permanent_banned`,
|
||||
`rate_limit:exceeded`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AntiCheatValidator } from "@suaveplan/game-server/enhanced/anti-cheat/anti-cheat-validator";
|
||||
|
||||
const validator = new AntiCheatValidator();
|
||||
const r = validator.validateMovement({
|
||||
playerId: "p1",
|
||||
position: { x: 0, y: 0 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (!r.valid) console.warn(r.violation);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AntiCheatValidator } from "@suaveplan/game-server/enhanced/anti-cheat/anti-cheat-validator";
|
||||
|
||||
let now = 0;
|
||||
const validator = new AntiCheatValidator<
|
||||
{ x: number; y: number },
|
||||
{ damage: number }
|
||||
>(
|
||||
{
|
||||
rateLimits: [{ maxActions: 30, windowMs: 1_000, actionType: "shoot" }],
|
||||
movement: {
|
||||
maxSpeed: 12,
|
||||
maxTeleportDistance: 60,
|
||||
allowVertical: true,
|
||||
maxVerticalSpeed: 18,
|
||||
toleranceFactor: 1.25,
|
||||
},
|
||||
autoFlagThreshold: 6,
|
||||
enableAutoPunishment: true,
|
||||
},
|
||||
{
|
||||
clock: () => now,
|
||||
idGenerator: () => `v-${++now}`,
|
||||
}
|
||||
);
|
||||
|
||||
validator.registerValidator("shoot", async (action) => {
|
||||
return action.payload.damage <= 100
|
||||
? { valid: true, shouldWarn: false, recommendation: "none" }
|
||||
: {
|
||||
valid: false,
|
||||
shouldWarn: false,
|
||||
recommendation: "warn",
|
||||
reason: "damage out of range",
|
||||
};
|
||||
});
|
||||
|
||||
const unsub = validator.on("violation:detected", (v) => audit.write(v));
|
||||
// ... later
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new AntiCheatValidator(config?, deps?)` — validates `config` with
|
||||
`AntiCheatConfigSchema`; throws `GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`
|
||||
on failure. `deps`: `{ clock?, idGenerator?, meter? }`.
|
||||
- `validateMovement(data)`: `ValidationResult` — first call seeds the
|
||||
player's reference position; subsequent calls compare against it.
|
||||
- `validateAction(action)`: `Promise<ValidationResult>` — rate-checks then
|
||||
invokes any registered action validator.
|
||||
- `checkRateLimit(playerId, actionType, timestamp)`: `ValidationResult`.
|
||||
- `registerValidator(actionType, validator)` — throws
|
||||
`GAME_SERVER.INVALID_VALIDATION_RULE` on empty `actionType`.
|
||||
- `getPlayerHistory(playerId)`, `flagPlayer`, `unflagPlayer`, `banPlayer`,
|
||||
`isPlayerBanned`, `setTick`, `getStats`, `resetStats`, `clearPlayer`,
|
||||
`dispose`.
|
||||
- `on(event, listener)` returns an unsubscribe function; `off(event,
|
||||
listener)` removes a specific listener.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Heavy data-shape work (`processFlag`, `evaluateRateLimit`,
|
||||
`classifyMovement`, `recommend`) lives in the sibling
|
||||
`anti-cheat-validator.internal.ts` so the main class stays focused on
|
||||
lifecycle, dependency injection, and event dispatch. The Euclidean
|
||||
distance helper treats missing `z` as 0 so 2D and 3D positions
|
||||
interoperate.
|
||||
|
||||
Auto-punishment dispatches into `flagPlayer`/`banPlayer` so the listener
|
||||
registry sees the corresponding `player:*` events even when escalation is
|
||||
driven internally. Temp bans expire lazily on the next `isPlayerBanned`
|
||||
call rather than via a timer to avoid scheduling drift. Statistics include
|
||||
a live `uptimeMs` computed from the injected `Clock` so deterministic
|
||||
tests can advance time without observing real wall-clock skew.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,72 @@
|
||||
# interest-manager.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure geometry helpers backing `InterestManager`: Euclidean
|
||||
`vectorDistance` (z defaults to 0 for 2D interop), `zoneContains`
|
||||
discriminating on `ZoneShape` (`circular` uses radius distance,
|
||||
`rectangular` uses half-extent checks honouring optional `depth`, `custom`
|
||||
delegates to the zone-supplied `containsPoint` predicate), and
|
||||
`calcPriority` clamping a `maxPriority - distance × decay + zoneBonus`
|
||||
score into `[0, maxPriority]`.
|
||||
|
||||
Extracting these keeps `interest-manager.ts` under the per-file line
|
||||
budget and isolates the geometric primitives so they can be unit-tested
|
||||
without instantiating the manager.
|
||||
|
||||
## Features
|
||||
|
||||
- `vectorDistance(a, b)` — 3D Euclidean distance with 2D fallback.
|
||||
- `zoneContains(zone, point)` — discriminated containment test across
|
||||
circular, rectangular (optionally 3D), and custom zones.
|
||||
- `calcPriority(d, n, max, decay)` — clamped priority score with a small
|
||||
bonus per shared subscribed zone.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
vectorDistance,
|
||||
zoneContains,
|
||||
} from "./interest-manager.internal.js";
|
||||
|
||||
vectorDistance({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
|
||||
zoneContains(
|
||||
{
|
||||
id: "z",
|
||||
shape: "circular",
|
||||
center: { x: 0, y: 0 },
|
||||
radius: 5,
|
||||
priority: 1,
|
||||
},
|
||||
{ x: 4, y: 0 }
|
||||
); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { calcPriority } from "./interest-manager.internal.js";
|
||||
|
||||
// At max distance the bonus still keeps shared-zone entities visible.
|
||||
calcPriority(80, 3, 10, 0.1); // 3.5
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `vectorDistance(a: Vector3, b: Vector3): number`
|
||||
- `zoneContains(zone: InterestZone, point: Vector3): boolean`
|
||||
- `calcPriority(distance: number, sharedZoneCount: number, maxPriority:
|
||||
number, decayRate: number): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`zoneContains` treats a missing `depth` on rectangular zones as 2D
|
||||
containment (no z-check), which matches the migrated upstream behaviour
|
||||
and lets callers seamlessly mix 2D and 3D entities. The custom-zone path
|
||||
intentionally invokes the user predicate without exception trapping —
|
||||
callers are responsible for ensuring `containsPoint` is total, and any
|
||||
thrown error propagates up through `InterestManager.isInZone`. The
|
||||
priority formula gives shared-zone entities a half-unit lift per
|
||||
subscribed zone so that interesting entities don't fall out of the top-N
|
||||
slice at the edge of `viewDistance`.
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# interest-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InterestManager` implements area-of-interest (AOI) culling for open-world
|
||||
multiplayer games: it tracks zones, entities, and players in memory and
|
||||
returns the bounded slice of entities each player can see, ranked by
|
||||
priority. The class is the standard mechanism for shrinking broadcast
|
||||
scope from O(world) to O(perceived) updates.
|
||||
|
||||
It is dependency-injectable: a `Clock` source provides timestamps and an
|
||||
OpenTelemetry `Meter` produces visibility-calculation counters and
|
||||
duration histograms. Configuration is validated with Zod; invalid input
|
||||
raises `GAME_SERVER.INVALID_INTEREST_CONFIG`. Unknown players passed to
|
||||
`updatePlayerPosition` raise
|
||||
`GAME_SERVER.INTEREST_PLAYER_NOT_REGISTERED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Three zone shapes: circular, rectangular (optionally 3D), custom
|
||||
(predicate-based).
|
||||
- Per-player `viewDistance` override (defaults to
|
||||
`config.defaultViewDistance`).
|
||||
- Priority-weighted visibility ranking via `maxPriority - distance ×
|
||||
decay + sharedZones × 0.5` with output clamped into `[0, maxPriority]`.
|
||||
- `getVisibleEntities` slices to `maxEntitiesPerPlayer`.
|
||||
- `calculateVisibilityChanges` emits enter/exit deltas only when the set
|
||||
actually changes (returns `null` on no-op).
|
||||
- Typed event registry: `entity:added/removed`,
|
||||
`player:subscribed/unsubscribed`, `visibility:changed`.
|
||||
- OTel: `game_server.interest.visibility_calc_total` (counter),
|
||||
`game_server.interest.visibility_calc_duration_ms` (histogram).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
|
||||
|
||||
const aoi = new InterestManager({ defaultViewDistance: 50 });
|
||||
aoi.registerPlayer("p1", { x: 0, y: 0 });
|
||||
aoi.updateEntity("npc", { x: 10, y: 0 }, { kind: "merchant" });
|
||||
const visible = aoi.getVisibleEntities("p1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
|
||||
|
||||
const aoi = new InterestManager<{ name: string }>(
|
||||
{
|
||||
defaultViewDistance: 80,
|
||||
maxEntitiesPerPlayer: 100,
|
||||
priorityDecayRate: 0.05,
|
||||
maxPriority: 20,
|
||||
},
|
||||
{ clock: () => testNow }
|
||||
);
|
||||
|
||||
aoi.registerZone({
|
||||
id: "downtown",
|
||||
shape: "rectangular",
|
||||
center: { x: 0, y: 0, z: 0 },
|
||||
width: 200,
|
||||
height: 200,
|
||||
depth: 30,
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
aoi.registerPlayer("p1", { x: 1, y: 1, z: 0 });
|
||||
aoi.subscribeToZone("p1", "downtown");
|
||||
|
||||
const unsub = aoi.on("visibility:changed", (change) => {
|
||||
syncEnterExitToClient(change.playerId, change.entered, change.exited);
|
||||
});
|
||||
|
||||
// In the server tick:
|
||||
aoi.updateEntity("merchant", { x: 5, y: 5, z: 0 }, { name: "Sera" });
|
||||
aoi.calculateVisibilityChanges("p1");
|
||||
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new InterestManager(config, deps?)` — validates `config` with
|
||||
`InterestManagerConfigSchema`; throws
|
||||
`GAME_SERVER.INVALID_INTEREST_CONFIG` on failure.
|
||||
- `registerZone(zone)` / `unregisterZone(zoneId)`; throws
|
||||
`GAME_SERVER.INTEREST_REGION_INVALID` on empty `zone.id`.
|
||||
- `updateEntity(id, position, data?, type?)` / `removeEntity(id)`.
|
||||
- `registerPlayer(id, position, viewDistance?)` /
|
||||
`updatePlayerPosition(id, position)` / `unregisterPlayer(id)`.
|
||||
- `subscribeToZone(playerId, zoneId)` /
|
||||
`unsubscribeFromZone(playerId, zoneId)`.
|
||||
- `getVisibleEntities(playerId)` / `calculateVisibilityChanges(playerId)`.
|
||||
- `isInZone(zoneId, point)` / `getZonesAtPoint(point)` / `getZone` /
|
||||
`getAllZones` / `getEntity` / `getStats` / `dispose`.
|
||||
- `on(event, listener) → unsubscribe`; `off(event, listener)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Geometry primitives (`vectorDistance`, `zoneContains`, `calcPriority`)
|
||||
live in `interest-manager.internal.ts` so the main class is dominated by
|
||||
lifecycle, dispatch, and metrics emission. `getVisibleEntities` is the
|
||||
hot path; it sorts the candidate set in descending priority and slices to
|
||||
`maxEntitiesPerPlayer` so the result is always bounded. The shared-zones
|
||||
walk only checks zones the player explicitly subscribed to, so an entity
|
||||
in an unsubscribed zone is treated as if the zone were absent. Visibility
|
||||
deltas are derived against the previous result rather than a snapshot of
|
||||
the world to support incremental synchronisation. `dispose` clears every
|
||||
internal collection so the manager can be safely garbage-collected after
|
||||
shutdown.
|
||||
@@ -0,0 +1,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
@@ -0,0 +1,70 @@
|
||||
# lag-compensator.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helpers backing `LagCompensator`: `RTT_SAMPLE_SIZE`, the
|
||||
`initialStats` factory, RTT-sample-window management (`pushRttSample`,
|
||||
`calcLatency`), and snapshot neighbour-lookup logic (`findNeighbors`,
|
||||
`pickClosest`).
|
||||
|
||||
Splitting these out keeps the main class focused on lifecycle, dependency
|
||||
injection, OTel emission, and event dispatch, and lets the neighbour
|
||||
search be tested independently against contrived snapshot sequences.
|
||||
|
||||
## Features
|
||||
|
||||
- `RTT_SAMPLE_SIZE` constant (20).
|
||||
- `initialStats()` factory.
|
||||
- `pushRttSample(samples, rtt, cap)` — appends and trims oldest.
|
||||
- `calcLatency(playerId, rtt, samples, now)` — average, min, max, jitter.
|
||||
- `findNeighbors(history, rewindTimestamp)` — earliest snapshot strictly
|
||||
after, latest snapshot at-or-before.
|
||||
- `pickClosest(before, after, rewind)` — closer-of-two by absolute time
|
||||
delta, ties prefer `before`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
initialStats,
|
||||
pushRttSample,
|
||||
} from "./lag-compensator.internal.js";
|
||||
|
||||
const samples = pushRttSample([], 80, 20);
|
||||
initialStats().totalLookups; // 0
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { findNeighbors, pickClosest } from "./lag-compensator.internal.js";
|
||||
|
||||
const history = [
|
||||
{ tick: 1, timestamp: 100, state: { x: 0 }, isServerState: true },
|
||||
{ tick: 2, timestamp: 200, state: { x: 5 }, isServerState: true },
|
||||
];
|
||||
const { before, after } = findNeighbors(history, 150);
|
||||
const closest = before && after ? pickClosest(before, after, 150) : before;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RTT_SAMPLE_SIZE: 20`
|
||||
- `initialStats(): LagCompensatorStats`
|
||||
- `pushRttSample(samples: number[], rtt: number, cap: number): number[]`
|
||||
- `calcLatency(playerId: string, rtt: number, samples: number[], now:
|
||||
number): PlayerLatency`
|
||||
- `findNeighbors<TState>(history, rewindTimestamp): NeighborResult<TState>`
|
||||
- `pickClosest<TState>(before, after, rewind): StateSnapshot<TState>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`pushRttSample` returns a new array each call to keep ownership semantics
|
||||
clear and allow callers to detect a size change. `calcLatency` always
|
||||
divides by the actual sample count so the first update produces sensible
|
||||
stats; `Math.min(...samples)`/`Math.max(...samples)` are safe at 20-sample
|
||||
max. `findNeighbors` walks the history once and stops as soon as the
|
||||
strictly-greater snapshot is found, so it is O(N) in worst-case but
|
||||
typically terminates within a handful of iterations for a 128-frame
|
||||
buffer. The tie-break in `pickClosest` picks the earlier snapshot to
|
||||
match canonical rollback-network semantics.
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
# lag-compensator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LagCompensator` performs server-side state rewinding for accurate hit
|
||||
registration in networked multiplayer games. It maintains a bounded
|
||||
per-entity ring buffer of `StateSnapshot` records, tracks per-player RTT
|
||||
statistics (average, min, max, jitter), and exposes a `validateHit`
|
||||
method that combines RTT compensation with snapshot interpolation to
|
||||
adjudicate hit candidates against the world state the shooter actually
|
||||
perceived at the moment of input.
|
||||
|
||||
The compensator is dependency-injectable: a `Clock` supplies timestamps
|
||||
for `recordState` defaults and latency timestamps; an OpenTelemetry
|
||||
`Meter` produces a lookup counter and a compensation-amount histogram.
|
||||
Configuration is validated with Zod at the constructor boundary; invalid
|
||||
input raises `GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG`.
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded per-entity history with `history:pruned` events on overflow.
|
||||
- Per-player RTT tracking with a 20-sample rolling window
|
||||
(`RTT_SAMPLE_SIZE`).
|
||||
- `getStateAtTime` supports exact-match, before-only, after-only,
|
||||
closest-neighbour, and lerp-interpolated lookups.
|
||||
- `validateHit` applies optional RTT compensation
|
||||
(`enableRttCompensation`), perceives the shooter and target at the
|
||||
same rewind point, runs a caller-supplied predicate, and emits
|
||||
`hit:validated` with both compensated states.
|
||||
- Compensation clamping at `maxCompensationMs` flagged on the result via
|
||||
`clamped: true`.
|
||||
- OTel: `game_server.lagcomp.lookup_total` (counter),
|
||||
`game_server.lagcomp.compensation_ms` (histogram).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LagCompensator } from "@suaveplan/game-server/enhanced/lag-compensation/lag-compensator";
|
||||
|
||||
const lc = new LagCompensator<{ x: number; y: number }>();
|
||||
lc.recordState("p1", { x: 0, y: 0 }, 1);
|
||||
lc.recordState("p2", { x: 5, y: 0 }, 1);
|
||||
const result = lc.validateHit({
|
||||
shooterId: "p1",
|
||||
targetId: "p2",
|
||||
actionTimestamp: Date.now(),
|
||||
serverTimestamp: Date.now(),
|
||||
validator: (s, t) => Math.hypot(s.x - t.x, s.y - t.y) <= 6,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LagCompensator } from "@suaveplan/game-server/enhanced/lag-compensation/lag-compensator";
|
||||
|
||||
let now = 0;
|
||||
const lc = new LagCompensator<{ x: number; y: number }>(
|
||||
{
|
||||
maxHistorySize: 256,
|
||||
maxCompensationMs: 200,
|
||||
enableRttCompensation: true,
|
||||
interpolationFactor: 1,
|
||||
},
|
||||
{ clock: () => now }
|
||||
);
|
||||
|
||||
lc.updateRtt("shooter", 80);
|
||||
|
||||
for (let t = 0; t < 10; t++) {
|
||||
now = t * 16;
|
||||
lc.recordState("shooter", { x: t, y: 0 }, t);
|
||||
lc.recordState("target", { x: t * 2, y: 0 }, t);
|
||||
}
|
||||
|
||||
const unsub = lc.on("hit:validated", (r) => audit.push(r));
|
||||
|
||||
const result = lc.validateHit(
|
||||
{
|
||||
shooterId: "shooter",
|
||||
targetId: "target",
|
||||
actionTimestamp: now - 50,
|
||||
serverTimestamp: now,
|
||||
validator: (s, t) => Math.hypot(s.x - t.x, s.y - t.y) <= 10,
|
||||
},
|
||||
(a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t })
|
||||
);
|
||||
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new LagCompensator(config?, deps?)` — validates `config` with
|
||||
`LagCompensatorConfigSchema`; throws
|
||||
`GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG` on failure.
|
||||
- `recordState(entityId, state, tick, timestamp = clock())`.
|
||||
- `updateRtt(playerId, rtt)` / `getPlayerLatency(playerId)`.
|
||||
- `getStateAtTime(entityId, targetTimestamp, currentTimestamp = clock(),
|
||||
lerp?)`.
|
||||
- `validateHit(request, lerp?)`.
|
||||
- `getTrackedEntities()`, `clearEntityHistory(entityId)`,
|
||||
`removePlayer(playerId)`, `getStats()`, `resetStats()`, `dispose()`.
|
||||
- `on(event, listener) → unsubscribe`; `off(event, listener)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interpolation factor is applied symmetrically as
|
||||
`f × raw + (1 − f) × raw`, identical to the upstream behaviour even
|
||||
though it collapses algebraically — kept verbatim so engines tuning
|
||||
`interpolationFactor` for visual smoothing match validation results.
|
||||
Tied neighbour timestamps prefer the earlier snapshot in `pickClosest`
|
||||
to match canonical rollback-network semantics. `validateHit` halves the
|
||||
shooter's RTT for compensation under the standard one-way-latency
|
||||
assumption; disabling `enableRttCompensation` lets callers run their own
|
||||
network model. The lookup counter tags `{clamped: "true" | "false"}` so
|
||||
observability dashboards can separate compensated-but-bounded queries
|
||||
from edge cases that hit the cap. `dispose` clears every collection and
|
||||
resets statistics so the compensator can be safely garbage-collected.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user