first commit
This commit is contained in:
166
docs/gaming/games/game-server/README.md
Normal file
166
docs/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)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# `AbstractGameLogic` — Template-Method base class for game rules
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AbstractGameLogic<TState, TMove>` is the abstract base every concrete
|
||||
game type extends. It implements the `IGameLogic` contract,
|
||||
orchestrates the `processMove` template method (validate → before hook →
|
||||
apply → state install → after hook → instrument), owns the player
|
||||
roster, and threads an injected `Clock` + `IdGenerator` so subclass move
|
||||
logic is deterministic by default. Every concrete game (chess, bingo,
|
||||
gem-crush, tic-tac-toe) fills in the five rule-specific abstract methods
|
||||
and optionally overrides the five lifecycle hooks; the base class
|
||||
handles the framework integration, instrumentation, and error
|
||||
discipline.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `IGameLogic` end-to-end — subclasses inherit `processMove`,
|
||||
`getGameState`, `dispose`, and player-roster utilities for free.
|
||||
- Injected `Clock` + `IdGenerator` — never calls `Date.now()` or
|
||||
`crypto.randomUUID()` directly; tests use fakes for determinism.
|
||||
- OTel instrumentation built in — every `processMove` records the
|
||||
`game_server.logic.tick_duration_ms` histogram and the
|
||||
`game_server.logic.moves_total` / `rejections_total` counters under the
|
||||
meter `@suaveplan/game-server` (override with `meterName`).
|
||||
- Typed errors — invalid moves, duplicate players, and missing players
|
||||
throw `GameServerError` with `INVALID_MOVE` / `PLAYER_ALREADY_EXISTS` /
|
||||
`PLAYER_NOT_FOUND` codes (no plain `Error`).
|
||||
- Lifecycle hooks (`beforeMoveProcessing`, `afterMoveProcessing`,
|
||||
`onPlayerAdded`, `onPlayerRemoved`, `onDispose`) — default no-ops;
|
||||
subclasses override only what they need.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbstractGameLogic,
|
||||
type GameResult,
|
||||
type MoveValidationResult,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
interface Counter { count: number; }
|
||||
type CounterMove = { delta: number };
|
||||
|
||||
class CounterGame extends AbstractGameLogic<Counter, CounterMove> {
|
||||
validateMove(_id: string, m: CounterMove): MoveValidationResult {
|
||||
return m.delta >= 0 ? this.valid() : this.invalid("negative");
|
||||
}
|
||||
protected applyMove(_id: string, m: CounterMove, s: Counter): Counter {
|
||||
return { count: s.count + m.delta };
|
||||
}
|
||||
isGameOver(s: Counter): boolean { return s.count >= 10; }
|
||||
getGameResult(s: Counter): GameResult {
|
||||
return { winner: s.count >= 10 ? "alice" : null };
|
||||
}
|
||||
initializeGame(): Counter { return { count: 0 }; }
|
||||
}
|
||||
|
||||
const game = new CounterGame({ initialState: { count: 0 } });
|
||||
game.addPlayer("alice");
|
||||
game.processMove("alice", { delta: 3 }, game.getGameState());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake clock and id generator for deterministic replay tests, and
|
||||
hook into the move-lifecycle to emit custom analytics:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbstractGameLogic,
|
||||
type GameResult,
|
||||
type MoveValidationResult,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
class ReplayableGame extends AbstractGameLogic<{ count: number }, { delta: number }> {
|
||||
private readonly moves: Array<{ id: string; delta: number; at: number }> = [];
|
||||
|
||||
validateMove(_id: string, m: { delta: number }): MoveValidationResult {
|
||||
return m.delta > 0 ? this.valid() : this.invalid("non-positive");
|
||||
}
|
||||
protected applyMove(_id: string, m: { delta: number }, s: { count: number }) {
|
||||
return { count: s.count + m.delta };
|
||||
}
|
||||
protected afterMoveProcessing(playerId: string, move: { delta: number }) {
|
||||
this.moves.push({
|
||||
id: this.nextId(),
|
||||
delta: move.delta,
|
||||
at: this.clock(),
|
||||
});
|
||||
}
|
||||
isGameOver(s: { count: number }) { return s.count >= 100; }
|
||||
getGameResult(s: { count: number }): GameResult {
|
||||
return { winner: s.count >= 100 ? "alice" : null };
|
||||
}
|
||||
initializeGame() { return { count: 0 }; }
|
||||
|
||||
getMoves() { return [...this.moves]; }
|
||||
}
|
||||
|
||||
let t = 0;
|
||||
const game = new ReplayableGame({
|
||||
initialState: { count: 0 },
|
||||
clock: () => ++t,
|
||||
idGenerator: () => `m-${t}`,
|
||||
});
|
||||
game.addPlayer("alice");
|
||||
game.processMove("alice", { delta: 5 }, game.getGameState());
|
||||
console.log(game.getMoves()); // [{ id: "m-1", delta: 5, at: 2 }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new AbstractGameLogic({ initialState, clock?, idGenerator?, meterName? })`
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `initialState` | required | Starting `TState`. |
|
||||
| `clock` | `() => Date.now()` | Injectable. |
|
||||
| `idGenerator` | `() => crypto.randomUUID()` | Injectable. |
|
||||
| `meterName` | `"@suaveplan/game-server"` | OTel meter. |
|
||||
|
||||
### Public methods
|
||||
|
||||
- `processMove(playerId, move, state): TState` — template method.
|
||||
- `getGameState(): TState`
|
||||
- `addPlayer(playerId, data?): void`
|
||||
- `removePlayer(playerId): void`
|
||||
- `getPlayer(playerId): unknown`
|
||||
- `hasPlayer(playerId): boolean`
|
||||
- `getPlayerIds(): ReadonlyArray<string>`
|
||||
- `getPlayerCount(): number`
|
||||
- `dispose(): void`
|
||||
|
||||
### Abstract methods (subclass MUST implement)
|
||||
|
||||
- `validateMove(playerId, move, state): MoveValidationResult`
|
||||
- `applyMove(playerId, move, state): TState` (protected)
|
||||
- `isGameOver(state): boolean`
|
||||
- `getGameResult(state): GameResult`
|
||||
- `initializeGame(config): TState`
|
||||
|
||||
### Hooks (subclass MAY override)
|
||||
|
||||
- `beforeMoveProcessing(playerId, move)`
|
||||
- `afterMoveProcessing(playerId, move, newState)`
|
||||
- `onPlayerAdded(playerId, data?)`
|
||||
- `onPlayerRemoved(playerId, data?)`
|
||||
- `onDispose()`
|
||||
|
||||
### Errors thrown
|
||||
|
||||
| Code | Cause |
|
||||
| --- | --- |
|
||||
| `GAME_SERVER.INVALID_MOVE` | `validateMove` returned `{ valid: false }`. |
|
||||
| `GAME_SERVER.PLAYER_ALREADY_EXISTS` | `addPlayer` on a known id. |
|
||||
| `GAME_SERVER.PLAYER_NOT_FOUND` | `removePlayer` on an unknown id. |
|
||||
|
||||
### OTel instruments
|
||||
|
||||
| Name | Kind | Attributes |
|
||||
| --- | --- | --- |
|
||||
| `game_server.logic.tick_duration_ms` | Histogram | none |
|
||||
| `game_server.logic.moves_total` | Counter | none |
|
||||
| `game_server.logic.rejections_total` | Counter | `reason` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`processMove` clamps the elapsed time with `Math.max(0, ...)` so a
|
||||
non-monotonic clock (fake or wall clock during NTP step-back) never
|
||||
records a negative histogram value. Instruments are cached by `meterName`
|
||||
— passing different names rebinds them, passing the same name reuses the
|
||||
existing meter so multiple `AbstractGameLogic` instances share one set
|
||||
of counters. `cloneState` uses JSON round-trip by default — subclasses
|
||||
holding non-JSON-safe state (Maps, Sets, Dates, class instances) should
|
||||
override it.
|
||||
@@ -0,0 +1,132 @@
|
||||
# `GameEventEmitter` — typed event emitter for the game framework
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameEventEmitter` is the typed event hub every game-server subsystem
|
||||
publishes to and listens on. It exposes the standard `on`/`once`/`off`/
|
||||
`emit` surface, plus four ergonomic extensions (`safeEmit`, `waitFor`,
|
||||
`onMultiple`, `onN`) that solve the common patterns observed in upstream
|
||||
game-server code without forcing callers to hand-roll wrappers. Listener
|
||||
payloads are a single, named, type-safe object per event key
|
||||
(`GameEvents[K]`), not the variadic tuples upstream used — this fits
|
||||
`@suaveplan/events`-style single-payload listeners and keeps the surface
|
||||
predictable when wiring into reactive bindings.
|
||||
|
||||
## Features
|
||||
|
||||
- Strongly-typed `on`/`once`/`emit` — payload type is inferred from the
|
||||
event key, no casts at call sites.
|
||||
- `safeEmit(event, payload)` — listener exceptions are caught and re-
|
||||
emitted on the `error` channel; the original emission keeps running for
|
||||
the remaining listeners.
|
||||
- `waitFor(event, timeoutMs?)` — returns a `Promise` that resolves with
|
||||
the next payload or rejects with `GameServerError(EVENT_WAIT_TIMEOUT)`
|
||||
after the timeout.
|
||||
- `onN(event, count, listener)` — auto-unsubscribes after `count` fires.
|
||||
- `onMultiple(events, listener)` — single listener observes multiple keys
|
||||
and receives the event name as its first arg.
|
||||
- Configurable per-key listener cap (default 100) — throws
|
||||
`GameServerError(LISTENER_LIMIT_EXCEEDED)` on overflow.
|
||||
- Mid-emit safety: listeners can call `off(...)` on themselves without
|
||||
shifting the iteration index of in-flight listeners.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
|
||||
const events = new GameEventEmitter();
|
||||
const unsub = events.on("game:started", ({ sessionId }) => {
|
||||
console.log("started", sessionId);
|
||||
});
|
||||
events.emit("game:started", { sessionId: "s1" });
|
||||
unsub();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wait for a `game:ended` event with a 30s ceiling, and use `safeEmit` so a
|
||||
faulty analytics listener can't bring down the rest of the pipeline:
|
||||
|
||||
```ts
|
||||
import {
|
||||
GameEventEmitter,
|
||||
GAME_SERVER_CODES,
|
||||
GameServerError,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
const events = new GameEventEmitter({ maxListenersPerKey: 250 });
|
||||
|
||||
events.on("error", ({ error }) => console.error("listener crashed", error));
|
||||
|
||||
events.on("game:ended", ({ sessionId, result }) => {
|
||||
if (Math.random() < 0.001) throw new Error("analytics flaked");
|
||||
console.log(`${sessionId} winner: ${result.winner}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const { sessionId } = await events.waitFor("game:ended", 30_000);
|
||||
console.log("game ended:", sessionId);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.EVENT_WAIT_TIMEOUT
|
||||
) {
|
||||
console.warn("no game ended in 30s — timing out");
|
||||
}
|
||||
}
|
||||
|
||||
// Elsewhere in the engine — safeEmit so analytics crash doesn't propagate.
|
||||
events.safeEmit("game:ended", {
|
||||
sessionId: "s1",
|
||||
result: { winner: "alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new GameEventEmitter(options?: GameEventEmitterOptions)`
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `maxListenersPerKey` | `100` | Set to `Infinity` to disable. |
|
||||
|
||||
### Methods
|
||||
|
||||
- `on(event, listener): Unsubscribe`
|
||||
- `once(event, listener): Unsubscribe`
|
||||
- `off(event, listener): void`
|
||||
- `emit(event, payload): boolean` — `true` iff at least one listener was invoked.
|
||||
- `safeEmit(event, payload): boolean` — listener errors emit on `"error"`.
|
||||
- `onMultiple(events, listener): Unsubscribe` — listener `(event, payload)`.
|
||||
- `waitFor(event, timeoutMs?): Promise<GameEvents[K]>`
|
||||
- `onN(event, count, listener): Unsubscribe` — `count > 0` required.
|
||||
- `listenerCount(event): number`
|
||||
- `removeAllListeners(): void`
|
||||
|
||||
### Errors thrown
|
||||
|
||||
| Code | Cause |
|
||||
| --- | --- |
|
||||
| `GAME_SERVER.LISTENER_LIMIT_EXCEEDED` | More than `maxListenersPerKey` listeners on one key. |
|
||||
| `GAME_SERVER.INVALID_LISTENER_COUNT` | `onN(count <= 0)`. |
|
||||
| `GAME_SERVER.EVENT_WAIT_TIMEOUT` | `waitFor(..., timeoutMs)` elapsed. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`emit` and `safeEmit` iterate a shallow snapshot (`[...list]`) so a
|
||||
listener that calls `off` for itself mid-fire does not cause the loop to
|
||||
skip the next listener. `safeEmit` deliberately does not recurse through
|
||||
`safeEmit("error", ...)` — it emits the `error` event directly and
|
||||
swallows secondary failures, because re-routing a failed error handler
|
||||
into the same `safeEmit` pipeline would mask the original cause.
|
||||
`waitFor(timeoutMs)` only schedules a timer when `timeoutMs` is a positive
|
||||
number — passing `0` is treated as "no timeout" so callers don't get a
|
||||
race condition between the immediate timeout-reject and the
|
||||
resolution path.
|
||||
@@ -0,0 +1,105 @@
|
||||
# `GameEvents` map and `GAME_EVENT_KEYS` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the typed event-payload map every game-server consumer
|
||||
keys off — players, sessions, game lifecycle, moves, and errors — plus
|
||||
the closed array `GAME_EVENT_KEYS` and predicate `isGameEventKey` that
|
||||
let downstream code enumerate or validate event names without hard-coding
|
||||
strings everywhere. suaveplan collapses upstream's variadic `[a, b]` tuples
|
||||
to single object payloads so the map satisfies `@suaveplan/events`'
|
||||
`EventMap = Record<string, unknown>` contract and listeners receive a
|
||||
single, named, shape-safe argument.
|
||||
|
||||
## Features
|
||||
|
||||
- Compile-time typed map (`GameEvents` interface in
|
||||
`../../types/core.types.ts`) — every key has a single payload shape.
|
||||
- Runtime registry (`GAME_EVENT_KEYS`) — readonly array of all 25
|
||||
well-known event names, in declaration order.
|
||||
- Predicate (`isGameEventKey(value)`) — narrows a `string` to a
|
||||
`keyof GameEvents` for safe wire-input validation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
GAME_EVENT_KEYS,
|
||||
isGameEventKey,
|
||||
} from "@suaveplan/game-server";
|
||||
|
||||
// Enumerate every event for analytics setup
|
||||
for (const key of GAME_EVENT_KEYS) {
|
||||
console.log("registering listener for", key);
|
||||
}
|
||||
|
||||
// Validate untrusted input
|
||||
function onWireMessage(raw: { event: string }): void {
|
||||
if (!isGameEventKey(raw.event)) {
|
||||
throw new Error(`unknown event: ${raw.event}`);
|
||||
}
|
||||
// raw.event is now `keyof GameEvents`
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the registry to mass-wire per-event counters at server startup so
|
||||
every emitted event is recorded without copy-pasting `metricsCounter`
|
||||
registrations:
|
||||
|
||||
```ts
|
||||
import {
|
||||
GAME_EVENT_KEYS,
|
||||
GameEventEmitter,
|
||||
} from "@suaveplan/game-server";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
export function instrumentEvents(emitter: GameEventEmitter): void {
|
||||
const meter = metrics.getMeter("@suaveplan/game-server");
|
||||
for (const key of GAME_EVENT_KEYS) {
|
||||
const counter = meter.createCounter(
|
||||
`game_server.events.${key.replace(/:/g, "_")}.total`,
|
||||
{ description: `Total ${key} events` }
|
||||
);
|
||||
emitter.on(key, () => counter.add(1));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `GAME_EVENT_KEYS: ReadonlyArray<keyof GameEvents>`
|
||||
|
||||
Frozen array literal. Declaration order matches the source-of-truth in
|
||||
`core.types.ts` so callers can build deterministic indexes off the array
|
||||
position.
|
||||
|
||||
### `isGameEventKey(value: string): value is keyof GameEvents`
|
||||
|
||||
Returns `true` iff `value` is a member of `GAME_EVENT_KEYS`. Narrows the
|
||||
input on the truthy branch.
|
||||
|
||||
### `GameEvents` (re-exported from `@suaveplan/game-server`)
|
||||
|
||||
| Group | Keys |
|
||||
| --- | --- |
|
||||
| Player | `player:connected`, `player:disconnected`, `player:reconnected`, `player:timeout`, `player:heartbeat`, `player:joined`, `player:left` |
|
||||
| Session | `session:created`, `session:destroyed`, `session:updated`, `session:metadata:changed` |
|
||||
| Game | `game:initialized`, `game:started`, `game:ended`, `game:paused`, `game:resumed`, `game:state:changed` |
|
||||
| Move | `move:requested`, `move:validated`, `move:processed`, `move:rejected` |
|
||||
| Error | `error`, `error:game`, `error:network`, `error:state` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The keys are deliberately declared in a single `const` array rather than
|
||||
derived via `Object.keys(...)` so the runtime registry stays exhaustive
|
||||
even if a future type-level edit adds a key without touching the array.
|
||||
The `conformance` gate covers the parity between
|
||||
`GAME_EVENT_KEYS` and the `GameEvents` interface — a key added to one
|
||||
must be added to the other, and missing entries surface as broken
|
||||
conformance tests rather than runtime silent failures.
|
||||
@@ -0,0 +1,122 @@
|
||||
# `IGameLogic` — strategy contract for pluggable game rules
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IGameLogic` is the strategy-pattern contract every concrete game type plugs
|
||||
into the universal game server through. The interface defines the seven
|
||||
methods (`validateMove`, `processMove`, `getGameState`, `isGameOver`,
|
||||
`getGameResult`, `initializeGame`, `dispose`) that the engine, session
|
||||
manager, and event pipeline all call against; concrete games — chess,
|
||||
bingo, tic-tac-toe, gem-crush — implement these methods to expose their
|
||||
rules to the framework. The companion `runIGameLogicConformance` harness
|
||||
gives migrators and contract testers a runtime probe that exercises every
|
||||
method against a caller-supplied factory and returns a structured pass/fail
|
||||
result list — interfaces alone are types-only and contribute no runtime
|
||||
coverage, so the harness is the bridge that makes the contract testable.
|
||||
|
||||
## Features
|
||||
|
||||
- Fully generic on `TState` and `TMove` — no `any` escape hatches; callers
|
||||
thread their own state and move types end-to-end.
|
||||
- Synchronous surface — every method returns directly so move processing
|
||||
can be driven by tight loops (matchmaking ticks, replay scrubbing).
|
||||
- Side-effect-free contract — `validateMove` MUST NOT mutate state; only
|
||||
`processMove` and `initializeGame` install state.
|
||||
- Conformance harness `runIGameLogicConformance(factory, fixtures)` runs
|
||||
the eight scenarios listed below and reports each as a
|
||||
`ConformanceCheckResult`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IGameLogic } from "@suaveplan/game-server";
|
||||
|
||||
class Counter implements IGameLogic<{ count: number }, { delta: number }> {
|
||||
private state = { count: 0 };
|
||||
initializeGame(): { count: number } {
|
||||
this.state = { count: 0 };
|
||||
return this.state;
|
||||
}
|
||||
getGameState() { return this.state; }
|
||||
validateMove(_id: string, move: { delta: number }) {
|
||||
return move.delta >= 0
|
||||
? { valid: true as const }
|
||||
: { valid: false as const, reason: "negative" };
|
||||
}
|
||||
processMove(id: string, move: { delta: number }, state: { count: number }) {
|
||||
const v = this.validateMove(id, move, state);
|
||||
if (!v.valid) throw new Error(v.reason);
|
||||
this.state = { count: state.count + move.delta };
|
||||
return this.state;
|
||||
}
|
||||
isGameOver(s: { count: number }) { return s.count >= 10; }
|
||||
getGameResult(s: { count: number }) {
|
||||
return { winner: s.count >= 10 ? "p1" : null };
|
||||
}
|
||||
dispose(): void { /* release resources */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Verify a third-party game implementation against the contract before
|
||||
plugging it into the engine:
|
||||
|
||||
```ts
|
||||
import { runIGameLogicConformance } from "@suaveplan/game-server";
|
||||
|
||||
const results = runIGameLogicConformance(() => new Counter(), {
|
||||
config: { start: 0 },
|
||||
playerId: "p1",
|
||||
validMove: { delta: 1 },
|
||||
invalidMove: { delta: -1 },
|
||||
});
|
||||
|
||||
const failures = results.filter((r) => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Implementation does not satisfy IGameLogic: ${failures
|
||||
.map((r) => `${r.scenario} (${r.detail ?? "no detail"})`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IGameLogic<TState, TMove>`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `validateMove(playerId, move, state)` | `MoveValidationResult` | Side-effect-free. |
|
||||
| `processMove(playerId, move, state)` | `TState` | MAY throw if validation fails. |
|
||||
| `getGameState()` | `TState` | Returns the implementation's installed state. |
|
||||
| `isGameOver(state)` | `boolean` | Pure predicate over `state`. |
|
||||
| `getGameResult(state)` | `GameResult` | Called once `isGameOver` returns true. |
|
||||
| `initializeGame(config)` | `TState` | Resets state from `Record<string, unknown>`. |
|
||||
| `dispose()` | `void` | Idempotent cleanup. |
|
||||
|
||||
### `runIGameLogicConformance(factory, fixtures)`
|
||||
|
||||
- `factory: IGameLogicFactory<TState, TMove>` — zero-arg factory producing
|
||||
a fresh impl. Called once per harness run.
|
||||
- `fixtures: IGameLogicFixtures<TState, TMove>` — `config`, `playerId`,
|
||||
`validMove`, `invalidMove`.
|
||||
- Returns `ConformanceCheckResult[]` — 8 entries, one per scenario.
|
||||
- Does not throw. Failures are captured as `{ passed: false, detail }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness deliberately runs scenarios in dependency order:
|
||||
`initializeGame` → `getGameState` → `validateMove` truthy → `validateMove`
|
||||
falsy → `processMove` → `isGameOver` → `getGameResult` → `dispose`. Each
|
||||
step uses the state returned by the previous one so the harness mirrors a
|
||||
real game tick. Because `processMove` is the only mutating call, the
|
||||
harness does not assert internal-state invariants beyond what `getGameState`
|
||||
returns — implementations that internally cache state with non-trivial
|
||||
semantics (CRDTs, event-sourced) should add their own integration tests
|
||||
in addition to running the conformance harness.
|
||||
@@ -0,0 +1,97 @@
|
||||
# `IPlayerManager` — player connection and lifecycle contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IPlayerManager` is the contract every connection-tracking implementation
|
||||
must satisfy so the engine, session manager, matchmaker, and analytics
|
||||
layer can address players uniformly. It owns the eleven lifecycle methods
|
||||
(`connect`, `disconnect`, `getConnection`, `getSessionPlayers`,
|
||||
`getAllConnections`, `heartbeat`, `checkTimeouts`, `updateMetadata`,
|
||||
`getPlayer`, `startHeartbeatMonitor`, `stopHeartbeatMonitor`) and the
|
||||
companion conformance harness `runIPlayerManagerConformance` that
|
||||
exercises each one end-to-end. Implementations are free to back the
|
||||
records with in-memory maps, Redis, SurrealDB, or any transport-specific
|
||||
store as long as the contract holds.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel-agnostic — `metadata` is `Record<string, unknown>` so transports
|
||||
(WebSocket, WebRTC, HTTP long-poll) can attach their own session shape.
|
||||
- Heartbeat-aware — `heartbeat`, `checkTimeouts`, `startHeartbeatMonitor`,
|
||||
`stopHeartbeatMonitor` give the lifecycle owner full timing control.
|
||||
- Session-aware — `getSessionPlayers(sessionId)` and the `sessionId` field
|
||||
let session managers do cheap per-session lookups.
|
||||
- Readonly returns — `getSessionPlayers` and `getAllConnections` return
|
||||
`ReadonlyArray<PlayerConnection>` so callers cannot accidentally mutate
|
||||
internal storage.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IPlayerManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: IPlayerManager;
|
||||
|
||||
const conn = mgr.connect("alice", "table:42", { region: "eu" });
|
||||
mgr.heartbeat("alice");
|
||||
mgr.updateMetadata("alice", { region: "us" });
|
||||
mgr.disconnect("alice", "client closed");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness as part of an integration test for a new
|
||||
SurrealDB-backed player manager:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runIPlayerManagerConformance } from "@suaveplan/game-server";
|
||||
import { SurrealPlayerManager } from "./surreal-player-manager.js";
|
||||
|
||||
describe("SurrealPlayerManager", () => {
|
||||
it("passes the IPlayerManager contract", { timeout: 5000 }, () => {
|
||||
const results = runIPlayerManagerConformance(
|
||||
() => new SurrealPlayerManager({ url: process.env.SURREAL_URL! })
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IPlayerManager`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `connect(playerId, sessionId, metadata?)` | `PlayerConnection` | Replaces any existing record for `playerId`. |
|
||||
| `disconnect(playerId, reason)` | `void` | Implementations MAY drop or mark records — the harness accepts either. |
|
||||
| `getConnection(playerId)` | `PlayerConnection \| null` | |
|
||||
| `getSessionPlayers(sessionId)` | `ReadonlyArray<PlayerConnection>` | |
|
||||
| `getAllConnections()` | `ReadonlyArray<PlayerConnection>` | |
|
||||
| `heartbeat(playerId)` | `void` | Updates `lastHeartbeat`. |
|
||||
| `checkTimeouts()` | `void` | MAY transition stale records to `timeout`. |
|
||||
| `updateMetadata(playerId, metadata)` | `void` | Merges into the record's `metadata`. |
|
||||
| `getPlayer(playerId)` | `PlayerInfo \| null` | |
|
||||
| `startHeartbeatMonitor(intervalMs)` | `void` | |
|
||||
| `stopHeartbeatMonitor()` | `void` | |
|
||||
|
||||
### `runIPlayerManagerConformance(factory)`
|
||||
|
||||
- `factory: IPlayerManagerFactory` — produces a fresh `IPlayerManager`.
|
||||
- Returns `IPlayerManagerCheckResult[]` (11 entries) — each carries the
|
||||
scenario name, a pass/fail flag, and optional detail.
|
||||
- Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness intentionally calls `startHeartbeatMonitor` followed
|
||||
immediately by `stopHeartbeatMonitor` and `checkTimeouts` so
|
||||
implementations that schedule background work via `setInterval` can be
|
||||
verified without leaking handles. The harness uses synthetic ids (`p1`,
|
||||
`s1`) — implementations that require validated id formats should expose
|
||||
their parsing as a pre-step or accept the harness fixture ids.
|
||||
@@ -0,0 +1,102 @@
|
||||
# `ISessionManager` — hierarchical session contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ISessionManager` is the contract every session-tree implementation must
|
||||
satisfy. The session hierarchy is the core organising structure of the
|
||||
game server — a `venue` contains `table`s, a `table` contains `queue`s, a
|
||||
`queue` contains `game`s, a `game` contains `player`s and `spectator`s —
|
||||
and the engine, matchmaker, and persistence layer all walk this tree
|
||||
through the methods defined here. The companion conformance harness
|
||||
`runISessionManagerConformance` exercises every method against a caller-
|
||||
supplied factory and reports per-scenario results.
|
||||
|
||||
## Features
|
||||
|
||||
- Arbitrary nesting depth — children reference parents, parents reference
|
||||
children, both directions are queryable in O(1) for a Map-backed impl.
|
||||
- Async persistence — `persist()` / `restore()` return promises so
|
||||
implementations can hit Redis, SurrealDB, or local storage without
|
||||
bending the contract.
|
||||
- Read-only result arrays — `getSessionsByType`, `getChildren`, and
|
||||
`getAllSessions` return `ReadonlyArray<SessionNode>` so callers cannot
|
||||
mutate internal structures.
|
||||
- Cascading destroy — `destroySession(id)` MUST recursively destroy every
|
||||
descendant. The conformance harness specifically checks this.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ISessionManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: ISessionManager;
|
||||
|
||||
const venue = mgr.createSession(null, {
|
||||
id: "v1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: Date.now(), updatedAt: Date.now() },
|
||||
});
|
||||
const table = mgr.createSession("v1", {
|
||||
id: "t1",
|
||||
type: "table",
|
||||
metadata: { createdAt: Date.now(), updatedAt: Date.now() },
|
||||
});
|
||||
mgr.destroySession("v1"); // removes table too
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness inside a vitest suite to verify a SurrealDB-
|
||||
backed session manager honours the cascade contract:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runISessionManagerConformance } from "@suaveplan/game-server";
|
||||
import { SurrealSessionManager } from "./surreal-session-manager.js";
|
||||
|
||||
describe("SurrealSessionManager", () => {
|
||||
it("passes the ISessionManager contract", { timeout: 30000 }, async () => {
|
||||
const results = await runISessionManagerConformance(
|
||||
() => new SurrealSessionManager({ url: process.env.SURREAL_URL! })
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ISessionManager`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `createSession(parentId, data)` | `SessionNode` | `parentId === null` for top-level. |
|
||||
| `getSession(id)` | `SessionNode \| null` | |
|
||||
| `destroySession(id)` | `void` | Cascades to descendants. |
|
||||
| `getSessionsByType(type)` | `ReadonlyArray<SessionNode>` | |
|
||||
| `getChildren(id)` | `ReadonlyArray<SessionNode>` | Immediate children only. |
|
||||
| `getParent(id)` | `SessionNode \| null` | |
|
||||
| `updateMetadata(id, partial)` | `void` | Shallow merge into metadata. |
|
||||
| `getAllSessions()` | `ReadonlyArray<SessionNode>` | Whole tree, flat. |
|
||||
| `persist()` | `Promise<void>` | |
|
||||
| `restore()` | `Promise<void>` | |
|
||||
|
||||
### `runISessionManagerConformance(factory)`
|
||||
|
||||
- `factory: ISessionManagerFactory` — produces a fresh `ISessionManager`.
|
||||
- Returns `Promise<ISessionManagerCheckResult[]>` (11 entries).
|
||||
- Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness creates one `venue` and one `table` to verify both top-level
|
||||
and nested creation paths. The cascade scenario destroys the root `venue1`
|
||||
and then checks that the previously-created child `table1` is also gone —
|
||||
implementations that leak children when their parent is destroyed will
|
||||
fail this scenario. `persist` and `restore` are called against an in-
|
||||
flight tree (i.e. without prior data) so implementations that require
|
||||
warm caches need to seed their own tests in addition to the harness.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `IStateManager` — immutable state contract with observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IStateManager<TState>` is the contract every state-management
|
||||
implementation in the game server stack must satisfy. State managers are
|
||||
the canonical home for per-session game data — board position, score,
|
||||
turn order, replay buffer — and the engine, persistence layer, and
|
||||
sync engine all observe state transitions through `subscribe`. suaveplan
|
||||
returns `Readonly<TState>` instead of `DeepReadonly<TState>` from
|
||||
`getState` because the deep mapped type interacts poorly with Zod-derived
|
||||
shapes and method-bearing objects; callers needing recursive readonly
|
||||
guarantees can layer their own typing on top.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic on `TState extends object` — no escape hatches; every method is
|
||||
fully typed.
|
||||
- Observability built-in — `subscribe(listener)` receives
|
||||
`StateChangeEvent<TState>` with previous + current + timestamp.
|
||||
- Replayable history — `getHistory()` returns the recorded transitions;
|
||||
`clearHistory()` empties them.
|
||||
- Granular updates — `update(key, value)`, `updateMany(partial)`, or
|
||||
`setState(replacement)` cover single-key, multi-key, and total
|
||||
replacement.
|
||||
- `reset()` reverts to the initial state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { IStateManager } from "@suaveplan/game-server";
|
||||
|
||||
declare const mgr: IStateManager<{ score: number; turn: string }>;
|
||||
|
||||
const unsub = mgr.subscribe((e) => {
|
||||
console.log("transition", e.previousState, "->", e.currentState);
|
||||
});
|
||||
mgr.update("score", 10);
|
||||
mgr.updateMany({ score: 12, turn: "p2" });
|
||||
mgr.setState({ score: 0, turn: "p1" });
|
||||
unsub();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Run the conformance harness against a third-party Immer-backed state
|
||||
manager and surface any contract violations:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runIStateManagerConformance } from "@suaveplan/game-server";
|
||||
import { ImmerStateManager } from "./immer-state-manager.js";
|
||||
|
||||
describe("ImmerStateManager", () => {
|
||||
it("passes the IStateManager contract", { timeout: 5000 }, () => {
|
||||
const results = runIStateManagerConformance(
|
||||
() => new ImmerStateManager({ score: 0, turn: "p1" }),
|
||||
{
|
||||
updateKey: "score",
|
||||
updateValue: 5,
|
||||
partialUpdate: { turn: "p2" },
|
||||
replacement: { score: 99, turn: "p1" },
|
||||
}
|
||||
);
|
||||
for (const r of results) expect(r.passed, r.scenario).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IStateManager<TState extends object>`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `getState()` | `Readonly<TState>` | |
|
||||
| `update<K>(key, value)` | `void` | Emits `StateChangeEvent`. |
|
||||
| `updateMany(partial)` | `void` | Single batched event. |
|
||||
| `setState(next)` | `void` | Full replacement. |
|
||||
| `subscribe(listener)` | `() => void` | Returns unsubscribe. |
|
||||
| `reset()` | `void` | Reverts to constructor state. |
|
||||
| `getHistory()` | `ReadonlyArray<StateChangeEvent<TState>>` | |
|
||||
| `clearHistory()` | `void` | |
|
||||
|
||||
### `runIStateManagerConformance(factory, fixtures)`
|
||||
|
||||
- `factory: IStateManagerFactory<TState>` — fresh manager per run.
|
||||
- `fixtures: IStateManagerFixtures<TState>` — drives the harness.
|
||||
- Returns `IStateManagerCheckResult[]` (9 entries).
|
||||
- Does not throw; reports failures in-band.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness counts observed events per scenario rather than asserting on
|
||||
specific payload shapes because `update` / `updateMany` / `setState` may
|
||||
internally coalesce or batch — what matters is that at least one event is
|
||||
delivered per call. The unsubscribe scenario asserts strict equality
|
||||
(`observed === 3`) on the post-unsubscribe call to catch implementations
|
||||
that return a no-op cleanup function. Reset is the last call so it does
|
||||
not interfere with history-buffer scenarios.
|
||||
@@ -0,0 +1,131 @@
|
||||
# DIContainer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DIContainer` is a small, type-safe service locator that the broader
|
||||
`@suaveplan/game-server` engine uses to thread shared dependencies — a
|
||||
`Clock`, a `LoggerLike`, an `EventBus`, networking adapters, plugin
|
||||
registries — through component boundaries without forcing every call
|
||||
site to take a deep parameter list. It is intentionally minimal: register
|
||||
a factory under a string or symbol identifier, resolve to either a cached
|
||||
singleton or a fresh transient instance, and inspect what is registered
|
||||
for testing.
|
||||
|
||||
This file is the first feature migrated from the reference branch
|
||||
`claude/review-game-engine-arch-Kaykr`
|
||||
(`src/engine/DIContainer.ts`). The port replaces the upstream's
|
||||
plain `throw new Error(...)` on unresolved identifiers with a typed
|
||||
`GameServerError` carrying the stable code
|
||||
`GAME_SERVER.SERVICE_NOT_REGISTERED`, so consumers can branch on
|
||||
`error.code` rather than scraping message strings.
|
||||
|
||||
## Features
|
||||
|
||||
- **Singleton lifecycle** — factory invoked once; the instance is cached.
|
||||
- **Transient lifecycle** — factory invoked on every `resolve`.
|
||||
- **Typed identifiers** — generic parameter on `ServiceIdentifier<T>` carries through `register` and `resolve`.
|
||||
- **Fluent API** — `register` returns `this` for chaining.
|
||||
- **Re-register invalidates cache** — calling `register` again clears any cached singleton instance (improvement over upstream).
|
||||
- **Typed error on miss** — `GameServerError(GAME_SERVER.SERVICE_NOT_REGISTERED)`.
|
||||
- **Introspection** — `has`, `getRegisteredServices`, and `clear` round out the surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { DIContainer } from "@suaveplan/game-server/engine/di-container";
|
||||
|
||||
const container = new DIContainer();
|
||||
|
||||
container.register("logger", () => new ConsoleLogger(), "singleton");
|
||||
container.register("requestId", () => crypto.randomUUID(), "transient");
|
||||
|
||||
const logger1 = container.resolve<ConsoleLogger>("logger");
|
||||
const logger2 = container.resolve<ConsoleLogger>("logger");
|
||||
console.log(logger1 === logger2); // true — singleton
|
||||
|
||||
const r1 = container.resolve<string>("requestId");
|
||||
const r2 = container.resolve<string>("requestId");
|
||||
console.log(r1 === r2); // false — transient
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { DIContainer } from "@suaveplan/game-server/engine/di-container";
|
||||
import { GAME_SERVER_CODES, GameServerError } from "@suaveplan/game-server/errors";
|
||||
|
||||
interface IClock { now(): number }
|
||||
const CLOCK = Symbol("Clock") as ServiceIdentifier<IClock>;
|
||||
|
||||
const container = new DIContainer()
|
||||
.register<IClock>(CLOCK, () => ({ now: () => Date.now() }))
|
||||
.register("events", () => new EventBus());
|
||||
|
||||
try {
|
||||
container.resolve("nope");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.SERVICE_NOT_REGISTERED
|
||||
) {
|
||||
// surface a typed missing-dep diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
// Re-registering invalidates the cached singleton:
|
||||
container.register<IClock>(CLOCK, () => new TestClock());
|
||||
const freshClock = container.resolve<IClock>(CLOCK); // new TestClock instance
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DIContainer`
|
||||
|
||||
#### `register<T>(identifier, factory, lifecycle = "singleton"): this`
|
||||
|
||||
Register a service. Re-registering an identifier replaces the factory
|
||||
and discards any cached singleton instance.
|
||||
|
||||
#### `resolve<T>(identifier): T`
|
||||
|
||||
Resolve a registered service. Throws `GameServerError` with code
|
||||
`GAME_SERVER.SERVICE_NOT_REGISTERED` if the identifier is unknown.
|
||||
|
||||
#### `has(identifier): boolean`
|
||||
|
||||
#### `unregister(identifier): boolean`
|
||||
|
||||
Returns `true` when the identifier was previously registered.
|
||||
|
||||
#### `clear(): void`
|
||||
|
||||
Remove every registration and cached singleton.
|
||||
|
||||
#### `getRegisteredServices(): ServiceIdentifier[]`
|
||||
|
||||
### Types
|
||||
|
||||
- `ServiceIdentifier<T = unknown>` — `string | symbol`.
|
||||
- `ServiceLifecycle` — `"singleton" | "transient"`.
|
||||
- `ServiceDefinition<T>` — internal record holding identifier, factory, singleton flag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Re-register-invalidates-cache is the only behavioural change versus the
|
||||
upstream. The upstream kept the stale singleton after a second
|
||||
`register` call, which masked test-double swaps — code that worked in
|
||||
unit tests because the test installed its fake first, but failed in
|
||||
integration because production startup happened to register the real
|
||||
service first. Discarding the cached instance on re-register matches the
|
||||
"last writer wins" semantics every other suaveplan registry uses
|
||||
(`@suaveplan/events`, `@suaveplan/config`).
|
||||
|
||||
The unresolved-identifier path raises `GameServerError`, not a plain
|
||||
`Error`, because everything in this package is expected to be
|
||||
discriminable via `error.code`. `String(identifier)` is included in
|
||||
both the message and the `context` bag so symbols and plain strings
|
||||
surface identically in logs.
|
||||
@@ -0,0 +1,153 @@
|
||||
# GameEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameEngine` is the top-level orchestrator for `@suaveplan/game-server`. It owns a `DIContainer`, a `GameLoop`, and a `PluginManager`, plus opaque structural slots (`sessionManager`, `playerManager`, `stateManager`, `gameLogic`) that downstream waves fill in. The engine never reaches into those slots — it only registers them in the container and disposes them on shutdown — so this Wave B-1 slice can land before the concrete session/state/network packages are ready and stay stable as they arrive.
|
||||
|
||||
The class enforces a strict lifecycle (`start → tick* → stop → dispose`) and rejects out-of-order calls with the corresponding `GameServerError`: `ENGINE_NOT_STARTED`, `ENGINE_ALREADY_STARTED`, `ENGINE_DISPOSED`. Every transition emits a structured log line, a typed event onto the injected `EventBusLike`, and an OTel `game_server.engine.lifecycle_total{op}` counter sample. Construction Zod-validates the entire options bag at the boundary so callers see a `INVALID_ENGINE_OPTIONS` error immediately rather than later silent misbehaviour.
|
||||
|
||||
## Features
|
||||
|
||||
- Lifecycle FSM with `ENGINE_NOT_STARTED` / `ENGINE_ALREADY_STARTED` / `ENGINE_DISPOSED` guards.
|
||||
- Constructor-time DI registration under `ENGINE_SERVICE_IDS` so plugins and downstream packages can resolve dependencies through a single shared container.
|
||||
- Zod-validated options (`heartbeatIntervalMs`, `playerTimeoutMs`, `autoStartLoop`, `loop`) with `INVALID_ENGINE_OPTIONS` on failure.
|
||||
- `registerPlugin` / `getPlugin` delegates to the underlying `PluginManager`.
|
||||
- `getStats()` returns the union of engine state, loop stats, plugin stats, and session/player counts.
|
||||
- `dispose()` is idempotent and tears down loop, plugins, then opaque slots in reverse-construction order.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { GameEngine } from "@suaveplan/game-server/engine/game-engine";
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const loop = new GameLoop({ clock, timers, logger, events });
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
|
||||
const engine = new GameEngine(
|
||||
{
|
||||
clock,
|
||||
timers,
|
||||
idGenerator,
|
||||
logger,
|
||||
events,
|
||||
gameLoop: loop,
|
||||
pluginManager: plugins,
|
||||
},
|
||||
{ autoStartLoop: true, loop: { mode: "event-driven" } },
|
||||
);
|
||||
|
||||
engine.start();
|
||||
await engine.tick();
|
||||
await engine.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { GameEngine, ENGINE_SERVICE_IDS } from "@suaveplan/game-server/engine/game-engine";
|
||||
|
||||
const engine = new GameEngine(
|
||||
{
|
||||
clock,
|
||||
timers,
|
||||
idGenerator,
|
||||
logger,
|
||||
events,
|
||||
gameLoop,
|
||||
pluginManager,
|
||||
sessionManager,
|
||||
playerManager,
|
||||
stateManager,
|
||||
gameLogic,
|
||||
},
|
||||
{ heartbeatIntervalMs: 5_000, playerTimeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
// Cross-cutting plugin resolves shared services through the engine container:
|
||||
await engine.registerPlugin({
|
||||
name: "telemetry",
|
||||
version: "1.0.0",
|
||||
async onInstall(ctx) {
|
||||
const log = engine.container.resolve(ENGINE_SERVICE_IDS.logger);
|
||||
log.info("telemetry plugin online", { ts: clock() });
|
||||
},
|
||||
});
|
||||
|
||||
engine.start();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await engine.tick();
|
||||
}
|
||||
engine.stop();
|
||||
await engine.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GameEngine`
|
||||
|
||||
#### `new GameEngine(deps, options?)`
|
||||
|
||||
`deps`: `{ clock, timers, idGenerator, logger, events, gameLoop,
|
||||
pluginManager, sessionManager?, playerManager?, stateManager?,
|
||||
gameLogic? }`. Throws `INVALID_ENGINE_OPTIONS` on schema failure.
|
||||
|
||||
#### `start(): void` / `stop(): void` / `tick(): Promise<void>`
|
||||
|
||||
Lifecycle FSM. Wrong-state calls throw the corresponding code.
|
||||
|
||||
#### `registerPlugin(plugin): Promise<void>` / `getPlugin(name): EnginePlugin`
|
||||
|
||||
Delegates to the underlying `PluginManager`.
|
||||
|
||||
#### `getStats(): GameEngineStats`
|
||||
|
||||
Union of `{ running, disposed, loop, plugins, sessions, players }`.
|
||||
|
||||
#### `isRunning(): boolean` / `isDisposed(): boolean`
|
||||
|
||||
Read-only state probes.
|
||||
|
||||
#### `dispose(): Promise<void>`
|
||||
|
||||
Idempotent shutdown. Future state-changing calls throw `ENGINE_DISPOSED`.
|
||||
|
||||
### Service identifiers
|
||||
|
||||
`ENGINE_SERVICE_IDS` — `logger`, `events`, `clock`, `timers`,
|
||||
`idGenerator`, `gameLoop`, `pluginManager`, `sessionManager`,
|
||||
`playerManager`, `stateManager`, `gameLogic`.
|
||||
|
||||
### Types
|
||||
|
||||
`GameEngineOptions`, `GameEngineOptionsInput`, `GameEngineStats`,
|
||||
`GameEngineDeps`, `GameEngineEventMap`, `ISessionManagerLike`,
|
||||
`IPlayerManagerLike`, `IStateManagerLike`, `IGameLogicLike`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The decision to expose `.container` publicly is deliberate — plugins
|
||||
need a way to reach shared services, and the upstream contract
|
||||
guarantees this. Hiding the container behind a `getService(name)`
|
||||
delegate would buy nothing and force every consumer to add an extra
|
||||
indirection.
|
||||
|
||||
`dispose()` traps any error from the chained cleanup steps and
|
||||
re-raises a `GameServerError` so callers can still discriminate via
|
||||
`code`. The first thing `dispose()` does on the error path is flip
|
||||
`disposed = true` so subsequent calls cannot accidentally re-enter
|
||||
shutdown mid-failure. This is the only place in the engine where state
|
||||
changes happen outside the strict success-path order, and it is
|
||||
documented because the failure mode (partial cleanup) is the worst kind
|
||||
of state.
|
||||
|
||||
The opaque session/player/state/game-logic slots are typed as `*Like`
|
||||
interfaces with `dispose?` as their only structural requirement. This
|
||||
matches the upstream contract while keeping Wave B-1 independent of
|
||||
Wave B-2's concrete shapes — downstream waves can extend these
|
||||
interfaces with whatever else they need without rebreaking the engine.
|
||||
139
docs/gaming/games/game-server/src/engine/game-loop/game-loop.md
Normal file
139
docs/gaming/games/game-server/src/engine/game-loop/game-loop.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# GameLoop
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameLoop` drives a deterministic, injection-friendly game loop with three operating modes (`tick-based`, `event-driven`, `hybrid`). Every wall-clock read routes through an injected `Clock` and every scheduled interval routes through an injected `TimersLike`, which is what makes the loop testable end-to-end under a `FakeClock` without resorting to `vi.useFakeTimers()` or other runner-pinned helpers.
|
||||
|
||||
The loop owns no state other than its callback registry and a small counter set (`tickCount`, `frameCount`, `totalTime`). Callers register update and fixed-update callbacks, then either start the loop in `tick-based`/`hybrid` mode so it schedules itself, or stay in `event-driven` mode and drive `tick()` / `triggerUpdate()` from their own dispatcher. Either way the loop emits a `loop:started` / `loop:stopped` / `loop:paused` / `loop:resumed` event onto the injected `EventBusLike`, and surfaces callback failures as `loop:error` so a host process can route them to its preferred error pipeline without the loop itself terminating.
|
||||
|
||||
## Features
|
||||
|
||||
- Three modes: `tick-based`, `event-driven`, `hybrid`.
|
||||
- `Clock` + `TimersLike` injection — no direct `Date.now()` or `setInterval`.
|
||||
- `Zod`-validated `GameLoopOptions` rejecting on `INVALID_GAME_LOOP_OPTIONS`.
|
||||
- Lifecycle errors as `GameServerError` (`GAME_LOOP_NOT_RUNNING`, `GAME_LOOP_ALREADY_RUNNING`).
|
||||
- Pause/resume refreshes `lastTickTime` so resumes never see a giant delta.
|
||||
- Callback errors surface via the injected event bus; the loop keeps running.
|
||||
- OTel `game_server.loop.tick_duration_ms` histogram sample per tick.
|
||||
- `dispose()` is idempotent and safely halts a running loop.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
|
||||
const loop = new GameLoop(
|
||||
{ clock: () => Date.now(), timers, logger, events },
|
||||
{ mode: "tick-based", tickRate: 30 },
|
||||
);
|
||||
const off = loop.onFixedUpdate((tick) => world.step(tick));
|
||||
loop.start();
|
||||
// later …
|
||||
off();
|
||||
loop.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { GameLoop } from "@suaveplan/game-server/engine/game-loop";
|
||||
import { FakeClock } from "@suaveplan/testing/fake-clock";
|
||||
|
||||
const fake = new FakeClock(0);
|
||||
const handles = new Map<number, { cb: () => void; due: number }>();
|
||||
let next = 1;
|
||||
const timers = {
|
||||
setTimer(cb: () => void, delayMs: number) {
|
||||
const h = next++;
|
||||
handles.set(h, { cb, due: fake.now() + delayMs });
|
||||
return h;
|
||||
},
|
||||
clearTimer(h: number) {
|
||||
handles.delete(h);
|
||||
},
|
||||
};
|
||||
|
||||
const loop = new GameLoop(
|
||||
{ clock: () => fake.now(), timers, logger, events },
|
||||
{ mode: "tick-based", tickRate: 60, maxDeltaTime: 50 },
|
||||
);
|
||||
loop.onUpdate(async (delta) => physics.advance(delta));
|
||||
loop.start();
|
||||
|
||||
// Drive the loop deterministically:
|
||||
fake.advance(16);
|
||||
for (const [, entry] of [...handles.entries()].filter(([, e]) => e.due <= fake.now())) {
|
||||
entry.cb();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GameLoop`
|
||||
|
||||
#### `new GameLoop(deps: GameLoopDeps, options?: GameLoopOptionsInput)`
|
||||
|
||||
`deps`: `{ clock, timers, logger, events }`. Zod-validates `options` and
|
||||
freezes them on the `.options` field. Throws
|
||||
`GameServerError(GAME_SERVER.INVALID_GAME_LOOP_OPTIONS)` on any schema
|
||||
failure.
|
||||
|
||||
#### `start(): void`
|
||||
|
||||
Begins the loop. Throws `GAME_LOOP_ALREADY_RUNNING` if called twice.
|
||||
|
||||
#### `stop(): void` / `pause(): void` / `resume(): void`
|
||||
|
||||
Throw `GAME_LOOP_NOT_RUNNING` if called before `start()`. `pause()` and
|
||||
`resume()` are no-ops when called repeatedly in the same state.
|
||||
|
||||
#### `tick(): Promise<void>` / `triggerUpdate(): Promise<void>`
|
||||
|
||||
Run a single tick or update phase. Both update `lastTickTime`, clamp
|
||||
delta to `maxDeltaTime`, and emit `game_server.loop.tick_duration_ms`.
|
||||
|
||||
#### `onUpdate(cb): () => void` / `onFixedUpdate(cb): () => void`
|
||||
|
||||
Register callbacks. Both return unsubscribe functions.
|
||||
|
||||
#### `getStats(): GameLoopStats`
|
||||
|
||||
Returns mode, running/paused flags, tick rate, totalTime, tickCount,
|
||||
frameCount, callback counts, averageTickMs.
|
||||
|
||||
#### `clearCallbacks(): void` / `dispose(): void`
|
||||
|
||||
`dispose()` is idempotent — calling it twice is safe.
|
||||
|
||||
### Types
|
||||
|
||||
`GameLoopMode`, `GameLoopOptions`, `GameLoopOptionsInput`, `GameLoopStats`,
|
||||
`GameLoopDeps`, `UpdateCallback`, `FixedUpdateCallback`, `GameLoopEventMap`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The scheduler uses `TimersLike.setTimer` recursively (one-shot, then
|
||||
re-schedule from inside the callback) rather than `setInterval`. This makes
|
||||
the loop's timing precisely additive — the next tick's delay starts from
|
||||
when the previous tick's callback completed, not from when the previous
|
||||
tick's timer fired. With a real timer that introduces a small per-tick
|
||||
jitter; with a `FakeClock`-driven timer it makes deterministic test
|
||||
playback trivial.
|
||||
|
||||
`runFixedUpdates` and `runUpdates` iterate the callback `Set` in insertion
|
||||
order (which `Set` guarantees) and catch each callback's error
|
||||
independently. A throwing callback never blocks its siblings, and the
|
||||
loop never terminates from a callback failure — both behaviours match the
|
||||
upstream contract documented in `../suaveplan/.../GameLoop.ts`.
|
||||
|
||||
`dispose()` is structurally idempotent: it flips a `disposed` flag then
|
||||
short-circuits subsequent calls. After dispose, `tick()` and
|
||||
`triggerUpdate()` silently no-op rather than throw — this is the only
|
||||
deliberate lifecycle leniency in the class. The reason: `dispose()` is
|
||||
typically called from a host shutdown hook, and host hosts often race
|
||||
with in-flight tick scheduling. Throwing here would produce noisy
|
||||
shutdown errors that consumers cannot meaningfully act on.
|
||||
@@ -0,0 +1,132 @@
|
||||
# PlayerManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PlayerManager` is the `IPlayerManager` implementation backing the player
|
||||
lifecycle in `@suaveplan/game-server`. It tracks every player's connection
|
||||
state (`connected`, `disconnected`, `reconnecting`, `timeout`), the session
|
||||
each player is bound to, the heartbeat-driven liveness signal, and the
|
||||
reconnection-grace window that distinguishes a transient disconnect from a
|
||||
permanent departure. Every wall-clock observation routes through the
|
||||
injected `Clock`, and the optional heartbeat-monitor loop schedules itself
|
||||
via the injected `TimersLike` — there are no `Date.now()` or `setInterval`
|
||||
calls.
|
||||
|
||||
The manager emits a typed `PlayerManagerEventMap` onto the injected
|
||||
EventBus so other subsystems (matchmaking, presence, audit) can react to
|
||||
join/leave/timeout signals without polling. Every state mutation is also
|
||||
mirrored to OTel via `recordPlayerLifecycle` so dashboards see the full
|
||||
counter set.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's canonical `IPlayerManager` contract.
|
||||
- Injectable `Clock` and `TimersLike` — fully deterministic under fakes.
|
||||
- Zod-validated options boundary (`timeoutThresholdMs`,
|
||||
`reconnectionGraceMs`) with stable `INVALID_PLAYER_MANAGER_OPTIONS`
|
||||
failure code.
|
||||
- `INVALID_HEARTBEAT_INTERVAL` for non-positive integer interval inputs.
|
||||
- Reconnection path: a `connect` against an existing disconnected /
|
||||
timeout entry transitions to `reconnecting`/`connected` rather than
|
||||
building a fresh record (preserves `connectedAt`).
|
||||
- Grace-window cleanup: in `checkTimeouts`, disconnected entries older
|
||||
than `reconnectionGraceMs` are evicted so a future `connect` builds a
|
||||
fresh record.
|
||||
- Defensive copies — public API returns frozen plain-object snapshots
|
||||
rather than mutable internals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { PlayerManager } from "../player-manager/index.js";
|
||||
|
||||
const mgr = new PlayerManager({
|
||||
clock: () => Date.now(),
|
||||
timers,
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
mgr.connect("alice", "table-1", { tier: "gold" });
|
||||
mgr.heartbeat("alice");
|
||||
console.log(mgr.getConnection("alice")?.status); // "connected"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Custom timeout + grace window, with the heartbeat monitor running
|
||||
// via the injected timers source.
|
||||
const mgr = new PlayerManager(deps, {
|
||||
timeoutThresholdMs: 60_000,
|
||||
reconnectionGraceMs: 5_000,
|
||||
});
|
||||
|
||||
mgr.startHeartbeatMonitor(1_000); // sweep every second
|
||||
mgr.connect("alice", "table-1");
|
||||
// ...network drop...
|
||||
mgr.disconnect("alice", "network_lost");
|
||||
// 5s pass without reconnect — checkTimeouts evicts the entry.
|
||||
// A later connect("alice", "table-1") builds a fresh record.
|
||||
mgr.stopHeartbeatMonitor();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new PlayerManager(deps, options?)` where `deps` is
|
||||
`{ clock, timers, logger, events }`. Options:
|
||||
- `timeoutThresholdMs` (default 30000) — ms without heartbeat before a
|
||||
connected player is marked `timeout`.
|
||||
- `reconnectionGraceMs` (default 10000) — ms after a disconnect during
|
||||
which a `connect()` is treated as a reconnection.
|
||||
|
||||
### IPlayerManager surface (11 methods)
|
||||
|
||||
`connect`, `disconnect`, `getConnection`, `getSessionPlayers`,
|
||||
`getAllConnections`, `heartbeat`, `checkTimeouts`, `updateMetadata`,
|
||||
`getPlayer`, `startHeartbeatMonitor`, `stopHeartbeatMonitor`.
|
||||
|
||||
### Extras
|
||||
|
||||
- `getStats()` — connection-status histogram + average duration.
|
||||
- `dispose()` — stop monitor + drop every connection.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_PLAYER_MANAGER_OPTIONS` — Zod failure on constructor.
|
||||
- `INVALID_HEARTBEAT_INTERVAL` — non-positive integer to
|
||||
`startHeartbeatMonitor`.
|
||||
|
||||
### Events
|
||||
|
||||
`player:connected`, `player:disconnected`, `player:reconnected`,
|
||||
`player:timeout`, `player:heartbeat`, `player:joined`, `player:left`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The heartbeat monitor is implemented via `TimersLike.setTimer` chained in
|
||||
a self-rescheduling tick rather than via `setInterval`. That makes the
|
||||
behaviour deterministic under `FakeTimers` — the test suite explicitly
|
||||
fires due timers rather than depending on real wall-clock progression.
|
||||
The chain self-cancels by checking `heartbeatHandle !== undefined` before
|
||||
scheduling the next tick — so a `stopHeartbeatMonitor` between ticks
|
||||
prevents the next tick from being scheduled.
|
||||
|
||||
Connection records are mutable internally for hot-path efficiency
|
||||
(`heartbeat` is called every frame in worst case) but every public
|
||||
accessor returns a `freeze(...)`-built defensive copy. The metadata bag
|
||||
in particular is shallow-copied per accessor call — callers can mutate
|
||||
the returned object without affecting the internal state.
|
||||
|
||||
The grace-window logic in `checkTimeouts` is what distinguishes a
|
||||
"reconnection" from a "fresh connection". A disconnected entry within
|
||||
the grace window is still resolvable via `getConnection`, so a later
|
||||
`connect()` call routes through the reconnection path; once the grace
|
||||
expires, the entry is evicted entirely and a future `connect()` builds a
|
||||
fresh record (with a new `connectedAt`).
|
||||
@@ -0,0 +1,145 @@
|
||||
# PluginManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PluginManager` is the typed plugin registry for `@suaveplan/game-server`. It owns the install/uninstall/enable/disable lifecycle, drives plugin dependency ordering via topological sort, and wraps every plugin lifecycle hook in a single discriminable error code (`PLUGIN_LIFECYCLE_FAILED`) so consumers can centralise their error handling without scraping cause chains.
|
||||
|
||||
The manager carries no global state — plugins are stored under their declared `name`, and the install order is captured separately so `disposeAll()` can tear plugins down in reverse. Every state-changing call (`install`, `uninstall`, `enable`, `disable`) emits both a structured log line and a typed event onto the injected `EventBusLike`, and bumps the `game_server.plugin.lifecycle_total` OTel counter with a low-cardinality `op`/`outcome` attribute pair.
|
||||
|
||||
## Features
|
||||
|
||||
- Typed `EnginePlugin` contract with optional `onInstall` / `onUninstall` / `onEnable` / `onDisable` hooks.
|
||||
- Zod-validated plugin shape on every registration path.
|
||||
- `DUPLICATE_PLUGIN` on second install with the same name; `PLUGIN_NOT_FOUND` on every unknown-name lookup; `PLUGIN_LIFECYCLE_FAILED` wrapping hook errors, missing deps, and cycles.
|
||||
- `installAll(plugins)` accepts an unordered batch and resolves dependencies via topological sort.
|
||||
- `disposeAll()` tears plugins down in reverse install order; `isEnabled` is checked before `onDisable` fires (so callers can call uninstall on a still-enabled plugin without an explicit disable first).
|
||||
- Structured logging + typed event emission on every state change.
|
||||
- OTel `game_server.plugin.lifecycle_total` counter incremented with `op`/`outcome` attributes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
await plugins.install({
|
||||
name: "metrics",
|
||||
version: "1.0.0",
|
||||
async onInstall(ctx) {
|
||||
ctx.logger.info("metrics plugin online");
|
||||
},
|
||||
});
|
||||
await plugins.enable("metrics");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { PluginManager } from "@suaveplan/game-server/engine/plugin-manager";
|
||||
|
||||
const plugins = new PluginManager({ logger, events });
|
||||
|
||||
await plugins.installAll([
|
||||
{ name: "metrics", version: "1.0.0" },
|
||||
{
|
||||
name: "leaderboard",
|
||||
version: "0.3.0",
|
||||
dependencies: ["metrics"],
|
||||
async onInstall(ctx) {
|
||||
ctx.events.emit("plugin:installed", {
|
||||
name: "leaderboard",
|
||||
version: "0.3.0",
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// `metrics` is installed first because `leaderboard.dependencies = ["metrics"]`.
|
||||
|
||||
await plugins.enable("leaderboard");
|
||||
await plugins.enable("metrics");
|
||||
|
||||
// Errors carry the offending plugin + hook name:
|
||||
try {
|
||||
await plugins.install({
|
||||
name: "broken",
|
||||
version: "0.1.0",
|
||||
async onInstall() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === "GAME_SERVER.PLUGIN_LIFECYCLE_FAILED") {
|
||||
console.log(err.context); // { name: "broken", hook: "onInstall" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class PluginManager`
|
||||
|
||||
#### `new PluginManager(deps)`
|
||||
|
||||
`deps`: `{ logger: LoggerLike; events: EventBusLike<PluginManagerEventMap> }`.
|
||||
|
||||
#### `install(plugin): Promise<void>` / `installAll(plugins): Promise<void>`
|
||||
|
||||
`install` rejects with `DUPLICATE_PLUGIN` on a re-install. `installAll`
|
||||
topologically orders the batch; a missing dependency or a cycle rejects
|
||||
with `PLUGIN_LIFECYCLE_FAILED` whose `context` carries either `{ missing, requestedBy }`
|
||||
or `{ cycle }`.
|
||||
|
||||
#### `uninstall(name): Promise<void>`
|
||||
|
||||
Disables the plugin first if it is currently enabled, then runs
|
||||
`onUninstall`. Rejects with `PLUGIN_NOT_FOUND` on unknown name.
|
||||
|
||||
#### `enable(name): Promise<void>` / `disable(name): Promise<void>`
|
||||
|
||||
Idempotent — calling `enable` on an already-enabled plugin is a no-op
|
||||
that does not run the hook again.
|
||||
|
||||
#### `get(name): EnginePlugin` / `has(name): boolean` / `isEnabled(name): boolean`
|
||||
|
||||
`get` and `isEnabled` throw `PLUGIN_NOT_FOUND` on unknown name; `has`
|
||||
returns a boolean.
|
||||
|
||||
#### `list(): readonly string[]` / `getStats(): PluginManagerStats`
|
||||
|
||||
`list()` returns the install order; `getStats()` adds `enabled`/`disabled`
|
||||
counts.
|
||||
|
||||
#### `disposeAll(): Promise<void>`
|
||||
|
||||
Uninstall every plugin in reverse install order.
|
||||
|
||||
### Types
|
||||
|
||||
`EnginePlugin`, `EnginePluginContext`, `PluginManagerStats`,
|
||||
`PluginManagerEventMap`, `PluginManagerDeps`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Topological sort uses the standard three-set Tarjan algorithm
|
||||
(`visited` / `visiting` plus a `path` accumulator) so cycles surface with
|
||||
the offending edge sequence rather than a generic message. The error
|
||||
carries the cycle string in `context.cycle` so log scrapers can group by
|
||||
the participating plugins without parsing free text.
|
||||
|
||||
The decision to fold "schema validation" failures into the
|
||||
`PLUGIN_LIFECYCLE_FAILED` code rather than a dedicated `INVALID_PLUGIN`
|
||||
code keeps the error surface narrow — every plugin-related failure has
|
||||
the same discriminable code, with the specific failure mode visible in
|
||||
`context.hook` or `context.missing` or `context.cycle`. Consumers that
|
||||
want to branch on the precise failure mode read those fields; the
|
||||
`code` field is enough for a catch-all log emit.
|
||||
|
||||
`disposeAll` walks the install order in reverse so plugins with declared
|
||||
dependencies tear down after their dependents. This mirrors the
|
||||
upstream contract: a logging plugin installed first must be the last to
|
||||
go, because every other plugin's `onUninstall` may still need to log.
|
||||
@@ -0,0 +1,128 @@
|
||||
# SessionLifecycle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionLifecycle` is the per-session finite-state machine that models
|
||||
each session's progression through the three high-level phases of its
|
||||
existence: `lobby` (open for players, not yet running), `active` (the game
|
||||
is in progress), and `ended` (no further transitions allowed). The class
|
||||
owns the FSM transition table, the per-session entry record, and a full
|
||||
audit history of every transition the manager has observed.
|
||||
|
||||
Each transition is validated against a fixed allow-table: `lobby → active`,
|
||||
`lobby → ended`, and `active → ended` are legal; every other pair is
|
||||
rejected with a stable error code. The manager emits a
|
||||
`session:transitioned` event onto the injected EventBus on every legal
|
||||
transition and records two OTel instruments: a counter for the transition
|
||||
itself and a histogram for the dwell time in the state being left.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed three-state vocabulary: `lobby`, `active`, `ended`.
|
||||
- Strict allow-table — illegal transitions throw
|
||||
`INVALID_SESSION_TRANSITION`; transitions out of the terminal `ended`
|
||||
state throw `SESSION_ALREADY_ENDED`.
|
||||
- Injectable `Clock` — every state-entry timestamp and dwell sample is
|
||||
deterministic under `FakeClock`.
|
||||
- `canTransition(id, to)` — non-throwing predicate for callers that want
|
||||
to branch on legality rather than catch.
|
||||
- Full audit history preserved across `forget(id)` — operations teams
|
||||
can post-mortem a session even after its registry entry is dropped.
|
||||
- OTel instruments: `game_server.session.lifecycle_total` counter (tags
|
||||
`from`/`to`/`outcome`) and `game_server.session.dwell_ms` histogram
|
||||
(tag `state`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionLifecycle } from "../session-lifecycle/index.js";
|
||||
|
||||
const fsm = new SessionLifecycle({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
fsm.register("table-1");
|
||||
fsm.transition("table-1", "active");
|
||||
// ...game plays out...
|
||||
fsm.transition("table-1", "ended", "game_complete");
|
||||
console.log(fsm.getState("table-1")); // "ended"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Defensive read before transitioning — useful from a UI controller.
|
||||
if (fsm.canTransition(id, "ended")) {
|
||||
fsm.transition(id, "ended", "host_cancelled");
|
||||
}
|
||||
|
||||
// Audit reporting — group transitions by session for a post-mortem.
|
||||
const history = fsm.getHistoryFor("table-1");
|
||||
for (const t of history) {
|
||||
console.log(`${t.at}: ${t.from} → ${t.to} ${t.reason ?? ""}`);
|
||||
}
|
||||
|
||||
// Re-using ids: forget the entry but keep the audit trail.
|
||||
fsm.forget("table-1");
|
||||
console.log(fsm.getState("table-1")); // null
|
||||
console.log(fsm.getHistoryFor("table-1").length); // > 0 (preserved)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionLifecycle(deps)` where `deps` is `{ clock, logger, events }`.
|
||||
|
||||
### Methods
|
||||
|
||||
- `register(id)` — initialise an entry in the `lobby` state. Throws
|
||||
`DUPLICATE_SESSION_ID` on collision.
|
||||
- `transition(id, to, reason?)` — validated mutation; emits
|
||||
`session:transitioned`; records OTel.
|
||||
- `getState(id)` / `getEntry(id)` — null for unknown ids.
|
||||
- `getAllEntries()` — defensive snapshot.
|
||||
- `canTransition(id, to)` — non-throwing legality predicate.
|
||||
- `getHistory()` / `getHistoryFor(id)` — defensive copies.
|
||||
- `forget(id)` — drop the entry; history is preserved.
|
||||
|
||||
### Errors
|
||||
|
||||
- `SESSION_NOT_FOUND` — `transition` on an unregistered id.
|
||||
- `SESSION_ALREADY_ENDED` — `transition` on an entry already in `ended`.
|
||||
- `INVALID_SESSION_TRANSITION` — `to` not in the allow-table for the
|
||||
current state.
|
||||
- `DUPLICATE_SESSION_ID` — `register` collision.
|
||||
|
||||
### Events
|
||||
|
||||
- `session:transitioned` — payload is the full `SessionTransition`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The allow-table is a `Readonly<Record<SessionState, ReadonlyArray<...>>>`
|
||||
constant — adding a new state requires updating both the
|
||||
`SessionState` union and the table, and every `transition` call walks the
|
||||
table at most twice (once for the rejection check, once for the
|
||||
recording). Forgetting to update the table would manifest as the FSM
|
||||
silently rejecting every transition out of the new state, which the test
|
||||
suite would catch.
|
||||
|
||||
The history is appended to a single shared array rather than
|
||||
per-session — this keeps the audit log monotonically ordered by wall
|
||||
clock, which matters when post-mortems span multiple sessions. The
|
||||
`getHistoryFor(id)` helper filters out the rest with a single linear
|
||||
walk; for production-scale histories, replace this in a follow-up with a
|
||||
per-id index. The current implementation is sized for the lifecycle
|
||||
flows of typical real-time games (single-digit transitions per session).
|
||||
|
||||
OTel emission uses two attributes on the counter (`from` and `to`) so a
|
||||
dashboard can aggregate by either side of the transition. The dwell
|
||||
histogram tags only the state being left — the duration is measured from
|
||||
when the session entered that state to when it left.
|
||||
@@ -0,0 +1,126 @@
|
||||
# SessionGraphNode
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionGraphNode` is the internal mutable graph node powering the
|
||||
hierarchical session tree at the heart of `@suaveplan/game-server`. Every
|
||||
node holds a stable id, a closed-vocabulary session type, a metadata bag,
|
||||
the mutable bidirectional parent/children links that form the tree, and
|
||||
the two timestamps that mark when the node was created and last touched.
|
||||
|
||||
The class is named `SessionGraphNode` deliberately — Wave A's public
|
||||
`SessionNode` interface (in `core/types`) is a read-only structural view of
|
||||
exactly this shape. exposes the read-only interface to consumers
|
||||
and uses the mutable class internally, so callers cannot accidentally
|
||||
mutate the tree by reaching into `node.children.push(...)` through the
|
||||
public manager surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's `SessionNode` interface structurally (the class is
|
||||
the canonical implementation; the interface is the read-only view).
|
||||
- Injectable `Clock` — no `Date.now()` calls; the entire tree is
|
||||
deterministic under `FakeClock`.
|
||||
- Bidirectional parent/children link maintenance via `addChild` /
|
||||
`removeChild` with idempotent attach/detach semantics.
|
||||
- Tree-walking helpers: `getDepth`, `getDescendants` (DFS pre-order),
|
||||
`getAncestors`, `getRoot`, `isAncestorOf`, `isDescendantOf`.
|
||||
- `toJSON()` returning a `SerializedSessionNode` shape for `SessionStore`
|
||||
persistence.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
|
||||
const clock = () => 1700000000000;
|
||||
const root = new SessionGraphNode(
|
||||
"venue1",
|
||||
"venue",
|
||||
{ createdAt: 0, updatedAt: 0 },
|
||||
clock
|
||||
);
|
||||
|
||||
const table = new SessionGraphNode(
|
||||
"table1",
|
||||
"table",
|
||||
{ createdAt: 0, updatedAt: 0 },
|
||||
clock,
|
||||
root
|
||||
);
|
||||
root.addChild(table);
|
||||
console.log(table.getDepth()); // 1
|
||||
console.log(table.getRoot().id); // "venue1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
|
||||
// Persistence round-trip: serialise, then restore with original timestamps.
|
||||
const node = new SessionGraphNode("s1", "game", meta, clock);
|
||||
const json = node.toJSON();
|
||||
const restored = new SessionGraphNode(
|
||||
json.id,
|
||||
json.type,
|
||||
json.metadata,
|
||||
clock,
|
||||
null,
|
||||
{ createdAt: json.createdAt, updatedAt: json.updatedAt }
|
||||
);
|
||||
|
||||
// Removing a child clears the bidirectional link.
|
||||
root.removeChild(table);
|
||||
console.log(table.parent); // null
|
||||
console.log(root.children.length); // 0
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionGraphNode(id, type, metadata, clock, parent?, timestamps?)`
|
||||
|
||||
`timestamps` is optional — passed only when restoring from persistence to
|
||||
preserve the original `createdAt` / `updatedAt` stamps.
|
||||
|
||||
### Methods
|
||||
|
||||
- `addChild(child)` — attach a child; idempotent (re-adding is a no-op).
|
||||
- `removeChild(child)` — returns `true` if removed, `false` if not a
|
||||
child.
|
||||
- `updateMetadata(patch)` — shallow-merge patch; refreshes `updatedAt`.
|
||||
- `getDepth()` — 0 for a root, otherwise the distance to the root.
|
||||
- `getDescendants()` — depth-first pre-order array, excluding `this`.
|
||||
- `getAncestors()` — parent-to-root array.
|
||||
- `getRoot()` — the topmost ancestor (or `this`).
|
||||
- `isAncestorOf(other)` / `isDescendantOf(other)` — strict ancestry tests.
|
||||
- `toJSON()` — serialised plain object suitable for storage adapters.
|
||||
|
||||
### Read-only properties
|
||||
|
||||
`id`, `type`, `createdAt`, `updatedAt`.
|
||||
|
||||
### Mutable properties
|
||||
|
||||
`parent` (managed by `addChild`/`removeChild`), `children`, `metadata`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bidirectional parent/children invariant is held by `addChild` and
|
||||
`removeChild`. Direct mutation of `node.children` or `node.parent` is
|
||||
discouraged — callers should always go through these methods so the
|
||||
counterpart link stays in sync. The class is internal to the engine
|
||||
package and is not part of the public re-export surface; consumers should
|
||||
import `SessionNode` from `core/types` instead.
|
||||
|
||||
The constructor accepts an optional `timestamps` parameter solely for the
|
||||
persistence round-trip path — without it, a freshly restored node would
|
||||
have its `createdAt` set to the moment of restoration, losing the original
|
||||
creation time. With it, `SessionStore.restore` produces nodes that are
|
||||
byte-equivalent to the ones that were originally persisted.
|
||||
@@ -0,0 +1,137 @@
|
||||
# SessionStore
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionStore` is the `ISessionManager` implementation that consumers wire
|
||||
into a `GameEngine`. It composes a `SessionTree` (the in-memory
|
||||
hierarchical graph) with an injectable `SessionPersistence` adapter so the
|
||||
session graph survives process restarts. Callers receive the read-only
|
||||
`SessionNode` shape from `core/types` — the mutable `SessionGraphNode` is
|
||||
internal to the engine package and cannot be mutated through the public
|
||||
manager surface.
|
||||
|
||||
The store accepts a Zod-validated options bundle (`storageKey` for
|
||||
namespacing the persistence blob) and an injectable persistence adapter.
|
||||
When no adapter is supplied, an in-memory default is used so callers can
|
||||
exercise the full lifecycle in tests and ephemeral environments without a
|
||||
real storage backend.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements Wave A's canonical `ISessionManager` contract (10 methods).
|
||||
- Zod-validated options boundary with stable
|
||||
`INVALID_SESSION_STORE_OPTIONS` failure code.
|
||||
- Pluggable `SessionPersistence` adapter (`get`/`set`/`delete`) so callers
|
||||
can swap in any byte-store.
|
||||
- Default in-memory persistence for ephemeral runs.
|
||||
- Restore reconstructs parent-before-child ordering automatically from
|
||||
any record permutation.
|
||||
- Forwards every tree mutation to the injected EventBus via the
|
||||
composed `SessionTree`.
|
||||
- Exposes `getStats`, `hasSession`, `clear`, and `clearPersistence` as
|
||||
ergonomic extras over the bare `ISessionManager` contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionStore } from "../session-store/index.js";
|
||||
|
||||
const store = new SessionStore({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
store.createSession(null, {
|
||||
id: "venue1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
store.createSession("venue1", {
|
||||
id: "table1",
|
||||
type: "table",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
|
||||
await store.persist();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Inject a SurrealDB-backed persistence adapter for durability.
|
||||
const persistence: SessionPersistence = {
|
||||
async get(key) {
|
||||
const row = await db.select(key);
|
||||
return row?.payload ?? null;
|
||||
},
|
||||
async set(key, payload) {
|
||||
await db.upsert(key, { payload });
|
||||
},
|
||||
async delete(key) {
|
||||
await db.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
const store = new SessionStore(
|
||||
{ clock, logger, events, persistence, storageKey: "shard-eu-1" },
|
||||
{}
|
||||
);
|
||||
|
||||
// On startup: restore prior state, then begin accepting traffic.
|
||||
await store.restore();
|
||||
console.log(store.getAllSessions().length); // restored count
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionStore(deps, options?)` where `deps` is
|
||||
`{ clock, logger, events, persistence?, storageKey? }`. Options accept
|
||||
`{ storageKey? }` and default to `game_server:sessions`.
|
||||
|
||||
### ISessionManager surface
|
||||
|
||||
`createSession`, `getSession`, `destroySession`, `getSessionsByType`,
|
||||
`getChildren`, `getParent`, `updateMetadata`, `getAllSessions`, `persist`,
|
||||
`restore` — all forwarding to the composed `SessionTree` and adapter.
|
||||
|
||||
### Extras
|
||||
|
||||
- `getStats()` — aggregate counts.
|
||||
- `hasSession(id)` — O(1) presence.
|
||||
- `clear()` — drop in-memory only.
|
||||
- `clearPersistence()` — drop the persisted blob.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_SESSION_STORE_OPTIONS` — Zod failure at construction.
|
||||
- All `SessionTree`-thrown codes (`INVALID_SESSION_DATA`,
|
||||
`DUPLICATE_SESSION_ID`, `PARENT_SESSION_NOT_FOUND`) pass through.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`restore` performs a fixed-point walk: it repeatedly scans the remaining
|
||||
records and attaches any whose parent is already present, halting when
|
||||
either every record is attached or no progress was made in a full pass.
|
||||
Orphan records (a `parentId` referencing a session not in the payload)
|
||||
are logged as warnings rather than thrown — losing the parent shouldn't
|
||||
prevent the rest of the tree from coming online. Real persistence
|
||||
adapters that guarantee referential integrity will never trigger that
|
||||
warning path.
|
||||
|
||||
The default `InMemoryPersistence` is intentionally namespaced inside the
|
||||
store instance — a fresh `SessionStore` always gets a fresh map. This
|
||||
keeps test isolation trivial: callers don't need to manually clear
|
||||
persistence between tests. For production usage, the adapter is always
|
||||
supplied.
|
||||
|
||||
`persist` snapshots the current tree by walking `getAllSessions()` and
|
||||
calling `toJSON()` on each node. The order of records in the payload is
|
||||
preserved insertion-order from the internal `Map`, but `restore` does not
|
||||
rely on that order — the fixed-point walk handles arbitrary permutations.
|
||||
@@ -0,0 +1,132 @@
|
||||
# SessionTree
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionTree` is the hierarchical session graph backing
|
||||
`@suaveplan/game-server`. It owns the `Map<id, SessionGraphNode>` index plus
|
||||
the `Set<rootId>` that tracks parent-less sessions, and it exposes the
|
||||
mutation surface used by `SessionStore` to satisfy the public
|
||||
`ISessionManager` contract. The tree supports unlimited nesting
|
||||
(`venue → table → queue → game → player`) with O(1) lookups by id.
|
||||
|
||||
Every wall-clock observation routes through the injected `Clock`, every
|
||||
mutation emits a typed event onto the injected `EventBus`, and every input
|
||||
boundary that accepts session data is validated through Zod with a
|
||||
`GameServerError` thrown on rejection. The class is internal to the engine
|
||||
package — consumers reach it through `SessionStore`, never directly.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `getSession` lookup via a single `Map`.
|
||||
- Bidirectional parent/children link maintenance via `SessionGraphNode`.
|
||||
- Zod-validated `SessionData` boundary with stable `INVALID_SESSION_DATA`
|
||||
failure code.
|
||||
- Duplicate-id and dangling-parent guards (`DUPLICATE_SESSION_ID`,
|
||||
`PARENT_SESSION_NOT_FOUND`).
|
||||
- Bottom-up `destroySession` cascading through every descendant.
|
||||
- `attachRestored` re-attach path for `SessionStore.restore` that does NOT
|
||||
emit `session:created` (restoration is not creation).
|
||||
- `validateIntegrity` audit for debug logging.
|
||||
- Aggregate `getStats` reporting total session count, root count, max
|
||||
depth, and per-type distribution.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionTree } from "../session-tree/index.js";
|
||||
|
||||
const tree = new SessionTree({
|
||||
clock: () => Date.now(),
|
||||
logger,
|
||||
events,
|
||||
});
|
||||
|
||||
const venue = tree.createSession(null, {
|
||||
id: "venue1",
|
||||
type: "venue",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
tree.createSession("venue1", {
|
||||
id: "table1",
|
||||
type: "table",
|
||||
metadata: { createdAt: 0, updatedAt: 0 },
|
||||
});
|
||||
|
||||
console.log(tree.getChildren("venue1").map((c) => c.id)); // ["table1"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
// Destroying a parent cascades through all descendants bottom-up.
|
||||
tree.destroySession("venue1");
|
||||
console.log(tree.getAllSessions()); // []
|
||||
|
||||
// Persistence round-trip — restore via attachRestored without firing
|
||||
// the "session:created" event (because restoration is not creation).
|
||||
import { SessionGraphNode } from "../session-node/index.js";
|
||||
const restored = new SessionGraphNode("a", "venue", meta, clock, null, {
|
||||
createdAt: 100,
|
||||
updatedAt: 200,
|
||||
});
|
||||
tree.attachRestored(restored, null);
|
||||
console.log(tree.hasSession("a")); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new SessionTree(deps)` where `deps` is `{ clock, logger, events }`.
|
||||
|
||||
### Methods
|
||||
|
||||
- `createSession(parentId, data)` — validates `data`, throws on duplicate
|
||||
id or missing parent; emits `session:created`.
|
||||
- `getSession(id)` / `hasSession(id)` — O(1) presence checks.
|
||||
- `updateMetadata(id, patch)` — shallow merge; emits
|
||||
`session:metadata:changed` and `session:updated`.
|
||||
- `destroySession(id)` — bottom-up cascade; emits `session:destroyed`
|
||||
once per detached node.
|
||||
- `getSessionsByType(type)` — filter by closed `SessionType` vocabulary.
|
||||
- `getChildren` / `getParent` / `getDescendants` / `getAncestors`.
|
||||
- `getAllSessions()` / `getRoots()` / `getSessionCount()`.
|
||||
- `getStats()` — aggregate counts + max depth + per-type distribution.
|
||||
- `clear()` — drop everything; used by `SessionStore.restore`.
|
||||
- `attachRestored(node, parentId)` — re-attach during restore; throws on
|
||||
duplicate id or missing parent.
|
||||
- `validateIntegrity()` — `{valid, errors}` audit.
|
||||
|
||||
### Errors
|
||||
|
||||
- `INVALID_SESSION_DATA` — Zod validation failure on `createSession`.
|
||||
- `DUPLICATE_SESSION_ID` — id collision on `createSession` or
|
||||
`attachRestored`.
|
||||
- `PARENT_SESSION_NOT_FOUND` — `parentId` references an unknown session.
|
||||
|
||||
### Events
|
||||
|
||||
- `session:created` / `session:destroyed` / `session:updated` /
|
||||
`session:metadata:changed`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`destroySession` walks descendants depth-first and then reverses the array
|
||||
so deepest sessions detach first — this matters when the EventBus has
|
||||
subscribers that expect leaf-before-parent ordering. The reverse-iteration
|
||||
of `getDescendants()` is deliberate: `getDescendants()` returns pre-order,
|
||||
which makes the children-before-parent walk a single `reverse()` call
|
||||
rather than a separate post-order traversal.
|
||||
|
||||
`attachRestored` is a deliberate seam in the tree's surface — it accepts a
|
||||
pre-built `SessionGraphNode` (with its preserved `createdAt`/`updatedAt`
|
||||
timestamps) rather than `SessionData`. This is the only path that bypasses
|
||||
the Zod validation, because validation already happened at the original
|
||||
`createSession` call before persistence. Restoration uses the same
|
||||
duplicate-id and missing-parent guards as creation, but emits no events —
|
||||
listeners should subscribe to the `SessionStore.restore()` completion
|
||||
signal if they need a notification.
|
||||
@@ -0,0 +1,71 @@
|
||||
# anti-cheat-validator.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal pure helpers backing `AntiCheatValidator`. Holds default
|
||||
configuration constants (movement, rate limits, punishment thresholds),
|
||||
the Euclidean `distance` calculator, the `recommend` punishment-escalation
|
||||
ladder, and the `emptyHistory` factory.
|
||||
|
||||
Splitting these out keeps `anti-cheat-validator.ts` below the 300-line code
|
||||
file limit while ensuring the helpers remain side-effect free, individually
|
||||
testable, and unit-coverable.
|
||||
|
||||
## Features
|
||||
|
||||
- Default `MovementConfig` (`maxSpeed`, `maxTeleportDistance`,
|
||||
`toleranceFactor`).
|
||||
- Default `PunishmentThresholds` (escalation ladder for warnings → minor →
|
||||
major → kick → temp/permanent ban).
|
||||
- Default `RateLimitConfig` array (60 actions / 1 s / all).
|
||||
- `distance(a, b)` Euclidean 3D distance (z defaults to 0).
|
||||
- `recommend(h, p)` punishment ladder driven by per-severity counts and
|
||||
current ban status.
|
||||
- `emptyHistory(playerId)` factory for new tracked players.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_MOVEMENT,
|
||||
distance,
|
||||
} from "./anti-cheat-validator.internal.js";
|
||||
|
||||
distance({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
|
||||
DEFAULT_MOVEMENT.maxSpeed; // 10
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
emptyHistory,
|
||||
recommend,
|
||||
DEFAULT_PUNISHMENTS,
|
||||
} from "./anti-cheat-validator.internal.js";
|
||||
|
||||
const h = emptyHistory("p1");
|
||||
h.violationsBySeverity.major = 3;
|
||||
recommend(h, DEFAULT_PUNISHMENTS); // "temp_ban"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_MOVEMENT: MovementConfig`
|
||||
- `DEFAULT_PUNISHMENTS: PunishmentThresholds`
|
||||
- `DEFAULT_RATE_LIMITS: RateLimitConfig[]`
|
||||
- `distance(a: Position, b: Position): number`
|
||||
- `recommend(h: PlayerViolationHistory, p: PunishmentThresholds):
|
||||
PunishmentRecommendation`
|
||||
- `emptyHistory(playerId: string): PlayerViolationHistory`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`distance` treats missing `z` as 0 so 2D positions interoperate with 3D
|
||||
checks. `recommend` short-circuits on the strictest applicable threshold
|
||||
(critical violation or active temp ban → permanent), then walks the ladder
|
||||
top-down. `emptyHistory` reserves an empty object for the per-type and
|
||||
per-severity tally maps so callers can use the safe `??` increment pattern
|
||||
without hitting `undefined` keys. Defaults match the migrated upstream
|
||||
configuration so the public observable behaviour of `AntiCheatValidator`
|
||||
is unchanged from the universal source.
|
||||
@@ -0,0 +1,131 @@
|
||||
# anti-cheat-validator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AntiCheatValidator` performs server-authoritative validation for player
|
||||
movement and action streams in real-time multiplayer games. It tracks per-
|
||||
player position history, applies movement-speed and teleport thresholds,
|
||||
enforces rolling-window rate limits, runs caller-registered action
|
||||
validators, accumulates violation histories, and escalates punishments
|
||||
through a configurable warn → minor → major → kick → temp-ban → permanent-
|
||||
ban ladder.
|
||||
|
||||
The validator is dependency-injectable: a `Clock` provides wall-clock
|
||||
time, an `IdGenerator` mints violation ids, and an OpenTelemetry `Meter`
|
||||
provides metrics emission. Configuration is validated with Zod at the
|
||||
constructor boundary; invalid input raises
|
||||
`GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`.
|
||||
|
||||
## Features
|
||||
|
||||
- Movement validation: teleport (distance), horizontal speed-hack,
|
||||
optional vertical speed-hack, timing-anomaly detection.
|
||||
- Rolling-window rate limiting per action type plus a global `"all"`
|
||||
bucket.
|
||||
- Pluggable action validators with full validation context
|
||||
(`getPlayerPosition`, `getPlayerHistory`, `getCurrentTick`).
|
||||
- Auto-flagging at a configurable violations-per-minute threshold.
|
||||
- Automatic punishment ladder driven by per-severity tallies and current
|
||||
ban status.
|
||||
- Per-player history, statistics, OTel counters
|
||||
(`game_server.anticheat.validation_total`,
|
||||
`game_server.anticheat.violation_total`).
|
||||
- Typed listener registry (`on`/`off`) for `violation:detected`,
|
||||
`player:flagged`/`:unflagged`/`:kicked`/`:temp_banned`/`:permanent_banned`,
|
||||
`rate_limit:exceeded`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AntiCheatValidator } from "@suaveplan/game-server/enhanced/anti-cheat/anti-cheat-validator";
|
||||
|
||||
const validator = new AntiCheatValidator();
|
||||
const r = validator.validateMovement({
|
||||
playerId: "p1",
|
||||
position: { x: 0, y: 0 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (!r.valid) console.warn(r.violation);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AntiCheatValidator } from "@suaveplan/game-server/enhanced/anti-cheat/anti-cheat-validator";
|
||||
|
||||
let now = 0;
|
||||
const validator = new AntiCheatValidator<
|
||||
{ x: number; y: number },
|
||||
{ damage: number }
|
||||
>(
|
||||
{
|
||||
rateLimits: [{ maxActions: 30, windowMs: 1_000, actionType: "shoot" }],
|
||||
movement: {
|
||||
maxSpeed: 12,
|
||||
maxTeleportDistance: 60,
|
||||
allowVertical: true,
|
||||
maxVerticalSpeed: 18,
|
||||
toleranceFactor: 1.25,
|
||||
},
|
||||
autoFlagThreshold: 6,
|
||||
enableAutoPunishment: true,
|
||||
},
|
||||
{
|
||||
clock: () => now,
|
||||
idGenerator: () => `v-${++now}`,
|
||||
}
|
||||
);
|
||||
|
||||
validator.registerValidator("shoot", async (action) => {
|
||||
return action.payload.damage <= 100
|
||||
? { valid: true, shouldWarn: false, recommendation: "none" }
|
||||
: {
|
||||
valid: false,
|
||||
shouldWarn: false,
|
||||
recommendation: "warn",
|
||||
reason: "damage out of range",
|
||||
};
|
||||
});
|
||||
|
||||
const unsub = validator.on("violation:detected", (v) => audit.write(v));
|
||||
// ... later
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new AntiCheatValidator(config?, deps?)` — validates `config` with
|
||||
`AntiCheatConfigSchema`; throws `GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`
|
||||
on failure. `deps`: `{ clock?, idGenerator?, meter? }`.
|
||||
- `validateMovement(data)`: `ValidationResult` — first call seeds the
|
||||
player's reference position; subsequent calls compare against it.
|
||||
- `validateAction(action)`: `Promise<ValidationResult>` — rate-checks then
|
||||
invokes any registered action validator.
|
||||
- `checkRateLimit(playerId, actionType, timestamp)`: `ValidationResult`.
|
||||
- `registerValidator(actionType, validator)` — throws
|
||||
`GAME_SERVER.INVALID_VALIDATION_RULE` on empty `actionType`.
|
||||
- `getPlayerHistory(playerId)`, `flagPlayer`, `unflagPlayer`, `banPlayer`,
|
||||
`isPlayerBanned`, `setTick`, `getStats`, `resetStats`, `clearPlayer`,
|
||||
`dispose`.
|
||||
- `on(event, listener)` returns an unsubscribe function; `off(event,
|
||||
listener)` removes a specific listener.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Heavy data-shape work (`processFlag`, `evaluateRateLimit`,
|
||||
`classifyMovement`, `recommend`) lives in the sibling
|
||||
`anti-cheat-validator.internal.ts` so the main class stays focused on
|
||||
lifecycle, dependency injection, and event dispatch. The Euclidean
|
||||
distance helper treats missing `z` as 0 so 2D and 3D positions
|
||||
interoperate.
|
||||
|
||||
Auto-punishment dispatches into `flagPlayer`/`banPlayer` so the listener
|
||||
registry sees the corresponding `player:*` events even when escalation is
|
||||
driven internally. Temp bans expire lazily on the next `isPlayerBanned`
|
||||
call rather than via a timer to avoid scheduling drift. Statistics include
|
||||
a live `uptimeMs` computed from the injected `Clock` so deterministic
|
||||
tests can advance time without observing real wall-clock skew.
|
||||
@@ -0,0 +1,72 @@
|
||||
# interest-manager.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure geometry helpers backing `InterestManager`: Euclidean
|
||||
`vectorDistance` (z defaults to 0 for 2D interop), `zoneContains`
|
||||
discriminating on `ZoneShape` (`circular` uses radius distance,
|
||||
`rectangular` uses half-extent checks honouring optional `depth`, `custom`
|
||||
delegates to the zone-supplied `containsPoint` predicate), and
|
||||
`calcPriority` clamping a `maxPriority - distance × decay + zoneBonus`
|
||||
score into `[0, maxPriority]`.
|
||||
|
||||
Extracting these keeps `interest-manager.ts` under the per-file line
|
||||
budget and isolates the geometric primitives so they can be unit-tested
|
||||
without instantiating the manager.
|
||||
|
||||
## Features
|
||||
|
||||
- `vectorDistance(a, b)` — 3D Euclidean distance with 2D fallback.
|
||||
- `zoneContains(zone, point)` — discriminated containment test across
|
||||
circular, rectangular (optionally 3D), and custom zones.
|
||||
- `calcPriority(d, n, max, decay)` — clamped priority score with a small
|
||||
bonus per shared subscribed zone.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
vectorDistance,
|
||||
zoneContains,
|
||||
} from "./interest-manager.internal.js";
|
||||
|
||||
vectorDistance({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
|
||||
zoneContains(
|
||||
{
|
||||
id: "z",
|
||||
shape: "circular",
|
||||
center: { x: 0, y: 0 },
|
||||
radius: 5,
|
||||
priority: 1,
|
||||
},
|
||||
{ x: 4, y: 0 }
|
||||
); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { calcPriority } from "./interest-manager.internal.js";
|
||||
|
||||
// At max distance the bonus still keeps shared-zone entities visible.
|
||||
calcPriority(80, 3, 10, 0.1); // 3.5
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `vectorDistance(a: Vector3, b: Vector3): number`
|
||||
- `zoneContains(zone: InterestZone, point: Vector3): boolean`
|
||||
- `calcPriority(distance: number, sharedZoneCount: number, maxPriority:
|
||||
number, decayRate: number): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`zoneContains` treats a missing `depth` on rectangular zones as 2D
|
||||
containment (no z-check), which matches the migrated upstream behaviour
|
||||
and lets callers seamlessly mix 2D and 3D entities. The custom-zone path
|
||||
intentionally invokes the user predicate without exception trapping —
|
||||
callers are responsible for ensuring `containsPoint` is total, and any
|
||||
thrown error propagates up through `InterestManager.isInZone`. The
|
||||
priority formula gives shared-zone entities a half-unit lift per
|
||||
subscribed zone so that interesting entities don't fall out of the top-N
|
||||
slice at the edge of `viewDistance`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# interest-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InterestManager` implements area-of-interest (AOI) culling for open-world
|
||||
multiplayer games: it tracks zones, entities, and players in memory and
|
||||
returns the bounded slice of entities each player can see, ranked by
|
||||
priority. The class is the standard mechanism for shrinking broadcast
|
||||
scope from O(world) to O(perceived) updates.
|
||||
|
||||
It is dependency-injectable: a `Clock` source provides timestamps and an
|
||||
OpenTelemetry `Meter` produces visibility-calculation counters and
|
||||
duration histograms. Configuration is validated with Zod; invalid input
|
||||
raises `GAME_SERVER.INVALID_INTEREST_CONFIG`. Unknown players passed to
|
||||
`updatePlayerPosition` raise
|
||||
`GAME_SERVER.INTEREST_PLAYER_NOT_REGISTERED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Three zone shapes: circular, rectangular (optionally 3D), custom
|
||||
(predicate-based).
|
||||
- Per-player `viewDistance` override (defaults to
|
||||
`config.defaultViewDistance`).
|
||||
- Priority-weighted visibility ranking via `maxPriority - distance ×
|
||||
decay + sharedZones × 0.5` with output clamped into `[0, maxPriority]`.
|
||||
- `getVisibleEntities` slices to `maxEntitiesPerPlayer`.
|
||||
- `calculateVisibilityChanges` emits enter/exit deltas only when the set
|
||||
actually changes (returns `null` on no-op).
|
||||
- Typed event registry: `entity:added/removed`,
|
||||
`player:subscribed/unsubscribed`, `visibility:changed`.
|
||||
- OTel: `game_server.interest.visibility_calc_total` (counter),
|
||||
`game_server.interest.visibility_calc_duration_ms` (histogram).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
|
||||
|
||||
const aoi = new InterestManager({ defaultViewDistance: 50 });
|
||||
aoi.registerPlayer("p1", { x: 0, y: 0 });
|
||||
aoi.updateEntity("npc", { x: 10, y: 0 }, { kind: "merchant" });
|
||||
const visible = aoi.getVisibleEntities("p1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
|
||||
|
||||
const aoi = new InterestManager<{ name: string }>(
|
||||
{
|
||||
defaultViewDistance: 80,
|
||||
maxEntitiesPerPlayer: 100,
|
||||
priorityDecayRate: 0.05,
|
||||
maxPriority: 20,
|
||||
},
|
||||
{ clock: () => testNow }
|
||||
);
|
||||
|
||||
aoi.registerZone({
|
||||
id: "downtown",
|
||||
shape: "rectangular",
|
||||
center: { x: 0, y: 0, z: 0 },
|
||||
width: 200,
|
||||
height: 200,
|
||||
depth: 30,
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
aoi.registerPlayer("p1", { x: 1, y: 1, z: 0 });
|
||||
aoi.subscribeToZone("p1", "downtown");
|
||||
|
||||
const unsub = aoi.on("visibility:changed", (change) => {
|
||||
syncEnterExitToClient(change.playerId, change.entered, change.exited);
|
||||
});
|
||||
|
||||
// In the server tick:
|
||||
aoi.updateEntity("merchant", { x: 5, y: 5, z: 0 }, { name: "Sera" });
|
||||
aoi.calculateVisibilityChanges("p1");
|
||||
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new InterestManager(config, deps?)` — validates `config` with
|
||||
`InterestManagerConfigSchema`; throws
|
||||
`GAME_SERVER.INVALID_INTEREST_CONFIG` on failure.
|
||||
- `registerZone(zone)` / `unregisterZone(zoneId)`; throws
|
||||
`GAME_SERVER.INTEREST_REGION_INVALID` on empty `zone.id`.
|
||||
- `updateEntity(id, position, data?, type?)` / `removeEntity(id)`.
|
||||
- `registerPlayer(id, position, viewDistance?)` /
|
||||
`updatePlayerPosition(id, position)` / `unregisterPlayer(id)`.
|
||||
- `subscribeToZone(playerId, zoneId)` /
|
||||
`unsubscribeFromZone(playerId, zoneId)`.
|
||||
- `getVisibleEntities(playerId)` / `calculateVisibilityChanges(playerId)`.
|
||||
- `isInZone(zoneId, point)` / `getZonesAtPoint(point)` / `getZone` /
|
||||
`getAllZones` / `getEntity` / `getStats` / `dispose`.
|
||||
- `on(event, listener) → unsubscribe`; `off(event, listener)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Geometry primitives (`vectorDistance`, `zoneContains`, `calcPriority`)
|
||||
live in `interest-manager.internal.ts` so the main class is dominated by
|
||||
lifecycle, dispatch, and metrics emission. `getVisibleEntities` is the
|
||||
hot path; it sorts the candidate set in descending priority and slices to
|
||||
`maxEntitiesPerPlayer` so the result is always bounded. The shared-zones
|
||||
walk only checks zones the player explicitly subscribed to, so an entity
|
||||
in an unsubscribed zone is treated as if the zone were absent. Visibility
|
||||
deltas are derived against the previous result rather than a snapshot of
|
||||
the world to support incremental synchronisation. `dispose` clears every
|
||||
internal collection so the manager can be safely garbage-collected after
|
||||
shutdown.
|
||||
@@ -0,0 +1,70 @@
|
||||
# lag-compensator.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helpers backing `LagCompensator`: `RTT_SAMPLE_SIZE`, the
|
||||
`initialStats` factory, RTT-sample-window management (`pushRttSample`,
|
||||
`calcLatency`), and snapshot neighbour-lookup logic (`findNeighbors`,
|
||||
`pickClosest`).
|
||||
|
||||
Splitting these out keeps the main class focused on lifecycle, dependency
|
||||
injection, OTel emission, and event dispatch, and lets the neighbour
|
||||
search be tested independently against contrived snapshot sequences.
|
||||
|
||||
## Features
|
||||
|
||||
- `RTT_SAMPLE_SIZE` constant (20).
|
||||
- `initialStats()` factory.
|
||||
- `pushRttSample(samples, rtt, cap)` — appends and trims oldest.
|
||||
- `calcLatency(playerId, rtt, samples, now)` — average, min, max, jitter.
|
||||
- `findNeighbors(history, rewindTimestamp)` — earliest snapshot strictly
|
||||
after, latest snapshot at-or-before.
|
||||
- `pickClosest(before, after, rewind)` — closer-of-two by absolute time
|
||||
delta, ties prefer `before`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
initialStats,
|
||||
pushRttSample,
|
||||
} from "./lag-compensator.internal.js";
|
||||
|
||||
const samples = pushRttSample([], 80, 20);
|
||||
initialStats().totalLookups; // 0
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { findNeighbors, pickClosest } from "./lag-compensator.internal.js";
|
||||
|
||||
const history = [
|
||||
{ tick: 1, timestamp: 100, state: { x: 0 }, isServerState: true },
|
||||
{ tick: 2, timestamp: 200, state: { x: 5 }, isServerState: true },
|
||||
];
|
||||
const { before, after } = findNeighbors(history, 150);
|
||||
const closest = before && after ? pickClosest(before, after, 150) : before;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RTT_SAMPLE_SIZE: 20`
|
||||
- `initialStats(): LagCompensatorStats`
|
||||
- `pushRttSample(samples: number[], rtt: number, cap: number): number[]`
|
||||
- `calcLatency(playerId: string, rtt: number, samples: number[], now:
|
||||
number): PlayerLatency`
|
||||
- `findNeighbors<TState>(history, rewindTimestamp): NeighborResult<TState>`
|
||||
- `pickClosest<TState>(before, after, rewind): StateSnapshot<TState>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`pushRttSample` returns a new array each call to keep ownership semantics
|
||||
clear and allow callers to detect a size change. `calcLatency` always
|
||||
divides by the actual sample count so the first update produces sensible
|
||||
stats; `Math.min(...samples)`/`Math.max(...samples)` are safe at 20-sample
|
||||
max. `findNeighbors` walks the history once and stops as soon as the
|
||||
strictly-greater snapshot is found, so it is O(N) in worst-case but
|
||||
typically terminates within a handful of iterations for a 128-frame
|
||||
buffer. The tie-break in `pickClosest` picks the earlier snapshot to
|
||||
match canonical rollback-network semantics.
|
||||
@@ -0,0 +1,123 @@
|
||||
# lag-compensator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LagCompensator` performs server-side state rewinding for accurate hit
|
||||
registration in networked multiplayer games. It maintains a bounded
|
||||
per-entity ring buffer of `StateSnapshot` records, tracks per-player RTT
|
||||
statistics (average, min, max, jitter), and exposes a `validateHit`
|
||||
method that combines RTT compensation with snapshot interpolation to
|
||||
adjudicate hit candidates against the world state the shooter actually
|
||||
perceived at the moment of input.
|
||||
|
||||
The compensator is dependency-injectable: a `Clock` supplies timestamps
|
||||
for `recordState` defaults and latency timestamps; an OpenTelemetry
|
||||
`Meter` produces a lookup counter and a compensation-amount histogram.
|
||||
Configuration is validated with Zod at the constructor boundary; invalid
|
||||
input raises `GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG`.
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded per-entity history with `history:pruned` events on overflow.
|
||||
- Per-player RTT tracking with a 20-sample rolling window
|
||||
(`RTT_SAMPLE_SIZE`).
|
||||
- `getStateAtTime` supports exact-match, before-only, after-only,
|
||||
closest-neighbour, and lerp-interpolated lookups.
|
||||
- `validateHit` applies optional RTT compensation
|
||||
(`enableRttCompensation`), perceives the shooter and target at the
|
||||
same rewind point, runs a caller-supplied predicate, and emits
|
||||
`hit:validated` with both compensated states.
|
||||
- Compensation clamping at `maxCompensationMs` flagged on the result via
|
||||
`clamped: true`.
|
||||
- OTel: `game_server.lagcomp.lookup_total` (counter),
|
||||
`game_server.lagcomp.compensation_ms` (histogram).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LagCompensator } from "@suaveplan/game-server/enhanced/lag-compensation/lag-compensator";
|
||||
|
||||
const lc = new LagCompensator<{ x: number; y: number }>();
|
||||
lc.recordState("p1", { x: 0, y: 0 }, 1);
|
||||
lc.recordState("p2", { x: 5, y: 0 }, 1);
|
||||
const result = lc.validateHit({
|
||||
shooterId: "p1",
|
||||
targetId: "p2",
|
||||
actionTimestamp: Date.now(),
|
||||
serverTimestamp: Date.now(),
|
||||
validator: (s, t) => Math.hypot(s.x - t.x, s.y - t.y) <= 6,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LagCompensator } from "@suaveplan/game-server/enhanced/lag-compensation/lag-compensator";
|
||||
|
||||
let now = 0;
|
||||
const lc = new LagCompensator<{ x: number; y: number }>(
|
||||
{
|
||||
maxHistorySize: 256,
|
||||
maxCompensationMs: 200,
|
||||
enableRttCompensation: true,
|
||||
interpolationFactor: 1,
|
||||
},
|
||||
{ clock: () => now }
|
||||
);
|
||||
|
||||
lc.updateRtt("shooter", 80);
|
||||
|
||||
for (let t = 0; t < 10; t++) {
|
||||
now = t * 16;
|
||||
lc.recordState("shooter", { x: t, y: 0 }, t);
|
||||
lc.recordState("target", { x: t * 2, y: 0 }, t);
|
||||
}
|
||||
|
||||
const unsub = lc.on("hit:validated", (r) => audit.push(r));
|
||||
|
||||
const result = lc.validateHit(
|
||||
{
|
||||
shooterId: "shooter",
|
||||
targetId: "target",
|
||||
actionTimestamp: now - 50,
|
||||
serverTimestamp: now,
|
||||
validator: (s, t) => Math.hypot(s.x - t.x, s.y - t.y) <= 10,
|
||||
},
|
||||
(a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t })
|
||||
);
|
||||
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new LagCompensator(config?, deps?)` — validates `config` with
|
||||
`LagCompensatorConfigSchema`; throws
|
||||
`GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG` on failure.
|
||||
- `recordState(entityId, state, tick, timestamp = clock())`.
|
||||
- `updateRtt(playerId, rtt)` / `getPlayerLatency(playerId)`.
|
||||
- `getStateAtTime(entityId, targetTimestamp, currentTimestamp = clock(),
|
||||
lerp?)`.
|
||||
- `validateHit(request, lerp?)`.
|
||||
- `getTrackedEntities()`, `clearEntityHistory(entityId)`,
|
||||
`removePlayer(playerId)`, `getStats()`, `resetStats()`, `dispose()`.
|
||||
- `on(event, listener) → unsubscribe`; `off(event, listener)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interpolation factor is applied symmetrically as
|
||||
`f × raw + (1 − f) × raw`, identical to the upstream behaviour even
|
||||
though it collapses algebraically — kept verbatim so engines tuning
|
||||
`interpolationFactor` for visual smoothing match validation results.
|
||||
Tied neighbour timestamps prefer the earlier snapshot in `pickClosest`
|
||||
to match canonical rollback-network semantics. `validateHit` halves the
|
||||
shooter's RTT for compensation under the standard one-way-latency
|
||||
assumption; disabling `enableRttCompensation` lets callers run their own
|
||||
network model. The lookup counter tags `{clamped: "true" | "false"}` so
|
||||
observability dashboards can separate compensated-but-bounded queries
|
||||
from edge cases that hit the cap. `dispose` clears every collection and
|
||||
resets statistics so the compensator can be safely garbage-collected.
|
||||
@@ -0,0 +1,113 @@
|
||||
# LeaderboardManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LeaderboardManager` provides multi-leaderboard score tracking for game
|
||||
servers with three independent ranking modes — raw score, win-count, and
|
||||
Elo. Each leaderboard is configured separately, sorted independently, and
|
||||
capped at a configurable max-entry size that evicts the lowest-ranked
|
||||
entries to keep memory bounded under unbounded submission volume.
|
||||
|
||||
The manager injects its `Clock` so deterministic tests and seeded replays
|
||||
can advance time without resorting to fake timers, and emits OTel counters
|
||||
(`game_server.leaderboard.submit_total`, `game_server.leaderboard.eviction_total`)
|
||||
so production deployments can monitor submission throughput and eviction
|
||||
pressure without bespoke instrumentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Three ranking modes: `score`, `wins`, `elo`.
|
||||
- Ascending (golf) or descending (default) sort.
|
||||
- Tie-break by secondary score.
|
||||
- Per-board `maxEntries` cap with rank-based eviction.
|
||||
- Elo update with configurable K-factor.
|
||||
- Injectable `Clock` for deterministic time.
|
||||
- Stable `GAME_SERVER.*` error codes (`LEADERBOARD_NOT_FOUND`,
|
||||
`INVALID_LEADERBOARD_CONFIG`, `INVALID_SCORE_SUBMISSION`,
|
||||
`LEADERBOARD_ENTRY_INVALID`).
|
||||
- Zod-validated configs and submissions.
|
||||
- OTel `Counter` instrumentation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LeaderboardManager } from "@suaveplan/game-server/enhanced/leaderboard/leaderboard-manager";
|
||||
|
||||
const lm = new LeaderboardManager();
|
||||
lm.createLeaderboard({ id: "global", rankingMode: "score" });
|
||||
lm.submitScore("global", {
|
||||
playerId: "alice",
|
||||
displayName: "Alice",
|
||||
score: 4200,
|
||||
});
|
||||
const top = lm.getTopN("global", 10);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LeaderboardManager } from "@suaveplan/game-server/enhanced/leaderboard/leaderboard-manager";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const lm = new LeaderboardManager<{ region: string }>(
|
||||
{ defaultMaxEntries: 500, defaultEloKFactor: 24, defaultInitialElo: 1500 },
|
||||
{ clock: () => now }
|
||||
);
|
||||
|
||||
lm.createLeaderboard({
|
||||
id: "ranked-1v1",
|
||||
rankingMode: "elo",
|
||||
maxEntries: 1000,
|
||||
eloKFactor: 32,
|
||||
});
|
||||
|
||||
lm.submitScore("ranked-1v1", {
|
||||
playerId: "alice",
|
||||
displayName: "Alice",
|
||||
score: 0,
|
||||
isWin: true,
|
||||
opponentElo: 1450,
|
||||
metadata: { region: "eu-west" },
|
||||
});
|
||||
|
||||
now += 60_000;
|
||||
const entry = lm.getEntry("ranked-1v1", "alice");
|
||||
const stats = lm.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new LeaderboardManager(config?, deps?)` — `deps.clock?: Clock`, `deps.meter?: Meter`.
|
||||
- `createLeaderboard(config: LeaderboardConfig): LeaderboardConfig`
|
||||
- `deleteLeaderboard(id: string): boolean`
|
||||
- `submitScore(id, submission): LeaderboardEntry`
|
||||
- `getTopN(id, n): readonly LeaderboardEntry[]`
|
||||
- `getEntry(id, playerId): LeaderboardEntry | undefined`
|
||||
- `removeEntry(id, playerId): boolean`
|
||||
- `resetLeaderboard(id): boolean`
|
||||
- `listLeaderboards(): readonly LeaderboardConfig[]`
|
||||
- `getStats(): LeaderboardStats`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Ranks are recomputed in full on every submission. For the configured
|
||||
ceiling of 1000 entries this is `O(N log N)` per write and stays well
|
||||
under a millisecond on commodity hardware; if profiles show this becoming
|
||||
a bottleneck, the sort can be swapped for an order-statistic tree without
|
||||
changing the public surface.
|
||||
|
||||
The Elo update follows the standard logistic formulation with a 400-point
|
||||
spread and rounding to the nearest integer rating. K-factor is per-board
|
||||
so high-volatility competitive ladders can run alongside calibration
|
||||
boards with low K. Submissions without an `opponentElo` are treated as
|
||||
unranked sessions and do not alter the Elo rating, which avoids polluting
|
||||
ladders with practice games or AI matches.
|
||||
|
||||
Eviction operates on rank rather than insertion order so the cap reflects
|
||||
the leaderboard's intent ("top 1000 players") rather than churn. The
|
||||
`eviction_total` counter is tagged by `leaderboardId` so per-board churn
|
||||
is observable in production dashboards without code changes.
|
||||
@@ -0,0 +1,100 @@
|
||||
# GamePersistence
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GamePersistence` persists game state to a pluggable key-value backend via
|
||||
the `StorageLike` structural contract from `@suaveplan/types`. State, named
|
||||
snapshots, and a bounded audit-trail history are serialised together as a
|
||||
UTF-8 JSON document under a deterministic key (`game-server:save:<gameId>:<id>`),
|
||||
allowing any conformant `AbstractStorage` adapter — in-memory, SurrealDB,
|
||||
SQLite, S3 — to back the engine without code changes.
|
||||
|
||||
The manager exposes async `save`, `load`, `delete`, `createSnapshot`, and
|
||||
`restoreSnapshot` operations that throw stable `GAME_SERVER.*` errors,
|
||||
plus an OTel save-duration histogram and counter pair so production
|
||||
throughput is observable without bespoke instrumentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Pluggable `StorageLike` backend (in-memory, durable, network).
|
||||
- Named snapshots with rotating `maxSnapshots` cap.
|
||||
- Bounded audit-trail history with `maxHistoryEntries` cap.
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic tests.
|
||||
- Stable error codes: `PERSISTENCE_SAVE_FAILED`, `PERSISTENCE_LOAD_FAILED`,
|
||||
`PERSISTENCE_NOT_FOUND`, `INVALID_SAVE_RECORD`.
|
||||
- OTel counters + duration histogram.
|
||||
- Tracks `currentSaveId` for snapshot context.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GamePersistence } from "@suaveplan/game-server/enhanced/persistence/game-persistence";
|
||||
|
||||
const gp = new GamePersistence<MyState>(
|
||||
{ gameId: "chess" },
|
||||
{ storage: createSurrealStorage() }
|
||||
);
|
||||
|
||||
const result = await gp.save(state, { name: "quicksave" });
|
||||
const loaded = await gp.load(result.id!);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GamePersistence } from "@suaveplan/game-server/enhanced/persistence/game-persistence";
|
||||
|
||||
const gp = new GamePersistence<MyState>(
|
||||
{ gameId: "chess", maxSnapshots: 50, maxHistoryEntries: 200 },
|
||||
{
|
||||
storage,
|
||||
clock: () => now,
|
||||
idGenerator: () => `save-${++counter}`,
|
||||
meter,
|
||||
}
|
||||
);
|
||||
|
||||
const save = await gp.save(state, { name: "checkpoint", tags: ["wave-4"] });
|
||||
const snap = await gp.createSnapshot(state, "before-boss", "auto");
|
||||
const restored = await gp.restoreSnapshot(save.id!, snap.id);
|
||||
const history = await gp.getHistory(save.id!);
|
||||
const stats = gp.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new GamePersistence(config, deps)` — `deps.storage: StorageLike`.
|
||||
- `save(state, input): Promise<SaveResult>`
|
||||
- `load(id): Promise<LoadResult>`
|
||||
- `delete(id): Promise<boolean>`
|
||||
- `exists(id): Promise<boolean>`
|
||||
- `createSnapshot(state, name, reason?): Promise<Snapshot>`
|
||||
- `restoreSnapshot(saveId, snapshotId): Promise<LoadResult>`
|
||||
- `getHistory(saveId): Promise<readonly HistoryEntry[]>`
|
||||
- `getStats(): PersistenceStats`
|
||||
- `getCurrentSaveId(): string | null`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
State is encoded as UTF-8 JSON via `TextEncoder`/`TextDecoder` so the
|
||||
on-disk shape is debuggable with any text viewer; binary-only stores will
|
||||
treat the payload as opaque bytes. For game states with large embedded
|
||||
binaries (images, audio), callers should chunk those out to a separate
|
||||
asset store and reference them by id in the persisted state to avoid
|
||||
inflating the JSON payload.
|
||||
|
||||
Storage keys are namespaced by both `gameId` and the per-save id so a
|
||||
single shared backend can host saves for many concurrent game instances
|
||||
without prefix collisions. The `currentSaveId` cursor is in-memory only —
|
||||
restarting the process requires an explicit `load()` before
|
||||
`createSnapshot()` will succeed.
|
||||
|
||||
JSON decode failures are mapped to `PERSISTENCE_LOAD_FAILED` rather than
|
||||
silently returning `null`, so corrupted storage surfaces as an
|
||||
inspectable error chain instead of an empty save list. Save failures
|
||||
preserve their underlying cause through `GameServerError.cause`, making
|
||||
storage-side diagnostics directly available to the caller.
|
||||
@@ -0,0 +1,104 @@
|
||||
# ReplayPlayer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ReplayPlayer` plays back a `ReplayData<TState, TInput>` document produced
|
||||
by `ReplayRecorder`. It exposes the controls expected of a media player —
|
||||
play, pause, stop, step forward/backward, seek, speed — but is driven by
|
||||
explicit `tick()` calls rather than a timer, so playback is fully
|
||||
deterministic and trivially exercised in tests or headless replay rigs.
|
||||
|
||||
The player delivers replayed inputs through observer subscriptions
|
||||
(`onInput`, `onSnapshotRestore`) so consumer engines stay in control of
|
||||
their own dispatch pipeline. OTel counters track `playback_advance_total`
|
||||
and `playback_seek_total` so dashboards can monitor replay throughput in
|
||||
production replay-analysis workloads.
|
||||
|
||||
## Features
|
||||
|
||||
- Tick-driven, deterministic playback.
|
||||
- Speeds: `0.25 / 0.5 / 1 / 2 / 4` with Zod-enforced enum.
|
||||
- Snapshot-aware seek and stepBackward.
|
||||
- Loop mode for highlight loops or attract screens.
|
||||
- Observer-style input + snapshot listeners (no global emitter).
|
||||
- Stable error codes: `REPLAY_NOT_LOADED`, `REPLAY_PARSE_FAILED`,
|
||||
`REPLAY_INVALID_SPEED`.
|
||||
- OTel counters tagged per advance / seek.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ReplayPlayer } from "@suaveplan/game-server/enhanced/replay/replay-player";
|
||||
|
||||
const player = new ReplayPlayer<GameState, GameInput>();
|
||||
player.load(replay);
|
||||
player.onInput((input) => engine.apply(input.data, input.playerId));
|
||||
player.play();
|
||||
|
||||
setInterval(() => player.tick(), 1000 / 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ReplayPlayer } from "@suaveplan/game-server/enhanced/replay/replay-player";
|
||||
|
||||
const player = new ReplayPlayer<GameState, GameInput>(
|
||||
{ loop: true, initialSpeed: 2 }
|
||||
);
|
||||
player.load(replay);
|
||||
|
||||
const offInput = player.onInput((i) => engine.apply(i.data, i.playerId));
|
||||
const offSnap = player.onSnapshotRestore((state) => engine.replace(state));
|
||||
|
||||
player.play();
|
||||
for (let i = 0; i < replay.metadata.totalTicks * 4; i += 1) player.tick();
|
||||
|
||||
player.seekToTick(replay.metadata.totalTicks / 2);
|
||||
player.setSpeed(0.5);
|
||||
player.stepBackward();
|
||||
|
||||
offInput();
|
||||
offSnap();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ReplayPlayer(config?, deps?)`
|
||||
- `load(replay): void`
|
||||
- `play(): void` / `pause(): void` / `stop(): void`
|
||||
- `tick(): void`
|
||||
- `stepForward(): void` / `stepBackward(): void`
|
||||
- `seekToTick(target): void`
|
||||
- `setSpeed(speed: PlaybackSpeed): void`
|
||||
- `onInput(listener): () => void`
|
||||
- `onSnapshotRestore(listener): () => void`
|
||||
- `getPosition(): PlaybackPosition`
|
||||
- `getMetadata(): ReplayMetadata | null`
|
||||
- `getStatus(): PlaybackStatus` / `getSpeed(): PlaybackSpeed`
|
||||
- `getCurrentState(): TState | null` / `isLoaded(): boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Playback advancement multiplies `speed` into the number of ticks consumed
|
||||
per `tick()` call, which yields exact rather than approximate replay speed
|
||||
on every callable cadence (60Hz, 30Hz, single-step debugging). Speeds
|
||||
outside the canonical set are rejected via Zod-validated config and an
|
||||
explicit `REPLAY_INVALID_SPEED` error at `setSpeed` time.
|
||||
|
||||
Seek walks the recorded snapshots backwards to find the most-recent
|
||||
snapshot at or before the target tick, restores from it, then advances
|
||||
input-by-input to the requested tick. With no snapshots, the player falls
|
||||
back to the initial state — slower but always correct. Engines that need
|
||||
snappy scrubbing should record interval snapshots at a reasonable rate
|
||||
(60–300 ticks).
|
||||
|
||||
End-of-replay handling is split between `loop=true` (rewinds and keeps
|
||||
playing) and `loop=false` (transitions to `ended`). The transition to
|
||||
`ended` is sticky — further `tick()` calls remain a no-op until `play()`
|
||||
or `seekToTick()` rewinds the cursor, which prevents accidental input
|
||||
re-emission from off-by-one tick loops.
|
||||
@@ -0,0 +1,105 @@
|
||||
# ReplayRecorder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ReplayRecorder` captures gameplay into a deterministic, replay-ready
|
||||
`ReplayData<TState, TInput>` document. It records inputs with monotonic
|
||||
sequence numbers, takes periodic state snapshots at a configurable tick
|
||||
interval, and tracks paused intervals so the resulting timeline reflects
|
||||
actual elapsed gameplay rather than wall-clock duration.
|
||||
|
||||
The recorder injects its `Clock` and `IdGenerator` so deterministic tests
|
||||
can produce byte-identical replay files run after run, and emits OTel
|
||||
counters for input and snapshot volume so production replay-capture rates
|
||||
are observable. The output document is the input contract for
|
||||
`ReplayPlayer` — record on one machine, ship the JSON, play back anywhere.
|
||||
|
||||
## Features
|
||||
|
||||
- Status FSM: `idle → recording → paused → stopped`.
|
||||
- Per-input monotonic `sequence` counters.
|
||||
- Auto-snapshot every N ticks plus manual snapshots.
|
||||
- Pause/resume that excludes paused intervals from duration.
|
||||
- Tags and free-form metadata attached at stop time.
|
||||
- Stable error codes: `REPLAY_RECORDING_STATE_INVALID`,
|
||||
`REPLAY_PARSE_FAILED`.
|
||||
- OTel counters tagged by snapshot reason.
|
||||
- Injectable `Clock` + `IdGenerator`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ReplayRecorder } from "@suaveplan/game-server/enhanced/replay/replay-recorder";
|
||||
|
||||
const rec = new ReplayRecorder<GameState, GameInput>();
|
||||
rec.start(initial);
|
||||
rec.recordInput({ dx: 1 }, "alice");
|
||||
rec.tick(current);
|
||||
const replay = rec.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ReplayRecorder } from "@suaveplan/game-server/enhanced/replay/replay-recorder";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
let counter = 0;
|
||||
const rec = new ReplayRecorder<GameState, GameInput>(
|
||||
{ snapshotInterval: 60, maxInputBuffer: 50_000 },
|
||||
{
|
||||
clock: () => now,
|
||||
idGenerator: () => `r-${++counter}`,
|
||||
}
|
||||
);
|
||||
|
||||
rec.addTag("ranked");
|
||||
rec.setMetadata({ matchId: "match-42", seed: 0xdeadbeef });
|
||||
rec.start(initial);
|
||||
|
||||
for (const event of stream) {
|
||||
rec.recordInput(event, event.playerId);
|
||||
rec.tick(currentState);
|
||||
}
|
||||
|
||||
const replay = rec.stop();
|
||||
// replay.version === 1, deterministic if (clock, idGenerator, inputs) match
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ReplayRecorder(config?, deps?)`
|
||||
- `start(initialState): void`
|
||||
- `pause(): void` / `resume(): void`
|
||||
- `recordInput(input, playerId?): void`
|
||||
- `recordSnapshot(state, reason?): RecordedSnapshot`
|
||||
- `tick(currentState?): void`
|
||||
- `addTag(tag): void` / `setMetadata(extra): void`
|
||||
- `stop(): ReplayData`
|
||||
- `reset(): void`
|
||||
- `getStats(): RecordingStats` / `getStatus(): RecordingStatus`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Pause handling is deliberately exclusive: the elapsed-time formula uses
|
||||
`pauseTime` while paused and `clock()` otherwise, so the duration reported
|
||||
during pause never advances. Resumption tallies the paused interval into
|
||||
`totalPausedTime` and is subtracted from all subsequent elapsed lookups,
|
||||
yielding a timeline that matches "active gameplay" not wall clock.
|
||||
|
||||
Snapshot intervals operate on tick count rather than wall-clock time so a
|
||||
60Hz game running at 30Hz under load still produces snapshots at
|
||||
predictable gameplay milestones. The auto-snapshot path skips when no
|
||||
state is provided to `tick()`, allowing pure-input replay capture for
|
||||
deterministic engines where the player can reconstruct state from inputs
|
||||
alone.
|
||||
|
||||
Input buffer overflow surfaces as a `GameServerError` rather than silent
|
||||
truncation so a runaway client can be detected at capture time. Callers
|
||||
that prefer back-pressure can wrap `recordInput` in their own throttle;
|
||||
the default `maxInputBuffer` of 10_000 is enough for ~15 minutes of dense
|
||||
60Hz inputs from a single player.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Registry — internal helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helpers extracted from `GameInstanceRegistry` so the orchestrator
|
||||
class stays inside the per-file budget. The helpers cover the two big
|
||||
chunks of stateless logic: applying every filter dimension to a
|
||||
candidate list, and dispatching one of the four load-balancing
|
||||
strategies.
|
||||
|
||||
## Features
|
||||
|
||||
- `applyFilter` — ten-dimension filter pipeline (health, type, region,
|
||||
mode, map, slot count, max count, privacy, tags, version).
|
||||
- `applyStrategy` — switch over `least-players`, `random`,
|
||||
`region-based`, `round-robin`. The class supplies its `rng`,
|
||||
`regionLatencies`, and `roundRobinIndex` references.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { applyFilter } from "./game-instance-registry.internal.js";
|
||||
const healthy = applyFilter(instances, { healthyOnly: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { applyStrategy } from "./game-instance-registry.internal.js";
|
||||
|
||||
const result = applyStrategy(
|
||||
candidates,
|
||||
"region-based",
|
||||
rng,
|
||||
regionLatencyMap,
|
||||
roundRobinIndex,
|
||||
/* preferredRegion */ "us-east"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyFilter<TCustom>(instances, filter): GameInstance<TCustom>[]`.
|
||||
- `applyStrategy<TCustom>(instances, strategy, rng, regionLatencies, roundRobinIndex, preferredRegion?): { instance, score }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The healthy-instance filter is applied first because in a real
|
||||
deployment most candidates are unhealthy at the moment of an outage,
|
||||
and short-circuiting the rest of the chain saves work on the long
|
||||
tail of subsequent dimension filters.
|
||||
- `random` prefers `Rng.nextIntInRange` when present and falls back to
|
||||
`Math.floor(rng.nextFloat() * len)`. This mirrors the contract spelled
|
||||
out by `@suaveplan/types`'s `Rng`, which marks `nextIntInRange` as
|
||||
optional but encourages CSPRNG callers to provide it for unbiased
|
||||
rejection sampling.
|
||||
- `region-based` falls back to a 100 ms default latency for unknown
|
||||
regions — same as the upstream baseline. The fallback keeps the
|
||||
weighting deterministic when a deployment grows into a new region
|
||||
before the operator updates the latency map.
|
||||
- `round-robin` keys the cursor on a sorted join of instance IDs, so
|
||||
changing the candidate set does not silently reset the rotation.
|
||||
The cursor lives on the orchestrator class instance (not in
|
||||
module-level state) so two registries can run round-robin in
|
||||
parallel without aliasing each other.
|
||||
- Every function is pure: callers thread their own `rng`,
|
||||
`regionLatencies`, and `roundRobinIndex` references in. That is
|
||||
what lets the orchestrator class stay under the file-size budget
|
||||
while keeping the helpers test-friendly in isolation.
|
||||
@@ -0,0 +1,119 @@
|
||||
# `GameInstanceRegistry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameInstanceRegistry` is the in-memory primitive that tracks every live
|
||||
game-server instance in a multi-host deployment. It owns the canonical
|
||||
`{id → instance}` map, evicts stale entries whose last heartbeat is older
|
||||
than `instanceTTL`, and selects one healthy instance per discovery
|
||||
request using one of four load-balancing strategies. Transport façades
|
||||
(HTTP/gRPC) that surface the registry over the network live in sibling
|
||||
packages — this module is the pure data structure plus its lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- TTL-based eviction driven by an injected `Clock` (no implicit `Date.now`).
|
||||
- Four load-balancing strategies: `least-players`, `random`, `region-based`,
|
||||
`round-robin`. The `random` strategy consults an injected `Rng`.
|
||||
- Filter-based `discover()`: by game type, region, mode, map, slot count,
|
||||
privacy, tags, and version.
|
||||
- Lifecycle events streamed through a single fan-in listener:
|
||||
`registered`, `deregistered`, `health-changed`, `capacity-changed`,
|
||||
`full`, `available`.
|
||||
- Zod-validated `register()` boundary; every public mutation routes to a
|
||||
typed `GameServerError` on lookup miss.
|
||||
- One OTel counter (`game_server.registry.instance_lifecycle_total`)
|
||||
tagged with `op ∈ {register, deregister, expire, health, capacity}`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameInstanceRegistry } from "@suaveplan/game-server/enhanced/service-discovery/game-instance-registry";
|
||||
|
||||
const registry = new GameInstanceRegistry({
|
||||
clock: Date.now,
|
||||
rng: { nextUint32: () => 0, nextFloat: Math.random },
|
||||
});
|
||||
|
||||
registry.register({
|
||||
id: "i-001",
|
||||
name: "us-east-shard-1",
|
||||
host: "10.0.0.1",
|
||||
port: 4000,
|
||||
metadata: {
|
||||
gameType: "duel",
|
||||
region: "us-east",
|
||||
playerCount: 0,
|
||||
maxPlayers: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const selected = registry.selectInstance(
|
||||
{ gameType: "duel", minAvailableSlots: 2 },
|
||||
"least-players"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const off = registry.on((event) => {
|
||||
if (event.kind === "full") notifyMatchmaker(event.instance.id);
|
||||
if (event.kind === "health-changed" && event.newStatus === "unhealthy") {
|
||||
drainSessionsFrom(event.instance.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Region-weighted selection — pass a latency map for cold start.
|
||||
const r = new GameInstanceRegistry(
|
||||
{ clock, rng },
|
||||
{
|
||||
instanceTTL: 30_000,
|
||||
defaultStrategy: "region-based",
|
||||
regionLatencies: new Map([
|
||||
["us-east", 25],
|
||||
["eu-west", 95],
|
||||
["ap-southeast", 180],
|
||||
]),
|
||||
autoDeregisterEmpty: true,
|
||||
}
|
||||
);
|
||||
|
||||
setInterval(() => r.sweep(), 5_000);
|
||||
off();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new GameInstanceRegistry(deps, config?)` — `deps: { clock, rng }`,
|
||||
`config: { instanceTTL?, defaultStrategy?, autoDeregisterEmpty?, regionLatencies? }`.
|
||||
- `register(registration)` — validate + insert; emits `registered`.
|
||||
- `deregister(id)` — remove; emits `deregistered`. Throws on unknown id.
|
||||
- `heartbeat(id)` — refresh `lastHeartbeat`.
|
||||
- `updateHealth(id, status)` — transition health; emits `health-changed`.
|
||||
- `updateCapacity({ instanceId, playerCount, maxPlayers? })` — emits
|
||||
`capacity-changed`, plus `full` or `available` at the boundary.
|
||||
- `discover(filter?)` — filter healthy instances.
|
||||
- `selectInstance(filter?, strategy?)` — pick one or `null`.
|
||||
- `getInstance(id)` — direct lookup, or `null`.
|
||||
- `getStats()` — aggregate snapshot.
|
||||
- `sweep()` — force the TTL pass (also invoked by `discover`).
|
||||
- `on(listener)` — subscribe to lifecycle events, returns an unsubscribe.
|
||||
- `dispose()` — clear all internal state.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Eviction runs in two phases: at `age > instanceTTL` the instance is
|
||||
marked `unhealthy`; at `age > instanceTTL * 2` it is removed. This
|
||||
preserves the upstream semantics where downstream consumers had one
|
||||
full TTL window to fail their own probes.
|
||||
- `random` prefers the optional `Rng.nextIntInRange` and falls back to
|
||||
`Math.floor(rng.nextFloat() * len)` when only `nextFloat` is provided.
|
||||
- `round-robin` keys on a sorted join of instance IDs so adding /
|
||||
removing instances does not silently reset the rotation.
|
||||
- The lifecycle counter is registered through the global OTel meter so
|
||||
no SDK wiring is needed in tests.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `BitPackerStateSync`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BitPackerStateSync` is the byte-packed state-synchronisation primitive
|
||||
for low-bandwidth, high-frequency multiplayer updates. It packs game
|
||||
state to `Uint8Array` via a caller-supplied `@suaveplan/bitpacker`
|
||||
`BitPacker`, maintains a bounded client-side prediction history, supports
|
||||
server-authoritative reconciliation with input replay above the
|
||||
server-acknowledged tick, and linearly interpolates between two history
|
||||
frames for smooth rendering. It coexists with the JSON-delta
|
||||
`state/state-sync` from wave B-3 — the JSON path is for human-readable
|
||||
debugging, this path is for the wire.
|
||||
|
||||
## Features
|
||||
|
||||
- Pack / unpack via the caller's pre-configured `BitPacker` (strategies
|
||||
registered by the caller — gives full control over schema dialect).
|
||||
- Client-side prediction with bounded history (`maxPredictionFrames`).
|
||||
- Server reconciliation: replays every pending input with `tick > serverTick`
|
||||
through the caller's `simulate` function, then compares to the server
|
||||
state via a caller-supplied `compare` function. Below
|
||||
`reconciliationThreshold` the result is a no-op.
|
||||
- Linear interpolation between two history frames bracketing the
|
||||
delayed render time. Falls back to the latest frame when no bracket
|
||||
exists.
|
||||
- All pack / unpack failures route through `GameServerError` with
|
||||
`BITPACK_ENCODE_FAILED` / `BITPACK_DECODE_FAILED`.
|
||||
- One counter (`game_server.state.bitpack_delta_total`) and one
|
||||
histogram (`game_server.state.bitpack_bytes`) report wire activity.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BitPacker } from "@suaveplan/bitpacker";
|
||||
import { registerDefaultStrategies } from "@suaveplan/bitpacker-strategies";
|
||||
import { BitPackerStateSync } from "@suaveplan/game-server/enhanced/state-sync/bitpacker-state-sync";
|
||||
|
||||
const packer = new BitPacker();
|
||||
registerDefaultStrategies(packer.strategyManager);
|
||||
|
||||
const sync = new BitPackerStateSync<{ x: number; y: number }>(
|
||||
{ clock: Date.now },
|
||||
{ packer, schema: { x: 255, y: 255 } }
|
||||
);
|
||||
|
||||
const msg = sync.packState({ x: 100, y: 50 });
|
||||
// transmit msg.payload over WebSocket / WebRTC
|
||||
const restored = sync.unpackState(msg);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Client-side prediction + reconciliation loop
|
||||
const simulate = (s: World, input: Input) => applyInput(s, input);
|
||||
const compare = (a: World, b: World) => squaredDistance(a, b);
|
||||
|
||||
sync.tick();
|
||||
const predicted = sync.predict(currentWorld, localInput, simulate);
|
||||
renderWorld(predicted);
|
||||
|
||||
onServerSnapshot((snapshot, serverTick) => {
|
||||
const result = sync.reconcile(snapshot.state, serverTick, simulate, compare);
|
||||
if (result.needed) snapToServerCorrected(result.state, result.framesResimulated);
|
||||
});
|
||||
|
||||
// Interpolation for remote entities (lerp between two server frames)
|
||||
const visible = sync.interpolate(serverHistory, performance.now(), lerpWorld);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new BitPackerStateSync({ clock }, config)` —
|
||||
`config: { packer, schema?, enablePrediction?, enableInterpolation?, enableReconciliation?, interpolationDelayTicks?, tickDurationMs?, maxPredictionFrames?, reconciliationThreshold? }`.
|
||||
- `tick() / getTick() / setTick(value)` — logical tick management.
|
||||
- `packState(state) → StateSyncMessage` — kind `"state:delta"`.
|
||||
- `packFullSnapshot(state) → StateSyncMessage` — kind `"state:full"`.
|
||||
- `unpackState(message) → TState`.
|
||||
- `predict(state, input, simulate) → TState`.
|
||||
- `reconcile(serverState, serverTick, simulate, compare) → ReconciliationResult`.
|
||||
- `interpolate(states, renderTime, lerp) → TState | null`.
|
||||
- `getStats() / resetStats()`.
|
||||
- `getHistory() / getPendingInputs() / getLastServerState()`.
|
||||
- `dispose()` — release internal buffers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `BitPacker` instance is supplied by the caller because strategy
|
||||
registration is application-specific — different games pack
|
||||
different field types, and the strategies package owns the registry.
|
||||
- History is trimmed to `maxPredictionFrames` (not 2×) once it exceeds
|
||||
the `2 × maxPredictionFrames` threshold; this is the upstream rule
|
||||
(small grace before trim).
|
||||
- `interpolate` derives its delay as `interpolationDelayTicks * tickDurationMs`,
|
||||
which lets callers configure the visual smoothness independent of
|
||||
the network sync interval.
|
||||
- Counters and histograms are registered against the global OTel meter
|
||||
named `@suaveplan/game-server` at version `0.1.0` — consistent with
|
||||
every other game-server instrument.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Vector-clock sync — internal helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
This module factors out the pure helpers that drive
|
||||
`VectorClockSync` — happens-before / concurrent predicates, conflict
|
||||
resolution, causality sort, concurrent-group detection, and
|
||||
missing-action computation. Pulling them out of the class keeps
|
||||
`vector-clock-sync.ts` under the per-file budget and lets the helpers be
|
||||
exercised directly by tests without standing the full class up.
|
||||
|
||||
## Features
|
||||
|
||||
- `happenedBefore` / `areConcurrent` — vector-clock predicates that
|
||||
delegate to `vectorClockCompare` from `@suaveplan/timing`.
|
||||
- `resolveConflict` — strategy selection (`custom` → `merge` →
|
||||
`last-write-wins`) with the same precedence as the upstream sync.
|
||||
- `sortByCausality` — stable happens-before sort with timestamp tie
|
||||
breaker.
|
||||
- `findConcurrentGroups` — dedup by group identity (set equality).
|
||||
- `findMissingActions` — pool filter against a remote clock state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { happenedBefore } from "./vector-clock-sync.internal.js";
|
||||
const earlier = happenedBefore(a, b);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
findMissingActions,
|
||||
resolveConflict,
|
||||
} from "./vector-clock-sync.internal.js";
|
||||
|
||||
const conflict = resolveConflict(
|
||||
incoming,
|
||||
[conflictingLocal],
|
||||
/* conflictResolver */ undefined,
|
||||
/* eventMerger */ (a, b) => ({ ...a, ...b })
|
||||
);
|
||||
const missing = findMissingActions(pool, theirClock);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `happenedBefore<T>(a, b): boolean`
|
||||
- `areConcurrent<T>(a, b): boolean`
|
||||
- `resolveConflict<T>(action, conflicts, conflictResolver?, eventMerger?): ConflictResolution<T>`
|
||||
- `sortByCausality<T>(actions): GameAction<T>[]`
|
||||
- `findConcurrentGroups<T>(actions): GameAction<T>[][]`
|
||||
- `findMissingActions<T>(pool, theirClock): GameAction<T>[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `resolveConflict` returns `conflictDetected: true` whenever the
|
||||
`conflicts` array is non-empty, even if the chosen winner is the
|
||||
original action (this matches upstream semantics and is what the
|
||||
outer class uses to decide whether to emit metrics).
|
||||
- `sortByCausality` is stable for concurrent actions because the
|
||||
comparator falls through to the wall-clock timestamp difference.
|
||||
- `findConcurrentGroups` deduplicates by membership equality, not
|
||||
reference equality, so two iterations that reach the same group
|
||||
through different seed actions collapse to one entry.
|
||||
@@ -0,0 +1,101 @@
|
||||
# `VectorClockSync`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`VectorClockSync` is the distributed event-ordering primitive used by
|
||||
multiplayer game sessions to decide whether two actions are causally
|
||||
ordered or concurrent — the basis for deterministic replay, conflict
|
||||
resolution and resync after partition. It builds on
|
||||
`@suaveplan/timing`'s `createVectorClock` so all clock arithmetic lives
|
||||
in one place and the game-server module just records actions, tags
|
||||
them with the current snapshot, and decides what to do when two clocks
|
||||
are incomparable.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-session bookkeeping (`pendingActions`, `confirmedActions`,
|
||||
participants, session vector clock).
|
||||
- Three conflict-resolution strategies, dispatched in this order:
|
||||
`custom` (resolver supplied) → `merge` (merger supplied AND a single
|
||||
concurrent conflict) → `last-write-wins`.
|
||||
- Happens-before and concurrent predicates exposed both as instance
|
||||
methods and as standalone helpers in `vector-clock-sync.internal.ts`.
|
||||
- `requestSync` / `handleSyncRequest` / `applySyncResponse` cover the
|
||||
three legs of a pull-style sync round-trip.
|
||||
- Action history bounded by `maxActionHistory`, with FIFO eviction.
|
||||
- Injectable `Clock` and `IdGenerator` everywhere a timestamp / id is
|
||||
minted (no implicit `Date.now` / `Math.random`).
|
||||
- Two OTel counters: `game_server.vector_clock.actions_total` and
|
||||
`game_server.vector_clock.conflicts_total`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VectorClockSync } from "@suaveplan/game-server/enhanced/timing/vector-clock-sync";
|
||||
|
||||
const sync = new VectorClockSync(
|
||||
{ clock: Date.now, idGenerator: () => crypto.randomUUID() },
|
||||
{ nodeId: "player-1" }
|
||||
);
|
||||
|
||||
const action = sync.recordAction("session-1", "move", { x: 10 }, /* tick */ 4);
|
||||
broadcast(action);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// On the receiving side
|
||||
const result = sync.receiveAction(remoteAction);
|
||||
if (result?.conflictDetected) {
|
||||
metrics.recordConflict(result.strategy);
|
||||
applyChosen(result.resolvedAction);
|
||||
}
|
||||
|
||||
// Pull-based sync round-trip after reconnect
|
||||
const request = sync.requestSync("player-2", "session-1");
|
||||
sendOverWire(request);
|
||||
|
||||
// On the peer
|
||||
const response = peerSync.handleSyncRequest(request);
|
||||
|
||||
// Back on the requester
|
||||
const newCount = sync.applySyncResponse(response);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new VectorClockSync({ clock, idGenerator }, config)` —
|
||||
`config: { nodeId, maxActionHistory?, autoResolveConflicts?, conflictResolver?, eventMerger? }`.
|
||||
- `recordAction(sessionId, actionType, data, tick) → GameAction`.
|
||||
- `receiveAction(action) → ConflictResolution | null`.
|
||||
- `acknowledgeAction(id) → boolean`.
|
||||
- `getCausalityChain(sessionId) → CausalityChain`.
|
||||
- `happenedBefore(a, b) / areConcurrent(a, b)` — predicates.
|
||||
- `requestSync(toNode, sessionId) → SyncRequest`.
|
||||
- `handleSyncRequest(request) → SyncResponse`.
|
||||
- `applySyncResponse(response) → number` (new actions count).
|
||||
- `addParticipant / removeParticipant`.
|
||||
- `getSessionState / getStats / dispose`.
|
||||
- `createVectorClockSync(deps, config)` — convenience factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `receiveAction` always advances this node's clock by `update(merge)`
|
||||
before resolution; this is what guarantees the resulting causality
|
||||
chain stays consistent even if the application discards the
|
||||
conflict winner.
|
||||
- Action IDs are generated by the injected `IdGenerator` rather than
|
||||
derived from `${nodeId}-${seq}-${timestamp}` so callers can plug in
|
||||
UUID v7 or any other source — the original derivation was lossy
|
||||
when two actions raced inside the same millisecond.
|
||||
- The action-history map is capped at `maxActionHistory` and evicts in
|
||||
insertion order. Beyond the cap, replays only work for actions
|
||||
inside the window.
|
||||
- `findConcurrentGroups` dedupes by membership equality (set equality)
|
||||
so two iterations that reach the same group through different seed
|
||||
actions collapse to one entry.
|
||||
@@ -0,0 +1,81 @@
|
||||
# tournament-bracket.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure bracket-generation helpers used by `TournamentManager`. Extracted to
|
||||
a sibling internal module to keep `tournament-manager.ts` under the
|
||||
project's 300-line hard limit while preserving full coverage of the
|
||||
generation paths through the manager's public surface.
|
||||
|
||||
The helpers are deliberately framework-free — they accept an injectable
|
||||
`createMatch` factory so the manager retains exclusive ownership of match
|
||||
ids, OTel counters, and other side-effecting machinery, leaving these
|
||||
helpers as deterministic pure functions over the participant + match
|
||||
type domain.
|
||||
|
||||
## Features
|
||||
|
||||
- `roundName(round, total)` — human-readable round labels.
|
||||
- `shuffleWithRng(arr, rng)` — in-place Fisher-Yates via injectable `Rng`.
|
||||
- `padToPowerOfTwo(participants)` — null-padding for single-elim.
|
||||
- `buildSingleElim(...)` — bracket with first-round byes.
|
||||
- `buildRoundRobin(...)` — circle-method scheduling.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildSingleElim } from "./tournament-bracket.internal.js";
|
||||
const rounds = buildSingleElim(participants, (round, position) =>
|
||||
createMatch(round, position)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildRoundRobin,
|
||||
shuffleWithRng,
|
||||
} from "./tournament-bracket.internal.js";
|
||||
|
||||
shuffleWithRng(participants, rng);
|
||||
const rounds = buildRoundRobin(participants, (round, position) => ({
|
||||
id: nextId(),
|
||||
round,
|
||||
position,
|
||||
participant1: null,
|
||||
participant2: null,
|
||||
status: "pending",
|
||||
result: null,
|
||||
nextMatchId: null,
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `roundName(round: number, total: number): string`
|
||||
- `shuffleWithRng<T>(arr: T[], rng: Rng | undefined): void`
|
||||
- `padToPowerOfTwo<T>(participants: Participant<T>[]): (Participant<T> | null)[]`
|
||||
- `buildSingleElim<TP, TR>(participants, createMatch): Round<TP, TR>[]`
|
||||
- `buildRoundRobin<TP, TR>(participants, createMatch): Round<TP, TR>[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildSingleElim` throws `INVALID_BRACKET_SIZE` if the padded size is not
|
||||
a power of two — this should be unreachable in practice since
|
||||
`padToPowerOfTwo` always pads to a power of two, but the guard catches
|
||||
caller misuse (e.g. handing in a pre-padded list) without silent
|
||||
corruption of the bracket shape.
|
||||
|
||||
`buildRoundRobin` uses the standard circle method: one participant is
|
||||
fixed and the others rotate around it. For an odd participant count a
|
||||
`null` slot is injected so each round contains exactly `floor(n/2)`
|
||||
matches; pairs with `null` are filtered out before emitting matches so
|
||||
they don't appear in the bracket. This yields `n - 1` rounds for `n`
|
||||
even and `n` rounds for `n` odd, matching the conventional schedule.
|
||||
|
||||
Shuffle is unbiased Fisher-Yates and uses `Rng.nextIntInRange` —
|
||||
implementations of `Rng` that do not provide it (it is optional in the
|
||||
core contract) leave the order untouched, which is the conservative
|
||||
default for any tournament harness that prefers explicit error over silent
|
||||
non-determinism.
|
||||
@@ -0,0 +1,129 @@
|
||||
# TournamentManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TournamentManager` orchestrates single-elimination and round-robin
|
||||
tournaments. Participants register during the `pending` phase, then a
|
||||
single `start()` locks the field, applies seeding (or RNG-driven
|
||||
shuffling), and constructs the bracket. From there the manager tracks
|
||||
match results, propagates winners through `nextMatchId` links, updates
|
||||
standings, and emits stable error codes whenever the caller would
|
||||
otherwise corrupt the bracket.
|
||||
|
||||
The manager injects its `Clock`, `IdGenerator`, and `Rng` so deterministic
|
||||
test rigs and seeded replays produce byte-identical brackets each run.
|
||||
OTel counters record per-match creation and completion so production
|
||||
operators can monitor tournament throughput without bespoke
|
||||
instrumentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Two bracket types: single-elimination, round-robin.
|
||||
- Automatic first-round bye propagation for non-power-of-two fields.
|
||||
- Deterministic seeded shuffling via injectable `Rng`.
|
||||
- Standings table with stable points/wins/losses tie-break.
|
||||
- Stable `GAME_SERVER.*` error codes (`INVALID_BRACKET_SIZE`,
|
||||
`TOURNAMENT_INVALID_STATE`, `TOURNAMENT_FULL`,
|
||||
`TOURNAMENT_DUPLICATE_PARTICIPANT`,
|
||||
`TOURNAMENT_INSUFFICIENT_PARTICIPANTS`, `TOURNAMENT_MATCH_NOT_FOUND`,
|
||||
`TOURNAMENT_INVALID_RESULT`).
|
||||
- Zod-validated config.
|
||||
- OTel match counter tagged by `op` (`created`, `completed`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TournamentManager } from "@suaveplan/game-server/enhanced/tournament/tournament-manager";
|
||||
|
||||
const tm = new TournamentManager({
|
||||
id: "weekly-cup",
|
||||
name: "Weekly Cup",
|
||||
bracketType: "single-elimination",
|
||||
maxParticipants: 16,
|
||||
});
|
||||
tm.addParticipant("alice");
|
||||
tm.addParticipant("bob");
|
||||
tm.start();
|
||||
const firstMatch = tm.getTournament().rounds[0]!.matches[0]!;
|
||||
tm.recordResult(firstMatch.id, "alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TournamentManager } from "@suaveplan/game-server/enhanced/tournament/tournament-manager";
|
||||
|
||||
const tm = new TournamentManager<PlayerProfile, MatchTelemetry>(
|
||||
{
|
||||
id: "monthly",
|
||||
name: "Monthly Championship",
|
||||
bracketType: "round-robin",
|
||||
maxParticipants: 8,
|
||||
shuffleSeeds: true,
|
||||
winPoints: 3,
|
||||
drawPoints: 1,
|
||||
lossPoints: 0,
|
||||
},
|
||||
{
|
||||
clock: () => now,
|
||||
rng: seededRng,
|
||||
idGenerator: () => `m-${++counter}`,
|
||||
}
|
||||
);
|
||||
|
||||
for (const player of players) {
|
||||
tm.addParticipant(player.id, player.profile);
|
||||
}
|
||||
tm.start();
|
||||
|
||||
for (const round of tm.getTournament().rounds) {
|
||||
for (const match of round.matches) {
|
||||
const winnerId = await playMatch(match);
|
||||
tm.recordResult(match.id, winnerId, { latencyMs: 27 });
|
||||
}
|
||||
}
|
||||
|
||||
const champion = tm.getWinner();
|
||||
const standings = tm.getStandings();
|
||||
const stats = tm.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new TournamentManager(config, deps?)`
|
||||
- `addParticipant(id, data?, seed?): Participant`
|
||||
- `start(): void`
|
||||
- `getMatch(id): Match`
|
||||
- `recordResult(matchId, winnerId, data?): Match`
|
||||
- `cancel(): void`
|
||||
- `getTournament(): Tournament`
|
||||
- `getWinner(): Participant | null`
|
||||
- `getStandings(): readonly Standing[]`
|
||||
- `getStats(): TournamentStats`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Bracket generation is split into `tournament-bracket.internal.ts` and
|
||||
standings/match utilities into `tournament-standings.internal.ts` to keep
|
||||
this file under the project's 300-line hard limit while preserving full
|
||||
behaviour and 100% coverage through the public surface. Internal modules
|
||||
are not exported through the package barrel — their tests run via the
|
||||
manager.
|
||||
|
||||
Single-elimination brackets pad participants to the next power of two
|
||||
with `null` slots that become first-round byes. Byes auto-advance their
|
||||
opponent into the next round during `start()`, so the bracket is
|
||||
immediately consistent before the first `recordResult()` call. Tournament
|
||||
completion is detected when exactly one participant remains active
|
||||
(single-elim) or every round has completed (round-robin).
|
||||
|
||||
Seeding has two paths: explicit `useSeeding=true` sorts by the
|
||||
caller-supplied `seed` value, while `shuffleSeeds=true` overrides that
|
||||
ordering by Fisher-Yates shuffling with the injected `Rng`. An `Rng`
|
||||
implementation that does not provide `nextIntInRange` is treated as a
|
||||
no-op shuffle — the conservative default for tournament harnesses that
|
||||
prefer explicit ordering errors over silent non-determinism.
|
||||
@@ -0,0 +1,71 @@
|
||||
# tournament-standings.internal
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helper utilities for the standings table inside a tournament:
|
||||
applying a win/loss outcome, marking participants eliminated, locating a
|
||||
match by id, and flipping a round to completed. Extracted from
|
||||
`tournament-manager.ts` to keep that file under the project's 300-line
|
||||
hard limit while preserving full behaviour through the public API.
|
||||
|
||||
The helpers are stateless and take their targets by reference so the
|
||||
manager retains exclusive ownership of mutability. They are covered
|
||||
indirectly via the manager's public surface tests rather than directly,
|
||||
which is the standard pattern for `.internal.ts` modules in this repo.
|
||||
|
||||
## Features
|
||||
|
||||
- `applyStanding` — write outcome + resort + re-rank in one call.
|
||||
- `markEliminated` — set `eliminated = true` by participant id.
|
||||
- `findMatch` — linear scan across rounds (small N).
|
||||
- `markRoundCompleteIfDone` — flip round completion idempotently.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { applyStanding } from "./tournament-standings.internal.js";
|
||||
applyStanding(standings, winner, loser, 3, 0);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
applyStanding,
|
||||
findMatch,
|
||||
markEliminated,
|
||||
markRoundCompleteIfDone,
|
||||
} from "./tournament-standings.internal.js";
|
||||
|
||||
applyStanding(tournament.standings, winner, loser, winPoints, lossPoints);
|
||||
markEliminated(tournament.standings, loser.id);
|
||||
const next = findMatch(tournament.rounds, currentMatch.nextMatchId!);
|
||||
markRoundCompleteIfDone(tournament.rounds, currentMatch);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyStanding(standings, winner, loser, winPoints, lossPoints): void`
|
||||
- `markEliminated(standings, id): void`
|
||||
- `findMatch(rounds, matchId): Match | null`
|
||||
- `markRoundCompleteIfDone(rounds, match): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The standings sort is stable on points first, then wins, then losses
|
||||
ascending — the conventional Swiss-style ordering. Re-sorting after every
|
||||
outcome is `O(N log N)` per result; for typical bracket sizes (≤ 256
|
||||
participants) this is well under a millisecond and avoids the
|
||||
bookkeeping complexity of an incremental order-statistic structure.
|
||||
|
||||
`findMatch` is a linear scan across all rounds. Bracket-size rounds grow
|
||||
logarithmically with participant count in single-elimination and
|
||||
linearly in round-robin, so the total match count stays small relative to
|
||||
typical scan budgets. A hot-path id-to-match index would be premature
|
||||
optimisation here and would force the manager to keep two structures in
|
||||
sync.
|
||||
|
||||
`markRoundCompleteIfDone` treats `bye` and `completed` as equivalent
|
||||
terminal states so a round consisting entirely of byes is reported as
|
||||
done immediately after `start()`, which keeps `getStats().currentRound`
|
||||
advancing through automatic walkovers without manual intervention.
|
||||
140
docs/gaming/games/game-server/src/errors/game-server-error.md
Normal file
140
docs/gaming/games/game-server/src/errors/game-server-error.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# GameServerError
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameServerError` is the single typed-exception class for the `@suaveplan/game-server` package. It extends `SuaveplanError<GameServerCode>` from `@suaveplan/error`, anchoring every failure raised by the server framework to a stable, lint-checkable code string in the `GAME_SERVER.*` namespace. Callers can branch on `error.code` without depending on `instanceof` semantics that fail across module realms.
|
||||
|
||||
The companion `GAME_SERVER_CODES` constant is produced by `defineCodes("GAME_SERVER", [...])` so the registry is verified at compile time and contributes its codes to the global suaveplan error catalog. The registry only enumerates codes that an actual production code path throws today — follow-up migrations (engine, state, matchmaking, telemetry, network) extend the array as their throw sites land, in line with `AGENTS.md` §13.
|
||||
|
||||
## Features
|
||||
|
||||
- Single typed error class for the entire package.
|
||||
- Codes resolved through `@suaveplan/error`'s `defineCodes` registry.
|
||||
- Optional `cause` and `context` for chained diagnostics.
|
||||
- Compile-time exhaustiveness on the `GameServerCode` union.
|
||||
- Code namespace `GAME_SERVER.*` is reserved for this package across the workspace.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import {
|
||||
GAME_SERVER_CODES,
|
||||
GameServerError,
|
||||
} from "@suaveplan/game-server/errors";
|
||||
|
||||
try {
|
||||
container.resolve("missing");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.SERVICE_NOT_REGISTERED
|
||||
) {
|
||||
// recover by registering a fallback
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { GameServerError, GAME_SERVER_CODES } from "@suaveplan/game-server/errors";
|
||||
|
||||
function loadConfig(input: unknown) {
|
||||
const parsed = GameConfigSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new GameServerError(
|
||||
GAME_SERVER_CODES.INVALID_GAME_CONFIG,
|
||||
"Game config failed Zod validation",
|
||||
{ cause: parsed.error, context: { input } },
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
The optional `context` bag is logged by the standard `@suaveplan/logger`
|
||||
formatter and forwarded as OpenTelemetry span attributes by
|
||||
`@suaveplan/opentelemetry`, so diagnostic data flows without bespoke wiring.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `GAME_SERVER_CODES`
|
||||
|
||||
A frozen object of code constants:
|
||||
|
||||
- `SERVICE_NOT_REGISTERED` — thrown by `DIContainer.resolve` on an unknown identifier.
|
||||
- `INVALID_GAME_CONFIG` / `INVALID_SESSION_METADATA` / `INVALID_PLAYER_INFO` — thrown by the corresponding `parse*` boundary functions in `./types`.
|
||||
- `INVALID_MOVE` — `AbstractGameLogic.processMove` validation failure.
|
||||
- `PLAYER_ALREADY_EXISTS` / `PLAYER_NOT_FOUND` — `AbstractGameLogic.{addPlayer,removePlayer}`.
|
||||
- `LISTENER_LIMIT_EXCEEDED` / `INVALID_LISTENER_COUNT` / `EVENT_WAIT_TIMEOUT` — `GameEventEmitter`.
|
||||
- `STATE_DIFF_FAILED` / `STATE_PATCH_FAILED` / `STATE_RESTORE_FAILED` / `INVALID_SNAPSHOT` / `STATE_DECOMPRESS_FAILED` / `STATE_SYNC_TRANSPORT_FAILED` — state-manager / differ / compressor / sync.
|
||||
- `TRANSPORT_ALREADY_INITIALIZED` / `TRANSPORT_NOT_INITIALIZED` / `TRANSPORT_NOT_CONNECTED` / `INVALID_MESSAGE_FORMAT` / `RECONNECT_EXHAUSTED` / `RECONNECT_IN_PROGRESS` — network adapter + reconnection manager + transports.
|
||||
- `ENGINE_NOT_STARTED` — engine method called before `start()`.
|
||||
- `ENGINE_ALREADY_STARTED` — second `start()` call on a live engine.
|
||||
- `ENGINE_DISPOSED` — engine method called after `dispose()`.
|
||||
- `INVALID_ENGINE_OPTIONS` — Zod failure on `GameEngineOptions`.
|
||||
- `INVALID_GAME_LOOP_OPTIONS` — Zod failure on `GameLoopOptions`.
|
||||
- `GAME_LOOP_NOT_RUNNING` — `pause`/`resume`/`stop` called on an idle loop.
|
||||
- `GAME_LOOP_ALREADY_RUNNING` — second `start()` call on a running loop.
|
||||
- `PLUGIN_NOT_FOUND` — `uninstall`, `enable`, `disable`, `get` on an unknown plugin name.
|
||||
- `DUPLICATE_PLUGIN` — `install` called with an already-registered plugin name.
|
||||
- `PLUGIN_LIFECYCLE_FAILED` — wraps any error thrown from a plugin lifecycle hook.
|
||||
- `MATCHMAKING_QUEUE_FULL` — `QueueManager.addToQueue` rejected because the per-key queue is at the configured cap.
|
||||
- `INVALID_QUEUE_REQUEST` — Zod failure on a `QueueRequest` arriving at `QueueManager` / `MatchmakingEngine`.
|
||||
- `INVALID_MATCH_CRITERIA` — Zod failure on a `MatchCriteria` arriving at `MatchmakingEngine.createMatch`.
|
||||
- `NO_MATCH_FOUND` — `MatchmakingEngine.createMatch` returned `null` when the caller requested `throwOnNoMatch`.
|
||||
- `INVALID_SKILL_SCORE` — `SkillMatcher` received a non-finite rating or seeker missing a positive `waitingSince`.
|
||||
- `SPECTATOR_LIMIT_REACHED` — `SpectatorManager.addSpectator` rejected because the session is at its spectator cap.
|
||||
- `INVALID_SPECTATOR_REQUEST` — Zod failure on `SpectatorPermissions` or duplicate spectator id.
|
||||
- `UNKNOWN_MATCHING_ALGORITHM` — `MatchmakingEngine.createMatch` was passed an algorithm string outside the registered union.
|
||||
- `POOL_EXHAUSTED` — `ObjectPool.acquire` rejected because the pool's `maxSize` is reached and no slots are free.
|
||||
- `POOL_DISPOSED` — `ObjectPool` method called after `dispose()`.
|
||||
- `RELEASE_FOREIGN_OBJECT` — `ObjectPool.release` received an object that did not originate from this pool.
|
||||
- `INVALID_POOL_CONFIG` — `ObjectPool` constructor received a non-positive `initialSize` / `maxSize`, or `maxSize` smaller than `initialSize`.
|
||||
- `INVALID_TIER_CONFIG` — `NetworkTierAdapter` constructor received an empty / malformed tier list or a node-capability with a non-finite score.
|
||||
- `UNKNOWN_TIER` — `NetworkTierAdapter` queried for a tier index / node-type that is not registered.
|
||||
|
||||
Each value is the fully-qualified string e.g. `"GAME_SERVER.SERVICE_NOT_REGISTERED"`.
|
||||
The latter six codes are introduced by Wave A (core foundation): `INVALID_MOVE`
|
||||
fires from `AbstractGameLogic.processMove` when validation fails;
|
||||
`PLAYER_ALREADY_EXISTS` / `PLAYER_NOT_FOUND` from `addPlayer` / `removePlayer`;
|
||||
`LISTENER_LIMIT_EXCEEDED` / `INVALID_LISTENER_COUNT` / `EVENT_WAIT_TIMEOUT`
|
||||
from `GameEventEmitter`.
|
||||
|
||||
The state-domain codes added in Wave B-3 (`STATE_*`, `INVALID_SNAPSHOT`)
|
||||
are thrown by the `state-manager`, `state-differ`, `delta-compressor`,
|
||||
and `state-sync` modules. `STATE_DIFF_FAILED` and `STATE_PATCH_FAILED`
|
||||
wrap unexpected exceptions raised while walking deeply-nested structures;
|
||||
`STATE_RESTORE_FAILED` and `INVALID_SNAPSHOT` surface when callers try to
|
||||
restore a snapshot whose shape disagrees with the manager's state schema;
|
||||
`STATE_DECOMPRESS_FAILED` wraps malformed wire packets; and
|
||||
`STATE_SYNC_TRANSPORT_FAILED` flags transport-layer send/receive faults.
|
||||
|
||||
### `type GameServerCode`
|
||||
|
||||
Union of all fully-qualified code strings in the registry.
|
||||
|
||||
### `new GameServerError(code, message, options?)`
|
||||
|
||||
- `code`: `GameServerCode`
|
||||
- `message`: `string`
|
||||
- `options?`: `{ cause?: unknown; context?: Record<string, unknown> }`
|
||||
|
||||
Instances expose `.code`, `.message`, `.cause` (or `undefined`),
|
||||
`.context` (or `undefined` when not supplied), and
|
||||
`.name === "GameServerError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError` is parameterised over a code union, so removing a code from
|
||||
the `defineCodes` literal array produces a TypeScript error at every throw
|
||||
site — this is the lint-checkable contract that keeps the registry minimal
|
||||
and honest. Codes are flat strings (no nesting) because `@suaveplan/error`'s
|
||||
canonical form is a single dot-delimited identifier. The class explicitly
|
||||
sets `this.name` because subclasses of `Error` inherit the parent name in
|
||||
some runtimes; downstream log scrapers key on the exact string
|
||||
`"GameServerError"`.
|
||||
@@ -0,0 +1,125 @@
|
||||
# EnhancedGameServer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EnhancedGameServer` is the top-of-stack integration layer for
|
||||
`@suaveplan/game-server`. Waves B and C-1 shipped the engine, plugin
|
||||
manager, and eight concrete plugins, but consumers still had to wire the
|
||||
loop, manager, engine, and every plugin by hand on each boot. This module
|
||||
collapses that boilerplate behind a single `bootstrap(options)` entry
|
||||
point and returns a typed handle exposing both the wired `GameEngine`
|
||||
and the bundle of installed plugins for direct access.
|
||||
|
||||
The integration is deliberately universal — zero browser, React, or
|
||||
DOM dependencies. The upstream React lobby shell is explicitly deferred
|
||||
to a future `react-game-server` package. All collaborators are injected
|
||||
through the structural contracts in `@suaveplan/types`
|
||||
(`Clock`, `IdGenerator`, `LoggerLike`, `TimersLike`, `EventBusLike`,
|
||||
`Rng`, `StorageLike`) plus the public APIs of `@suaveplan/feature-flags`,
|
||||
`@suaveplan/rules`, and `@suaveplan/workflow`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `bootstrap(options)` entry point — no manual wiring.
|
||||
- Pre-installs every built-in plugin (`logging`, `analytics`,
|
||||
`persistence`, `feature-flags`, `rules`, `workflow`, `ab-test`) via
|
||||
`PluginManager.installAll` so dependency order is honoured.
|
||||
- Enables every plugin after install so callers can opt-out by disabling
|
||||
individual plugins post-bootstrap rather than skipping them entirely.
|
||||
- Caller-supplied `extraPlugins` are installed last and enabled in the
|
||||
same pass.
|
||||
- `BOOTSTRAP_FAILED` wraps any plugin install failure with the original
|
||||
`cause` chain intact.
|
||||
- `ALREADY_BOOTSTRAPPED` prevents accidental double-wiring.
|
||||
- `start()` / `tick()` / `stop()` / `dispose()` delegate to the
|
||||
underlying `GameEngine` so existing engine codes (`ENGINE_NOT_STARTED`,
|
||||
`ENGINE_DISPOSED`) propagate unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EnhancedGameServer } from "@suaveplan/game-server/integrations/enhanced-server";
|
||||
|
||||
const server = new EnhancedGameServer({
|
||||
clock: () => Date.now(),
|
||||
timers,
|
||||
idGenerator,
|
||||
logger,
|
||||
events,
|
||||
emitter,
|
||||
flagManager,
|
||||
ruleEngine,
|
||||
rng,
|
||||
storage,
|
||||
analyticsSink: (event) => console.log(event),
|
||||
});
|
||||
|
||||
const { engine, plugins } = await server.bootstrap();
|
||||
server.start();
|
||||
await server.tick();
|
||||
server.stop();
|
||||
await server.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Install extra plugins after the canonical seven and configure tick rate:
|
||||
|
||||
```ts
|
||||
class TelemetryPlugin extends BasePlugin {
|
||||
public readonly name = "telemetry";
|
||||
public readonly version = "1.0.0";
|
||||
}
|
||||
|
||||
const handle = await server.bootstrap({
|
||||
autoStartLoop: false,
|
||||
tickRate: 30,
|
||||
extraPlugins: [new TelemetryPlugin()],
|
||||
});
|
||||
|
||||
// Disable analytics without uninstalling the rest.
|
||||
await handle.engine.getPlugin("analytics");
|
||||
// Direct typed access for per-plugin configuration:
|
||||
handle.plugins.featureFlag.evaluate("dark-mode", "player-1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class EnhancedGameServer(deps: EnhancedGameServerDeps)` — constructor.
|
||||
- `bootstrap(options?: EnhancedServerBootstrapOptions)` — returns
|
||||
`Promise<EnhancedServerHandle>`. Throws `ALREADY_BOOTSTRAPPED` on the
|
||||
second call and wraps install failures in `BOOTSTRAP_FAILED`.
|
||||
- `start()` — delegates to `GameEngine.start()`.
|
||||
- `tick()` — delegates to `GameEngine.tick()`.
|
||||
- `stop()` — delegates to `GameEngine.stop()`.
|
||||
- `dispose()` — idempotent; safe before bootstrap.
|
||||
- `isBootstrapped()` — predicate.
|
||||
- `getHandle()` — returns the typed handle; throws if not bootstrapped.
|
||||
|
||||
`EnhancedServerHandle` exposes the wired `engine` and a strongly-typed
|
||||
`plugins` bundle (`{ abTest, analytics, featureFlag, logging,
|
||||
persistence, rules, workflow, extras }`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Plugins are installed in an order that satisfies their declared
|
||||
`dependencies`. The current canonical set declares none, so the order
|
||||
is determined by the integration: log-first so subsequent installs are
|
||||
recorded, analytics next to capture session events, then persistence,
|
||||
feature-flags, rules, workflow, and finally A/B-test.
|
||||
- Bootstrap calls `PluginManager.installAll` (which `topoSort`s its
|
||||
argument) for an atomic batch install. After install, each plugin is
|
||||
individually enabled — `PluginManager.enable` is idempotent so the
|
||||
second-pass loop is safe.
|
||||
- The shared `EventBusLike<EnhancedServerEventMap>` is intersection-typed
|
||||
across the loop, manager, and engine maps so a single bus services all
|
||||
three subsystems without per-subsystem adapters.
|
||||
- All error wrapping preserves the original `cause` so consumers can
|
||||
walk the cause chain via `@suaveplan/error`'s utilities.
|
||||
- The class never holds React or DOM references; every concrete
|
||||
dependency is injected through a structural contract, keeping the
|
||||
package universal.
|
||||
@@ -0,0 +1,117 @@
|
||||
# MatchmakingEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MatchmakingEngine` is the top-level façade for the game-server's
|
||||
matchmaking pipeline. It composes three optional collaborators — a
|
||||
`QueueManager`, a `SpectatorManager`, and a `SkillMatcher` — behind a
|
||||
single `createMatch(players, criteria, algorithm)` API. The engine
|
||||
validates inputs through Zod (`MatchCriteriaSchema`,
|
||||
`MatchingAlgorithmSchema`), runs the chosen algorithm, mints a stable
|
||||
`matchId` via an injected `IdGenerator`, and surfaces every observable
|
||||
event through OTel counters and a synchronous `onMatchFound` listener.
|
||||
|
||||
The engine is also the place where the "pull from queue, run match,
|
||||
restore on shortfall" pattern lives. `createMatchFromQueue(queueKey,
|
||||
criteria, algorithm)` drains the top `criteria.maxPlayers` tickets from
|
||||
the injected `QueueManager`, hands them to `createMatch`, and — if not
|
||||
enough peers were waiting — re-enqueues the drained tickets so no
|
||||
player is silently lost. With `throwOnNoMatch` the shortfall path
|
||||
raises `NO_MATCH_FOUND` instead of returning `null`.
|
||||
|
||||
## Features
|
||||
|
||||
- Two algorithms today: `quick` (FIFO slice) and `skill-based` (largest
|
||||
group inside the configured `skillRange`).
|
||||
- Pluggable algorithm string — adding a new variant requires extending
|
||||
`MatchingAlgorithmSchema` and the dispatch switch.
|
||||
- Optional injected `QueueManager`, `SpectatorManager`, `SkillMatcher`.
|
||||
- Injectable `Clock`, `IdGenerator`, `Rng`.
|
||||
- Three OTel instruments — `match_create_total{algorithm,outcome}`,
|
||||
`match_reject_total{reason}`, `match_create_duration_ms`.
|
||||
- `onMatchFound` listener for observability + downstream wiring.
|
||||
- Stable `matchId` format: `match-<idGenerator-output>`.
|
||||
- `dispose()` clears every tracked match (for graceful shutdown).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { MatchmakingEngine } from "@suaveplan/game-server/matchmaking/matchmaking-engine";
|
||||
|
||||
const engine = new MatchmakingEngine();
|
||||
const match = engine.createMatch(
|
||||
[
|
||||
{ playerId: "alice", joinTime: 0, isPremium: false, queueType: "casual" },
|
||||
{ playerId: "bob", joinTime: 0, isPremium: false, queueType: "casual" },
|
||||
],
|
||||
{ minPlayers: 2, maxPlayers: 4 },
|
||||
);
|
||||
console.log(match?.matchId);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { MatchmakingEngine } from "@suaveplan/game-server/matchmaking/matchmaking-engine";
|
||||
import { QueueManager } from "@suaveplan/game-server/matchmaking/queue-manager";
|
||||
import { SpectatorManager } from "@suaveplan/game-server/matchmaking/spectator-manager";
|
||||
|
||||
const queue = new QueueManager({ maxQueueSize: 10_000 });
|
||||
const spectators = new SpectatorManager({ maxSpectatorsPerSession: 250 });
|
||||
|
||||
const engine = new MatchmakingEngine(
|
||||
{ throwOnNoMatch: false },
|
||||
{
|
||||
queueManager: queue,
|
||||
spectatorManager: spectators,
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
clock: () => Date.now(),
|
||||
onMatchFound: (match) => log.info({ event: "match.found", match }),
|
||||
},
|
||||
);
|
||||
|
||||
queue.addToQueue("ranked", { playerId: "alice", joinTime: Date.now(), isPremium: false, queueType: "ranked", skillRating: 1480 });
|
||||
queue.addToQueue("ranked", { playerId: "bob", joinTime: Date.now(), isPremium: false, queueType: "ranked", skillRating: 1505 });
|
||||
queue.addToQueue("ranked", { playerId: "carol", joinTime: Date.now(), isPremium: false, queueType: "ranked", skillRating: 1490 });
|
||||
|
||||
const match = engine.createMatchFromQueue(
|
||||
"ranked",
|
||||
{ minPlayers: 2, maxPlayers: 3, skillRange: 100 },
|
||||
"skill-based",
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new MatchmakingEngine(options?, deps?)`.
|
||||
- `createMatch(players, criteria, algorithm?)` — runs the algorithm,
|
||||
returns the freshly created `Match` or `null`/`NO_MATCH_FOUND`.
|
||||
- `createMatchFromQueue(queueKey, criteria, algorithm?)` — drains the
|
||||
queue, hands to `createMatch`, restores on shortfall.
|
||||
- `getMatch(matchId)` / `removeMatch(matchId)` / `getActiveMatchCount()`.
|
||||
- `dispose()` — clears every tracked match.
|
||||
- Getters: `queue`, `spectators` (the injected collaborators or
|
||||
`undefined`).
|
||||
- Algorithms: `"quick"`, `"skill-based"`, `"casual"` (current alias of
|
||||
`quick`). Invalid values raise `UNKNOWN_MATCHING_ALGORITHM`.
|
||||
- Errors: `INVALID_MATCH_CRITERIA`, `UNKNOWN_MATCHING_ALGORITHM`,
|
||||
`NO_MATCH_FOUND`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The skill-based algorithm tries each candidate as an anchor and grows
|
||||
the group greedily until either `maxPlayers` is reached or no further
|
||||
peers fit inside the `skillRange`. The match returned is the *largest*
|
||||
viable group; on ties the first-found anchor wins, matching the
|
||||
upstream behaviour. The engine deliberately does not own the
|
||||
`QueueManager`'s "remove on match" semantics — `createMatchFromQueue`
|
||||
drains via `QueueManager.findMatch` which already pops, and only
|
||||
re-inserts on shortfall to keep matchmaking idempotent under failure.
|
||||
The match object is `Object.freeze`d before storage so accidental
|
||||
mutation by listeners or downstream callers cannot poison the
|
||||
internal map. The matchId carries a stable `match-<uuid>` prefix so
|
||||
log scrapers and OTel attributes can filter cleanly without parsing.
|
||||
@@ -0,0 +1,118 @@
|
||||
# QueueManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`QueueManager` is the in-memory priority queue at the heart of the
|
||||
matchmaking pipeline. Each call to `addToQueue` inserts a `QueueRequest`
|
||||
into a per-key bucket, re-sorts the bucket by an effective priority
|
||||
composed of premium bonus, wait-time bonus, and an explicit caller
|
||||
offset, and increments observability counters. `findMatch(key, N)`
|
||||
atomically pops the top N tickets, recording wait-time histogram
|
||||
observations so production dashboards can watch matchmaking pressure
|
||||
without instrumenting callers.
|
||||
|
||||
The implementation is universal and dependency-free apart from
|
||||
`@suaveplan/types`'s `Clock`, the typed `GameServerError` registry,
|
||||
Zod (for input validation), and the OpenTelemetry `metrics` API. Every
|
||||
public mutation flows through Zod boundary validation; structural
|
||||
violations surface as `INVALID_QUEUE_REQUEST` and capacity violations
|
||||
surface as `MATCHMAKING_QUEUE_FULL`.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-key priority queues with premium-bonus, wait-time-bonus, and
|
||||
caller-supplied offsets.
|
||||
- Hard `maxQueueSize` cap with typed-error rejection.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
- Four OTel instruments — `queue_enqueue_total`, `queue_dequeue_total`,
|
||||
`queue_reject_total`, `queue_wait_duration_ms`.
|
||||
- `getStats()` snapshot of every active queue (size + oldest/newest
|
||||
join times).
|
||||
- `getQueuePosition()` for live UI ("you are 3rd in line").
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { QueueManager } from "@suaveplan/game-server/matchmaking/queue-manager";
|
||||
|
||||
const qm = new QueueManager();
|
||||
qm.addToQueue("ranked", {
|
||||
playerId: "alice",
|
||||
joinTime: Date.now(),
|
||||
isPremium: false,
|
||||
queueType: "ranked",
|
||||
});
|
||||
|
||||
const match = qm.findMatch("ranked", 4);
|
||||
if (match.length === 4) {
|
||||
console.log("found four:", match.map((p) => p.playerId));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { QueueManager } from "@suaveplan/game-server/matchmaking/queue-manager";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const qm = new QueueManager(
|
||||
{
|
||||
priorityMultiplier: 1.0,
|
||||
premiumBonus: 2_000_000,
|
||||
waitTimeBonus: 0.5,
|
||||
maxQueueSize: 5_000,
|
||||
},
|
||||
{
|
||||
clock: () => now,
|
||||
meter: metrics.getMeter("game-server.matchmaking"),
|
||||
},
|
||||
);
|
||||
|
||||
qm.addToQueue("ranked", {
|
||||
playerId: "alice",
|
||||
joinTime: now,
|
||||
isPremium: true,
|
||||
skillRating: 1480,
|
||||
queueType: "ranked",
|
||||
priority: 100,
|
||||
metadata: { region: "eu-west-1" },
|
||||
});
|
||||
|
||||
now += 30_000;
|
||||
const matched = qm.findMatch("ranked", 1);
|
||||
const stats = qm.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new QueueManager(config?: QueueConfig, deps?: QueueManagerDeps)` —
|
||||
Zod-validates the config; throws `INVALID_MATCH_CRITERIA` on a
|
||||
malformed shape.
|
||||
- `addToQueue(queueKey, request): void` — throws
|
||||
`INVALID_QUEUE_REQUEST` on malformed input,
|
||||
`MATCHMAKING_QUEUE_FULL` when the per-key cap is reached.
|
||||
- `removeFromQueue(queueKey, playerId): boolean` — `true` on hit.
|
||||
- `findMatch(queueKey, playersNeeded): readonly QueueRequest[]` —
|
||||
returns `[]` if not enough tickets are waiting; throws
|
||||
`INVALID_MATCH_CRITERIA` on a non-positive size.
|
||||
- `getQueueSize(queueKey): number` / `getQueuePosition(queueKey, playerId)`.
|
||||
- `getStats(): readonly QueueStats[]` — one entry per *known* queue key
|
||||
(preserved even after the queue empties, so dashboards see history).
|
||||
- `clear(): void` — drops every queue.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `QueueEntry` carries the precomputed priority so the
|
||||
post-insert sort runs over precomputed numbers; this keeps the worst-
|
||||
case insert at `O(n log n)` but lets `findMatch` run at `O(n)` simply
|
||||
by slicing the front. Wait-time bonus is clamped at zero when a caller
|
||||
submits a `joinTime` in the future (e.g. a clock-skew window during
|
||||
testing), which keeps the histogram well-formed and avoids a negative
|
||||
priority kick. The `getStats` snapshot retains empty queues so dashboards
|
||||
keep a row even after a churn-induced drain, mirroring how the upstream
|
||||
behaviour propagated dashboard state through Prometheus scrapes.
|
||||
@@ -0,0 +1,111 @@
|
||||
# SkillMatcher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SkillMatcher` picks the closest-rated viable opponent for a queue
|
||||
seeker out of a candidate pool, widening its acceptance band as the
|
||||
seeker waits. It is the matchmaking primitive that powers
|
||||
`MatchmakingEngine`'s `"skill-based"` algorithm but is also useable in
|
||||
isolation when callers want fine-grained control over rating math, such
|
||||
as during round-by-round tournament seeding or AI bot pairing.
|
||||
|
||||
Two pieces of math drive the matcher: the *effective range* (base
|
||||
`ratingRange` plus `waitTimeBonus * waitMinutes`, capped at `maxRange`)
|
||||
and the *score* (by default `Math.abs(seeker.rating - candidate.rating)`,
|
||||
overridable via the `score` dep so callers can plug in Glicko or
|
||||
TrueSkill). Tie-breaks across candidates with identical scores can be
|
||||
made deterministic by injecting an `Rng`.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable base range, per-minute wait-time bonus, and hard ceiling.
|
||||
- Pluggable scoring function (`SkillScoringFn`) for non-Elo metrics.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
- Injectable `Rng` for deterministic tie-breaks.
|
||||
- Two OTel instruments: a `skill_find_total{outcome}` counter and a
|
||||
`skill_search_duration_ms` histogram tagged by outcome.
|
||||
- All inputs validated through `SkillEntrySchema` and
|
||||
`SkillMatcherConfigSchema`; failures surface as typed
|
||||
`GameServerError(INVALID_SKILL_SCORE | INVALID_MATCH_CRITERIA)`.
|
||||
- Self-matches filtered automatically.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SkillMatcher } from "@suaveplan/game-server/matchmaking/skill-matcher";
|
||||
|
||||
const matcher = new SkillMatcher({ ratingRange: 200 });
|
||||
const opponent = matcher.findMatch(
|
||||
{ id: "seek", rating: 1500, waitingSince: 0 },
|
||||
[
|
||||
{ id: "a", rating: 1400, waitingSince: 0 },
|
||||
{ id: "b", rating: 1510, waitingSince: 0 },
|
||||
],
|
||||
);
|
||||
console.log(opponent?.id); // "b"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SkillMatcher } from "@suaveplan/game-server/matchmaking/skill-matcher";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
const seededRng: Rng = {
|
||||
nextUint32: () => 0xdeadbeef,
|
||||
nextFloat: () => 0.42,
|
||||
};
|
||||
const matcher = new SkillMatcher(
|
||||
{ ratingRange: 100, waitTimeBonus: 50, maxRange: 800 },
|
||||
{
|
||||
clock: () => 1_000_000,
|
||||
rng: seededRng,
|
||||
score: (seeker, c) =>
|
||||
Math.abs(seeker.rating - c.rating) +
|
||||
(c.id.startsWith("bot:") ? 75 : 0), // prefer humans
|
||||
},
|
||||
);
|
||||
|
||||
const match = matcher.findMatch(
|
||||
{ id: "seek", rating: 1500, waitingSince: 1_000_000 - 90_000 },
|
||||
[
|
||||
{ id: "bot:1", rating: 1505, waitingSince: 0 },
|
||||
{ id: "human:1", rating: 1520, waitingSince: 0 },
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SkillMatcher(config?: SkillMatcherConfig, deps?: SkillMatcherDeps)` —
|
||||
validates the config via Zod; throws `INVALID_MATCH_CRITERIA` on a
|
||||
malformed shape.
|
||||
- `findMatch(seeker, candidates): SkillEntry | null` — returns the best
|
||||
candidate by score, or `null` when no candidate falls inside the
|
||||
effective range. Throws `INVALID_SKILL_SCORE` on a non-finite rating
|
||||
or empty id.
|
||||
- `effectiveRange(seeker): number` — exposes the live tolerance for the
|
||||
given seeker, useful for diagnostic overlays.
|
||||
- Config: `ratingRange` (default 200), `waitTimeBonus` (default 50),
|
||||
`maxRange` (default `+Infinity`).
|
||||
- Deps: `clock` (default `Date.now`), `rng` (optional), `meter`
|
||||
(optional), `score` (optional override).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The matcher iterates the candidate pool exactly once, scoring eligible
|
||||
entries inline. Ineligible candidates are dropped before sorting so the
|
||||
sort never sees more elements than fit inside `effectiveRange`. Tie
|
||||
breaking is opt-in: without an `Rng` the first tied candidate wins
|
||||
(matching the upstream behaviour); with an `Rng` the matcher samples a
|
||||
uniform integer in `[0, tied.length)` so test runs are reproducible
|
||||
when callers seed the generator. The default scoring function is
|
||||
absolute-delta; a `score` override is the seam for ranked-pool
|
||||
modifiers (faction balancing, anti-smurf, region preference). The
|
||||
histogram captures the full method body's duration including pool
|
||||
iteration plus sort, so the upper percentiles are useful for spotting
|
||||
hot-loop regressions.
|
||||
@@ -0,0 +1,115 @@
|
||||
# SpectatorManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpectatorManager` owns the live spectator roster for every active game
|
||||
session. Spectators join a session through `addSpectator(sessionId,
|
||||
spectatorId, permissions)`, which enforces a per-session cap, validates
|
||||
permissions via Zod, and notifies caller-supplied listeners
|
||||
synchronously. The manager is universal and does not couple to a
|
||||
specific event bus — callers wire `onSpectatorJoined` and
|
||||
`onSpectatorLeft` callbacks through the deps bag, so the same component
|
||||
can feed an `EventBusLike`, a WebSocket fan-out, or analytics
|
||||
instrumentation without code changes.
|
||||
|
||||
The module's data shape mirrors the upstream behaviour (per-session
|
||||
`Map<spectatorId, Spectator>`) but tightens every boundary: empty ids
|
||||
and malformed permission objects fail with a typed
|
||||
`INVALID_SPECTATOR_REQUEST`, hitting the cap fails with
|
||||
`SPECTATOR_LIMIT_REACHED`, and three OTel counters track joins, leaves,
|
||||
and rejections (with reason attributes) so production dashboards can
|
||||
distinguish "session is at capacity" from "client sent garbage".
|
||||
|
||||
## Features
|
||||
|
||||
- Per-session spectator roster with a configurable cap (default 100).
|
||||
- Synchronous join/leave listener hooks, optional.
|
||||
- Zod-validated `SpectatorPermissions` boundary.
|
||||
- Three OTel counters — `spectator_join_total`, `spectator_leave_total`,
|
||||
`spectator_reject_total{reason}`.
|
||||
- Bulk `removeAllSpectators` for session shutdown.
|
||||
- Injectable `Clock` for deterministic `joinedAt` timestamps.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SpectatorManager } from "@suaveplan/game-server/matchmaking/spectator-manager";
|
||||
|
||||
const sm = new SpectatorManager();
|
||||
sm.addSpectator("session-42", "viewer-alice", {
|
||||
canChat: true,
|
||||
canViewPrivateInfo: false,
|
||||
isVIP: false,
|
||||
});
|
||||
console.log(sm.getSpectatorCount("session-42")); // 1
|
||||
sm.removeSpectator("session-42", "viewer-alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SpectatorManager } from "@suaveplan/game-server/matchmaking/spectator-manager";
|
||||
|
||||
const sm = new SpectatorManager(
|
||||
{ maxSpectatorsPerSession: 500 },
|
||||
{
|
||||
clock: () => Date.now(),
|
||||
onSpectatorJoined: (sessionId, spectator) =>
|
||||
networkBus.publish(`session:${sessionId}:roster`, {
|
||||
kind: "join",
|
||||
spectatorId: spectator.spectatorId,
|
||||
permissions: spectator.permissions,
|
||||
}),
|
||||
onSpectatorLeft: (sessionId, spectatorId) =>
|
||||
networkBus.publish(`session:${sessionId}:roster`, {
|
||||
kind: "leave",
|
||||
spectatorId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
sm.addSpectator("session-42", "viewer-vip-001", {
|
||||
canChat: true,
|
||||
canViewPrivateInfo: true,
|
||||
isVIP: true,
|
||||
});
|
||||
|
||||
// On session shutdown:
|
||||
sm.removeAllSpectators("session-42");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SpectatorManager(config?, deps?)` — Zod-validates config; throws
|
||||
`INVALID_MATCH_CRITERIA` on a malformed shape.
|
||||
- `addSpectator(sessionId, spectatorId, permissions): Spectator` —
|
||||
throws `INVALID_SPECTATOR_REQUEST` on empty ids, malformed
|
||||
permissions, or a duplicate spectator; throws
|
||||
`SPECTATOR_LIMIT_REACHED` when the cap is hit. Returns the created
|
||||
`Spectator` record.
|
||||
- `removeSpectator(sessionId, spectatorId): boolean` — `true` on hit.
|
||||
- `removeAllSpectators(sessionId): number` — returns the count removed.
|
||||
- `getSpectators(sessionId): readonly Spectator[]`.
|
||||
- `getSpectatorCount(sessionId): number` /
|
||||
`isSpectator(sessionId, spectatorId): boolean`.
|
||||
- `getSpectatorPermissions(sessionId, spectatorId)` — `null` on miss.
|
||||
- `getSessionCount(): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`removeAllSpectators` increments the `spectator_leave_total` counter
|
||||
once per spectator removed and fires the listener for each one before
|
||||
deleting the bucket, so consumers get a clean wave of leave
|
||||
notifications during session shutdown. The internal data structure
|
||||
deliberately exposes spectators through `Array.from(map.values())`
|
||||
rather than a live `MapIterator` to keep callers from mutating the
|
||||
internal map by accident. The `onSpectatorJoined` callback receives the
|
||||
fully-constructed `Spectator` (id + `joinedAt` + permissions) so a
|
||||
network fan-out caller does not need a second lookup to broadcast the
|
||||
join. Duplicate-spectator detection runs *before* cap enforcement so an
|
||||
already-attached viewer never accidentally fails with
|
||||
`SPECTATOR_LIMIT_REACHED` when the session happens to be full.
|
||||
@@ -0,0 +1,105 @@
|
||||
# WinnerStaysOnManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WinnerStaysOnManager` models the classic arcade format where the round
|
||||
winner keeps their seat while the next challenger steps up. It owns three
|
||||
pieces of state: the current champion id (or `null` when no result has
|
||||
been recorded), the champion's consecutive win streak, and a FIFO queue
|
||||
of pending challenger ids. Match results are streamed in via
|
||||
`recordResult(winnerId, loserId)`; the manager tracks streak growth,
|
||||
detects throne transfers, and exposes `isChampionRotationDue` once the
|
||||
streak hits the configured `maxWinStreak` cap.
|
||||
|
||||
The module is universal and dependency-free apart from the typed
|
||||
`GameServerError` registry and the OpenTelemetry `metrics` API. It is
|
||||
designed to plug into `MatchmakingEngine` when the caller wants
|
||||
champion-style flow rather than batch matchmaking, but it works equally
|
||||
well as a standalone primitive for split-screen tournaments, party-game
|
||||
session loops, or daily-leaderboard rotations.
|
||||
|
||||
## Features
|
||||
|
||||
- Tracks the current champion plus a deduplicated challenger queue.
|
||||
- Distinguishes "retain" vs "takeover" outcomes when recording results.
|
||||
- Surfaces a rotation-due flag once the win streak hits a configurable
|
||||
cap (default 5); the caller decides when to actually rotate.
|
||||
- `forceRotateChampion()` cleanly dethrones the current champion and
|
||||
promotes the next challenger.
|
||||
- Two OTel counters (`winner_result_total{outcome}` and
|
||||
`winner_rotation_total`) for production observability.
|
||||
- Zod-validated configuration via the typed
|
||||
`GameServerError(INVALID_MATCH_CRITERIA)` path.
|
||||
- Reset path for replay tests / scenario harnesses.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { WinnerStaysOnManager } from "@suaveplan/game-server/matchmaking/winner-stays-on";
|
||||
|
||||
const champ = new WinnerStaysOnManager();
|
||||
champ.recordResult("alice", "bob");
|
||||
champ.recordResult("alice", "carol");
|
||||
console.log(champ.champion); // "alice"
|
||||
console.log(champ.winStreak); // 2
|
||||
console.log(champ.challengerCount); // 2 (bob, carol)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { WinnerStaysOnManager } from "@suaveplan/game-server/matchmaking/winner-stays-on";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const wso = new WinnerStaysOnManager(
|
||||
{ maxWinStreak: 3 },
|
||||
metrics.getMeter("party-game.tournament")
|
||||
);
|
||||
|
||||
wso.addChallenger("dave");
|
||||
wso.recordResult("alice", "bob");
|
||||
wso.recordResult("alice", "carol");
|
||||
wso.recordResult("alice", "dave");
|
||||
if (wso.isChampionRotationDue) {
|
||||
const next = wso.forceRotateChampion();
|
||||
console.log(`alice rotated out, next champion: ${next}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new WinnerStaysOnManager(config?: WinnerStaysOnConfig, meter?: Meter)` —
|
||||
validates the config via Zod. Throws
|
||||
`GameServerError(INVALID_MATCH_CRITERIA)` on a non-positive
|
||||
`maxWinStreak`.
|
||||
- `recordResult(winnerId: string, loserId: string): void` — throws
|
||||
`INVALID_QUEUE_REQUEST` if `winnerId` is empty.
|
||||
- `addChallenger(playerId: string): void` — throws
|
||||
`INVALID_QUEUE_REQUEST` if `playerId` is empty.
|
||||
- `nextChallenger(): string | null` — pops the front of the queue.
|
||||
- `forceRotateChampion(): string | null` — pops a challenger into the
|
||||
champion slot, returns it (or `null` if queue empty); increments the
|
||||
`winner_rotation_total` counter.
|
||||
- `reset(): void` — drops all state.
|
||||
- `getStats(): WinnerStaysOnStats` — snapshot of counters.
|
||||
- Getters: `champion`, `winStreak`, `challengerCount`,
|
||||
`isChampionRotationDue`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class deliberately couples nothing to wall-clock time; the only
|
||||
external surface is the OpenTelemetry meter, which falls back to
|
||||
`metrics.getMeter("@suaveplan/game-server")` when the caller does not
|
||||
inject one. The challenger queue is a plain `string[]` rather than a
|
||||
`Set` because order matters: a deduplicating filter on insert keeps
|
||||
both behaviours correct without paying the iteration cost on every
|
||||
read. The `outcome` attribute on `winner_result_total` is `"retain"`
|
||||
for streak growth and `"takeover"` for throne transfer; analytics
|
||||
dashboards can therefore plot churn-rate without parsing log lines.
|
||||
`forceRotateChampion()` resets `winStreak` to zero so the very next
|
||||
`recordResult` re-establishes a baseline rather than appearing to be a
|
||||
second consecutive win.
|
||||
@@ -0,0 +1,124 @@
|
||||
# NetworkAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`NetworkAdapter` is the unified bridge between the `@suaveplan/game-server`
|
||||
framework and any concrete network transport (WebSocket, WebRTC,
|
||||
long-polling, …). It owns the transport-binding lifecycle, validates every
|
||||
outgoing frame against a Zod schema, fans inbound frames out to multiple
|
||||
subscribed handlers without letting a single faulty listener poison the
|
||||
delivery loop, and publishes OpenTelemetry counters for sent / received /
|
||||
error volumes.
|
||||
|
||||
The adapter is dependency-injected through a structural `Transport`
|
||||
contract so test code can drive it with an in-process fake — no `vi.mock`
|
||||
needed and no entanglement with a specific platform API such as
|
||||
`globalThis.WebSocket`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-initialisation guard with the typed code
|
||||
`GAME_SERVER.TRANSPORT_ALREADY_INITIALIZED`.
|
||||
- Per-send guards (`TRANSPORT_NOT_INITIALIZED`, `TRANSPORT_NOT_CONNECTED`,
|
||||
`INVALID_MESSAGE_FORMAT`).
|
||||
- Zod `NetworkMessageSchema` validates outbound and inbound frames.
|
||||
- Multi-subscriber `onMessage` with isolated error capture per handler.
|
||||
- OTel counters: `game_server.network.messages_sent_total`,
|
||||
`…messages_received_total`, `…errors_total` (attribute `kind` ∈
|
||||
`{transport, parse, handler}`).
|
||||
- Optional injectable `Clock`, `AdapterLogger`, and `AdapterEventBus`.
|
||||
- Stats snapshot (`getStats`) for monitoring dashboards and tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NetworkAdapter,
|
||||
type Transport,
|
||||
} from "@suaveplan/game-server/network/network-adapter";
|
||||
|
||||
const adapter = new NetworkAdapter();
|
||||
await adapter.initialize("websocket", myTransport, "wss://game.example/");
|
||||
|
||||
const off = adapter.onMessage((msg) => {
|
||||
if (msg.type === "move") {
|
||||
applyMove(msg.payload);
|
||||
}
|
||||
});
|
||||
|
||||
await adapter.send({ type: "ping", payload: {}, timestamp: 0 });
|
||||
|
||||
off();
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { NetworkAdapter } from "@suaveplan/game-server/network/network-adapter";
|
||||
import type { FakeClock } from "@suaveplan/testing";
|
||||
|
||||
const fake: FakeClock = createFakeClock();
|
||||
const adapter = new NetworkAdapter({
|
||||
clock: () => fake.now(),
|
||||
logger: {
|
||||
debug: (m, ctx) => log("debug", m, ctx),
|
||||
info: (m, ctx) => log("info", m, ctx),
|
||||
warn: (m, ctx) => log("warn", m, ctx),
|
||||
error: (m, ctx) => log("error", m, ctx),
|
||||
},
|
||||
events: {
|
||||
emit: (type, payload) => bus.publish(type, payload),
|
||||
},
|
||||
});
|
||||
|
||||
const handler = (msg: NetworkMessage) => {
|
||||
throw new Error("rogue listener");
|
||||
};
|
||||
adapter.onMessage(handler);
|
||||
// Subsequent inbound frames still reach every other handler. The error
|
||||
// is counted under `game_server.network.errors_total{kind="handler"}`.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class NetworkAdapter`
|
||||
|
||||
- `constructor(deps?: NetworkAdapterDeps)` — optional `clock`, `logger`,
|
||||
`events`.
|
||||
- `initialize(transportType, transport, url, options?): Promise<void>`
|
||||
- `send(message: NetworkMessage): Promise<void>` — fills `timestamp` from
|
||||
the injected clock when omitted.
|
||||
- `onMessage(handler): () => void` — returns unsubscribe thunk.
|
||||
- `disconnect(): Promise<void>`
|
||||
- `dispose(): Promise<void>` — disconnect and clear handlers.
|
||||
- `isConnected(): boolean`
|
||||
- `getTransportType(): TransportType | null`
|
||||
- `getStats(): NetworkAdapterStats`
|
||||
|
||||
### `interface Transport`
|
||||
|
||||
Structural contract: `connect`, `disconnect`, `send`, `onMessage`,
|
||||
`onError`, `onConnect`, `onDisconnect`, `isConnected`.
|
||||
|
||||
### `NetworkMessageSchema`
|
||||
|
||||
`z.object({ type, payload, timestamp, sessionId?, playerId? })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Transport` callbacks are registered in `initialize` before `connect`, so
|
||||
synchronous fire-on-open transports cannot miss the first event. The
|
||||
inbound delivery loop re-validates every frame so that malformed wire
|
||||
data from any transport surfaces as a single typed counter increment
|
||||
rather than escaping into application code. Per-handler error capture
|
||||
uses a synchronous `try/catch` because the structural contract is
|
||||
synchronous — async handlers are still supported but their rejection is
|
||||
the application's responsibility. The OTel counters are module-level
|
||||
exports so multiple adapter instances share the same metric stream;
|
||||
attributes (`type`, `transportType`, `kind`) provide per-instance
|
||||
breakdowns at query time.
|
||||
@@ -0,0 +1,119 @@
|
||||
# ReconnectionManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ReconnectionManager` drives transport reconnection with exponential
|
||||
backoff and configurable jitter for the `@suaveplan/game-server` package.
|
||||
It is transport-agnostic — the application supplies a `reconnect`
|
||||
callback and the manager schedules calls into it through an injectable
|
||||
`TimersLike`, advancing the backoff envelope on each failure and resetting
|
||||
on each success.
|
||||
|
||||
The implementation delegates the backoff calculation to
|
||||
`@suaveplan/retry`'s `createExponentialBackoff` + `applyJitter` so it
|
||||
shares the canonical jitter modes (`none`, `full`, `equal`,
|
||||
`decorrelated`) with every other retrying component in the workspace.
|
||||
State is fully observable through `getState()`, OTel counters
|
||||
(`game_server.network.reconnect_attempts_total`), and a histogram
|
||||
(`reconnect_delay_ms`).
|
||||
|
||||
## Features
|
||||
|
||||
- Exponential backoff with cap and jitter via `@suaveplan/retry`.
|
||||
- Injectable `Clock` and `TimersLike` for deterministic tests.
|
||||
- Configurable `maxAttempts` (0 = unlimited), `initialDelayMs`,
|
||||
`maxDelayMs`, `backoffBase`, `jitter` mode.
|
||||
- Typed errors: `RECONNECT_IN_PROGRESS`, `RECONNECT_EXHAUSTED`.
|
||||
- OTel counter with `outcome ∈ {success, failure, exhausted}`; histogram
|
||||
of scheduled delays.
|
||||
- Optional logger and event bus.
|
||||
- Hot config updates via `updateConfig`.
|
||||
- Idempotent `stop`, `reset`, `dispose`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { ReconnectionManager } from "@suaveplan/game-server/network/reconnection-manager";
|
||||
|
||||
const manager = new ReconnectionManager({
|
||||
reconnect: () => adapter.initialize("websocket", transport, url),
|
||||
clock: () => Date.now(),
|
||||
timers: {
|
||||
setTimer: (cb, ms) =>
|
||||
globalThis.setTimeout(cb, ms) as unknown as number,
|
||||
clearTimer: (h) => globalThis.clearTimeout(h as unknown as ReturnType<typeof setTimeout>),
|
||||
},
|
||||
});
|
||||
|
||||
manager.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { ReconnectionManager } from "@suaveplan/game-server/network/reconnection-manager";
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
|
||||
const fake = new FakeClock();
|
||||
const manager = new ReconnectionManager({
|
||||
reconnect: async () => {
|
||||
if (attemptsBeforeSuccess > 0) {
|
||||
attemptsBeforeSuccess -= 1;
|
||||
throw new Error("transient");
|
||||
}
|
||||
},
|
||||
clock: () => fake.now(),
|
||||
timers: fake.timers(),
|
||||
config: {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 2_000,
|
||||
backoffBase: 2,
|
||||
jitter: "none",
|
||||
},
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await fake.advance(5_000);
|
||||
expect(manager.getState().isReconnecting).toBe(false);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class ReconnectionManager`
|
||||
|
||||
- `constructor(deps: ReconnectionDeps)`
|
||||
- `start(): void` — begin reconnection.
|
||||
- `stop(): void` — cancel pending timer.
|
||||
- `forceReconnect(): void` — reset delay to initial and restart.
|
||||
- `reset(): void` — stop and clear counters.
|
||||
- `updateConfig(partial): void` — merge new options through Zod.
|
||||
- `getState(): ReconnectionSnapshot`
|
||||
- `dispose(): void`
|
||||
|
||||
### `ReconnectionConfigSchema`
|
||||
|
||||
`z.object({ maxAttempts, initialDelayMs, maxDelayMs, backoffBase, jitter })`
|
||||
with defaults `10 / 1000 / 30000 / 2 / "equal"`.
|
||||
|
||||
### `interface ReconnectionDeps`
|
||||
|
||||
`{ reconnect, clock, timers, logger?, events?, config? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`createExponentialBackoff` is configured with `jitter: "none"` so the
|
||||
function returns the raw envelope; jitter is applied at scheduling time
|
||||
through `applyJitter` to keep the envelope in `nextDelayMs` predictable
|
||||
across `getState` snapshots. Failed attempts advance `nextDelayMs` by
|
||||
calling the strategy with `attempts + 1`, matching the "next attempt"
|
||||
semantics expected by callers reading `getState().nextDelayMs` between
|
||||
attempts. The manager checks `isReconnecting` after each `await
|
||||
reconnect()` so a `stop()` invoked during an in-flight retry takes
|
||||
effect without an extra scheduling cycle. OTel instruments are
|
||||
module-level so multiple instances share the same metric stream,
|
||||
identified by the `outcome` attribute.
|
||||
@@ -0,0 +1,100 @@
|
||||
# WebRTCTransport
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebRTCTransport` is the game-server-tier `Transport` adapter for WebRTC
|
||||
data channels. It wraps an underlying client conforming to the
|
||||
structural `UnderlyingWebRTC` interface and exposes the unified
|
||||
`Transport` contract consumed by `NetworkAdapter`.
|
||||
|
||||
In production the underlying client is satisfied by a thin factory
|
||||
around `@suaveplan/network-transport-webrtc` (the WebRTC client
|
||||
with peer-connection lifecycle, ICE candidate handling, and STUN
|
||||
configuration). In tests the same contract is satisfied by an in-process
|
||||
fake double — no `vi.mock`, no real RTCPeerConnection.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural `UnderlyingWebRTC` boundary keeps the wrapper transport-
|
||||
agnostic.
|
||||
- JSON framing of every `NetworkMessage`.
|
||||
- Typed errors (`TRANSPORT_NOT_CONNECTED`, `TRANSPORT_NOT_INITIALIZED`,
|
||||
`INVALID_MESSAGE_FORMAT`).
|
||||
- Inbound parse failures surface through the `onError` channel.
|
||||
- Pass-through signalling helpers (`addIceCandidate`, `applyAnswer`) so
|
||||
application code can drive signalling without reaching into the
|
||||
underlying client directly.
|
||||
- Optional `options` bag forwarded to the underlying client (ICE servers,
|
||||
signalling-server URL, …).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { WebRTCTransport } from "@suaveplan/game-server/network/webrtc-transport";
|
||||
|
||||
const transport = new WebRTCTransport({ client: makeWebrtcClient() });
|
||||
transport.onMessage((m) => handleFrame(m));
|
||||
transport.onConnect(() => console.log("rtc open"));
|
||||
await transport.connect("signal://room/42", {
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
await transport.send({ type: "join", payload: {}, timestamp: 0 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { WebRTCTransport } from "@suaveplan/game-server/network/webrtc-transport";
|
||||
import type { UnderlyingWebRTC } from "@suaveplan/game-server/network/webrtc-transport";
|
||||
|
||||
const fake: UnderlyingWebRTC = {
|
||||
open: async () => undefined,
|
||||
close: async () => undefined,
|
||||
sendText: async () => undefined,
|
||||
onText: (h) => (textHandler = h),
|
||||
onOpen: (h) => (openHandler = h),
|
||||
onClose: (h) => (closeHandler = h),
|
||||
onError: (h) => (errorHandler = h),
|
||||
isOpen: () => true,
|
||||
addIceCandidate: async (c) => candidates.push(c),
|
||||
applyAnswer: async (a) => (lastAnswer = a),
|
||||
};
|
||||
|
||||
const transport = new WebRTCTransport({ client: fake });
|
||||
await transport.connect("signal://x", {});
|
||||
await transport.addIceCandidate({ candidate: "..." });
|
||||
await transport.applyAnswer({ type: "answer", sdp: "..." });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class WebRTCTransport implements Transport`
|
||||
|
||||
- `constructor(deps: { client: UnderlyingWebRTC })`
|
||||
- `connect(url, options?): Promise<void>` — `options` forwarded.
|
||||
- `disconnect(): Promise<void>`
|
||||
- `send(message): Promise<void>`
|
||||
- `onMessage`, `onError`, `onConnect`, `onDisconnect` — single-slot.
|
||||
- `isConnected(): boolean`
|
||||
- `addIceCandidate(candidate): Promise<void>` — throws if unsupported.
|
||||
- `applyAnswer(answer): Promise<void>` — throws if unsupported.
|
||||
|
||||
### `interface UnderlyingWebRTC`
|
||||
|
||||
`open / close / sendText / onText / onOpen / onClose / onError /
|
||||
isOpen` (+ optional `addIceCandidate`, `applyAnswer`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Signalling lives in the underlying client because it is highly
|
||||
deployment-specific (in-band over WebSocket, out-of-band over HTTP, etc).
|
||||
The wrapper provides typed pass-through helpers for `addIceCandidate`
|
||||
and `applyAnswer` so callers do not need a back-channel reference to the
|
||||
underlying client — keeping the dependency graph one-directional. As
|
||||
with `WebSocketTransport`, parse failures route through `onError`
|
||||
rather than throwing because `Transport` has no inbound back-pressure
|
||||
channel; the adapter's parse counter is where these are observed.
|
||||
@@ -0,0 +1,94 @@
|
||||
# WebSocketTransport
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebSocketTransport` is the game-server-tier `Transport` adapter for
|
||||
WebSocket connections. It wraps an underlying client conforming to the
|
||||
structural `UnderlyingWebSocket` interface and exposes the unified
|
||||
`Transport` contract consumed by `NetworkAdapter`.
|
||||
|
||||
In production the underlying client is satisfied by a thin factory
|
||||
around `@suaveplan/network-transport-websockets` (the WebSocket
|
||||
client with framing, heartbeat, and backpressure). In tests the same
|
||||
contract is satisfied by an in-process fake double, so we never need
|
||||
`vi.mock` or a live socket to exercise every code path.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural `UnderlyingWebSocket` boundary keeps the wrapper transport-
|
||||
agnostic.
|
||||
- JSON framing of every `NetworkMessage`.
|
||||
- Typed errors (`TRANSPORT_NOT_CONNECTED`, `INVALID_MESSAGE_FORMAT`).
|
||||
- Inbound parse failures surface through the `onError` channel rather
|
||||
than escaping into application code.
|
||||
- Optional `protocols` option passed through to the underlying client.
|
||||
- Synchronous handler registration so callers can wire callbacks before
|
||||
awaiting `connect`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { WebSocketTransport } from "@suaveplan/game-server/network/websocket-transport";
|
||||
|
||||
const transport = new WebSocketTransport({ client: makeNativeClient() });
|
||||
transport.onMessage((m) => handleFrame(m));
|
||||
transport.onConnect(() => console.log("ws open"));
|
||||
await transport.connect("wss://game.example/");
|
||||
await transport.send({ type: "join", payload: {}, timestamp: 0 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { WebSocketTransport } from "@suaveplan/game-server/network/websocket-transport";
|
||||
import type { UnderlyingWebSocket } from "@suaveplan/game-server/network/websocket-transport";
|
||||
|
||||
const fake: UnderlyingWebSocket = {
|
||||
open: async () => undefined,
|
||||
close: async () => undefined,
|
||||
sendText: async () => undefined,
|
||||
onText: (h) => (textHandler = h),
|
||||
onOpen: (h) => (openHandler = h),
|
||||
onClose: (h) => (closeHandler = h),
|
||||
onError: (h) => (errorHandler = h),
|
||||
isOpen: () => true,
|
||||
};
|
||||
|
||||
const transport = new WebSocketTransport({ client: fake });
|
||||
transport.onMessage((m) => seen.push(m));
|
||||
await transport.connect("wss://x/", { protocols: ["v1", "v2"] });
|
||||
textHandler('{"type":"ping","payload":{},"timestamp":1}');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class WebSocketTransport implements Transport`
|
||||
|
||||
- `constructor(deps: { client: UnderlyingWebSocket })`
|
||||
- `connect(url, options?): Promise<void>` — `options.protocols` forwarded.
|
||||
- `disconnect(): Promise<void>`
|
||||
- `send(message): Promise<void>` — throws typed error when disconnected.
|
||||
- `onMessage(handler)`, `onError(handler)`, `onConnect(handler)`,
|
||||
`onDisconnect(handler)` — single-slot registration.
|
||||
- `isConnected(): boolean`
|
||||
|
||||
### `interface UnderlyingWebSocket`
|
||||
|
||||
`open / close / sendText / onText / onOpen / onClose / onError /
|
||||
isOpen`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapper installs every callback on the underlying client in the
|
||||
constructor so that frames arriving immediately after `open()` (the
|
||||
common case with a hot reconnect) cannot be lost. Outbound serialisation
|
||||
catches `JSON.stringify` failures (circular references) and rethrows
|
||||
them as `GameServerError(INVALID_MESSAGE_FORMAT)`. Inbound parse
|
||||
failures route through `onError` rather than throwing because the
|
||||
unified `Transport` contract has no inbound back-pressure channel — the
|
||||
adapter's parse counter is the place where these are observed in
|
||||
production.
|
||||
116
docs/gaming/games/game-server/src/observability/otel.md
Normal file
116
docs/gaming/games/game-server/src/observability/otel.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Observability — game-server OTel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the three OpenTelemetry instruments the `@suaveplan/game-server` engine, loop, and plugin manager emit. Centralising them here keeps meter construction at a single call site so the polyfilled in-memory `MeterProvider` installed by `tests/setup/polyfills.ts` is the only provider that ever services the package, regardless of which feature module imports first.
|
||||
|
||||
The three instruments map one-to-one to the requirements in the game-server specification: lifecycle counters for engine and plugin state machines, and a tick-duration histogram for the deterministic game loop. Each emission carries low-cardinality attributes (operation name, mode, outcome) so dashboards can aggregate without paying cardinality cost.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy `meter` binding via `metrics.getMeter("@suaveplan/game-server")` — resolves the globally registered MeterProvider on first access.
|
||||
- Three `record*` helpers wrapping `counter.add` / `histogram.record` with structured attribute objects.
|
||||
- Outcome tagging on lifecycle counters (`ok` / `error`) so a single counter covers both success and failure paths.
|
||||
- Zero runtime overhead when no MeterProvider is registered — the API returns no-op instruments.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { recordEngineLifecycle } from "../observability/otel.js";
|
||||
|
||||
await engine.start();
|
||||
recordEngineLifecycle("start", "ok");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { recordLoopTickDuration } from "../observability/otel.js";
|
||||
|
||||
const start = clock();
|
||||
await runTickPhases();
|
||||
const elapsed = clock() - start;
|
||||
recordLoopTickDuration(elapsed, "tick-based");
|
||||
```
|
||||
|
||||
Combined with the test polyfill's `flushTestMetrics()`, tests can assert
|
||||
that exactly one sample was emitted with the expected attribute set.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `engineLifecycleCounter: Counter`
|
||||
|
||||
Counter `game_server.engine.lifecycle_total`. Increment via
|
||||
`recordEngineLifecycle(op, outcome?)`.
|
||||
|
||||
### `loopTickDurationHistogram: Histogram`
|
||||
|
||||
Histogram `game_server.loop.tick_duration_ms` (unit `ms`). Record via
|
||||
`recordLoopTickDuration(durationMs, mode)`.
|
||||
|
||||
### `pluginLifecycleCounter: Counter`
|
||||
|
||||
Counter `game_server.plugin.lifecycle_total`. Increment via
|
||||
`recordPluginLifecycle(op, outcome?)`.
|
||||
|
||||
### `recordEngineLifecycle(op, outcome)`
|
||||
|
||||
`op` ∈ `{start, stop, dispose}`; `outcome` ∈ `{ok, error}` (explicit — no default).
|
||||
|
||||
### `recordLoopTickDuration(durationMs, mode)`
|
||||
|
||||
Records one sample tagged with `mode` ∈ `{tick-based, event-driven, hybrid}`.
|
||||
|
||||
### `recordPluginLifecycle(op, outcome)`
|
||||
|
||||
`op` ∈ `{install, uninstall, enable, disable}`; `outcome` ∈ `{ok, error}` (explicit).
|
||||
|
||||
### `sessionLifecycleCounter: Counter`
|
||||
|
||||
Counter `game_server.session.lifecycle_total`. Increment via
|
||||
`recordSessionLifecycle(from, to, outcome)`. Tags carry low-cardinality
|
||||
state literals (`lobby`/`active`/`ended`).
|
||||
|
||||
### `sessionDwellHistogram: Histogram`
|
||||
|
||||
Histogram `game_server.session.dwell_ms` (unit `ms`). Record via
|
||||
`recordSessionDwell(state, durationMs)` whenever a session leaves a state.
|
||||
|
||||
### `playerLifecycleCounter: Counter`
|
||||
|
||||
Counter `game_server.player.lifecycle_total`. Increment via
|
||||
`recordPlayerLifecycle(op, outcome)` where `op` ∈
|
||||
`{connect, disconnect, reconnect, timeout, heartbeat}`.
|
||||
|
||||
### `recordSessionLifecycle(from, to, outcome)`
|
||||
|
||||
Records one transition; both `from` and `to` are emitted as attributes so
|
||||
dashboards can aggregate by either side of the transition.
|
||||
|
||||
### `recordSessionDwell(state, durationMs)`
|
||||
|
||||
Records one sample tagged with the state being left.
|
||||
|
||||
### `recordPlayerLifecycle(op, outcome)`
|
||||
|
||||
`op` ∈ `{connect, disconnect, reconnect, timeout, heartbeat}`; `outcome` ∈
|
||||
`{ok, error}` (explicit — no default).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The OTel meter is resolved at module import — this means the global
|
||||
`MeterProvider` must be installed BEFORE any source module is imported, or
|
||||
the meter binds to the API's default no-op provider and never reattaches.
|
||||
The package's `tests/setup/polyfills.ts` enforces this ordering by installing
|
||||
an `InMemoryMetricExporter`-backed `MeterProvider` at the very top of the
|
||||
file, before any `@suaveplan/game-server` symbol is referenced.
|
||||
|
||||
The counters use low-cardinality attributes only. `op` is a closed string
|
||||
enum, `outcome` is a closed two-value enum, and `mode` is the closed
|
||||
`GameLoopMode` enum. There is no per-plugin-name labelling on the plugin
|
||||
counter — the plugin name lives in the structured-logger event, not the
|
||||
metric attribute set.
|
||||
@@ -0,0 +1,127 @@
|
||||
# NetworkTierAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`NetworkTierAdapter` bridges the game-server's matchmaking + session
|
||||
layer to the `@suaveplan/network` tier model. Each tier (host /
|
||||
sub-host / player / spectator) carries an index, name, node-type, and
|
||||
capacity limits; the adapter ingests those tier definitions at
|
||||
construction time and accepts per-node capability assessments (CPU,
|
||||
memory, bandwidth, composite score) through
|
||||
`registerNodeCapabilities`. It then exposes lookups for optimal-tier
|
||||
selection and load-balancing recommendations that downstream routers
|
||||
or autoscalers can consume.
|
||||
|
||||
The adapter is universal and dependency-free apart from
|
||||
`@suaveplan/error`, Zod (for boundary validation), and the
|
||||
OpenTelemetry `metrics` API. Tier-list construction and capability
|
||||
registration both validate inputs strictly: an empty tier list,
|
||||
malformed entries, duplicate indexes, empty node ids, or out-of-range
|
||||
capability scores all raise `INVALID_TIER_CONFIG`. Unknown-tier
|
||||
queries raise `UNKNOWN_TIER`.
|
||||
|
||||
## Features
|
||||
|
||||
- Tier registry keyed by both `index` and `nodeType`.
|
||||
- Per-node capability registration with a synchronous update listener.
|
||||
- `getOptimalTier(capabilities)` — score-driven tier selection with
|
||||
graceful fallback when a preferred node-type is missing from the
|
||||
registered tier list.
|
||||
- `getLoadBalancingRecommendations()` — per-node load + reason,
|
||||
sorted by score descending.
|
||||
- Capacity check (`validateTierCapacity`) and per-tier max-children.
|
||||
- Stats snapshot (`getStats`) with per-tier histogram + average score.
|
||||
- One OTel counter — `capability_updates_total`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { NetworkTierAdapter } from "@suaveplan/game-server/performance/network-tier-adapter";
|
||||
|
||||
const adapter = new NetworkTierAdapter([
|
||||
{ index: 0, name: "host", nodeType: "host", maxNodes: 4, maxChildrenPerNode: 16 },
|
||||
{ index: 1, name: "sub-host", nodeType: "sub-host", maxNodes: 16, maxChildrenPerNode: 32 },
|
||||
{ index: 2, name: "player", nodeType: "player", maxNodes: 1000, maxChildrenPerNode: 0 },
|
||||
]);
|
||||
|
||||
adapter.registerNodeCapabilities("node-eu-west-1", {
|
||||
cpu: { usage: 35, cores: 8 },
|
||||
memory: { used: 4_000_000, total: 32_000_000, available: 28_000_000 },
|
||||
bandwidth: { upload: 1000, download: 1000, available: 800 },
|
||||
score: 92,
|
||||
});
|
||||
|
||||
const tier = adapter.getOptimalTier({
|
||||
cpu: { usage: 0, cores: 1 },
|
||||
memory: { used: 0, total: 1, available: 1 },
|
||||
bandwidth: { upload: 0, download: 0, available: 0 },
|
||||
score: 92,
|
||||
});
|
||||
console.log(tier.name); // "host"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { NetworkTierAdapter } from "@suaveplan/game-server/performance/network-tier-adapter";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const adapter = new NetworkTierAdapter(tiers, {
|
||||
meter: metrics.getMeter("game-server.network-tier"),
|
||||
onCapabilityUpdated: (nodeId, capabilities) => {
|
||||
bus.publish("node:capability-updated", { nodeId, capabilities });
|
||||
},
|
||||
});
|
||||
|
||||
for (const peer of liveCluster) {
|
||||
adapter.registerNodeCapabilities(peer.id, peer.capabilities);
|
||||
}
|
||||
|
||||
const recs = adapter.getLoadBalancingRecommendations();
|
||||
for (const rec of recs) {
|
||||
router.setTier(rec.nodeId, rec.tier, rec.recommendedLoad);
|
||||
}
|
||||
|
||||
const stats = adapter.getStats();
|
||||
console.log(stats.tierDistribution); // { host: 4, "sub-host": 11, player: 87 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new NetworkTierAdapter(tiers, deps?)` — throws
|
||||
`INVALID_TIER_CONFIG` on empty / malformed / duplicate-index tiers.
|
||||
- `registerNodeCapabilities(nodeId, caps): void` — throws
|
||||
`INVALID_TIER_CONFIG` on empty id or malformed capability shape.
|
||||
- `getTierForNodeType(nodeType): NetworkTier` — throws `UNKNOWN_TIER`
|
||||
on miss.
|
||||
- `getTierByIndex(index): NetworkTier` — throws `UNKNOWN_TIER` on miss.
|
||||
- `getOptimalTier(caps): NetworkTier` — score thresholds: ≥80 host,
|
||||
≥50 sub-host, else player. Falls back when preferred type missing.
|
||||
- `getLoadBalancingRecommendations(): readonly LoadBalancingRecommendation[]`.
|
||||
- `getTierHierarchy(): readonly NetworkTier[]` — sorted by index.
|
||||
- `validateTierCapacity(tierIndex, currentNodeCount): boolean`.
|
||||
- `getMaxChildren(tierIndex): number`.
|
||||
- `getNodeCapabilities(nodeId): NodeCapabilities | null`.
|
||||
- `getStats(): NetworkTierAdapterStats`.
|
||||
- `clear(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter copies validated tier entries into both `byIndex` and
|
||||
`byNodeType` maps so lookups are constant-time without iterating the
|
||||
input array. Multiple tiers sharing a `nodeType` are supported, with
|
||||
first-wins semantics for the `byNodeType` lookup (the upstream model
|
||||
uses one tier per type in practice, but the adapter avoids encoding
|
||||
that constraint). Score-bucket fallbacks deliberately mirror the
|
||||
upstream behaviour: if the host slot is missing the highest-rated
|
||||
tier wins; if sub-host is missing it falls back to `tiers[min(1,
|
||||
len-1)]`; if player is missing the last tier wins. Capability
|
||||
registration runs through Zod's `.finite()`-tightened number checks so
|
||||
non-finite scores cannot leak into recommendation math. The
|
||||
synchronous `onCapabilityUpdated` callback runs after the map has been
|
||||
updated so listeners that re-query through the adapter see consistent
|
||||
state.
|
||||
@@ -0,0 +1,120 @@
|
||||
# ObjectPool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ObjectPool<T>` is a generic typed object pool that dampens GC pressure
|
||||
in hot allocation paths — game-loop frame allocations, network message
|
||||
envelopes, session metadata containers — by recycling typed objects
|
||||
instead of letting them churn through the GC. Each pool is configured
|
||||
with a `factory` (creates a new instance), a `reset` (clears state
|
||||
before an instance returns to the pool), and an optional `dispose`
|
||||
(invoked on cleanup paths so callers can release native handles).
|
||||
|
||||
The pool is dependency-free apart from the typed
|
||||
`GameServerError` registry, Zod (for numeric-config validation), and
|
||||
the OpenTelemetry `metrics` API. Misuse is loud: releasing an object
|
||||
that didn't come from this pool raises `RELEASE_FOREIGN_OBJECT`;
|
||||
acquiring past the configured `maxSize` raises `POOL_EXHAUSTED`; using
|
||||
a pool after `dispose()` raises `POOL_DISPOSED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `initialSize`, `maxSize`, and pre-allocation toggle.
|
||||
- Typed factory + reset + optional dispose hooks.
|
||||
- `acquire`, `release`, and `withObject(callback)` (auto-release with
|
||||
finally semantics).
|
||||
- Hard cap enforcement at acquisition time, with typed-error rejection.
|
||||
- Lifecycle: `clear()` (resets stats + drops pool, fires dispose),
|
||||
`dispose()` (terminal, all subsequent calls throw).
|
||||
- Two OTel counters — `pool.acquire_total{outcome=reused|created}` and
|
||||
`pool.release_total`.
|
||||
- Stats snapshot — size, available, in-use, totals, hit-rate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { ObjectPool } from "@suaveplan/game-server/performance/object-pool";
|
||||
|
||||
interface Vec3 { x: number; y: number; z: number; }
|
||||
const pool = new ObjectPool<Vec3>({
|
||||
factory: () => ({ x: 0, y: 0, z: 0 }),
|
||||
reset: (v) => { v.x = v.y = v.z = 0; },
|
||||
initialSize: 64,
|
||||
});
|
||||
|
||||
const v = pool.acquire();
|
||||
v.x = 1; v.y = 2;
|
||||
pool.release(v);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { ObjectPool } from "@suaveplan/game-server/performance/object-pool";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
interface PacketBuffer { bytes: Uint8Array; offset: number; }
|
||||
const pool = new ObjectPool<PacketBuffer>(
|
||||
{
|
||||
factory: () => ({ bytes: new Uint8Array(2048), offset: 0 }),
|
||||
reset: (b) => {
|
||||
b.bytes.fill(0);
|
||||
b.offset = 0;
|
||||
},
|
||||
dispose: (b) => {
|
||||
// Optionally hand the underlying buffer to an async ring.
|
||||
ringFree.push(b.bytes);
|
||||
},
|
||||
initialSize: 32,
|
||||
maxSize: 512,
|
||||
},
|
||||
{ meter: metrics.getMeter("game-server.network") },
|
||||
);
|
||||
|
||||
// Hot loop: borrow a buffer per packet, auto-release on errors.
|
||||
await pool.withObject(async (buffer) => {
|
||||
await readInto(buffer.bytes);
|
||||
await transport.send(buffer.bytes);
|
||||
});
|
||||
|
||||
const stats = pool.getStats();
|
||||
console.log(`hit rate ${stats.hitRate}`);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ObjectPool<T>(config: ObjectPoolConfig<T>, deps?: ObjectPoolDeps)`
|
||||
— validates the numeric config via Zod; throws
|
||||
`INVALID_POOL_CONFIG` on missing `factory`/`reset` or invalid
|
||||
numeric fields.
|
||||
- `acquire(): T` — returns a recycled or freshly factory-allocated
|
||||
object. Throws `POOL_EXHAUSTED` when the pool is empty AND
|
||||
`pool+inUse >= maxSize`. Throws `POOL_DISPOSED` after dispose.
|
||||
- `release(obj: T): void` — throws `RELEASE_FOREIGN_OBJECT` if the
|
||||
object was not handed out by this pool; throws `POOL_DISPOSED` after
|
||||
dispose.
|
||||
- `withObject(cb): Promise<R>` — borrows + finally-releases.
|
||||
- `clear()` — drops every pooled object and resets stats (fires
|
||||
dispose hook).
|
||||
- `dispose()` — terminal, idempotent. Subsequent acquire/release/clear
|
||||
raise `POOL_DISPOSED`. Fires dispose hook on every pooled object.
|
||||
- `getStats(): PoolStats`.
|
||||
- `disposed: boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cap check at acquire is `pool.length + inUse.size >= maxSize`. When
|
||||
hit AND `pool.length === 0`, acquisition fails with `POOL_EXHAUSTED`.
|
||||
When hit AND `pool.length > 0`, the pool reuses an existing instance —
|
||||
this is the steady-state happy path for a saturated pool. Release
|
||||
always succeeds back into the pool because the cap is enforced at
|
||||
acquire, never letting `pool + inUse` exceed `maxSize`, so there is no
|
||||
unreachable eviction branch to test. The `dispose` hook fires only on
|
||||
`clear()` and `dispose()` paths, where the pool is being torn down or
|
||||
emptied. Pre-allocation runs the factory synchronously inside
|
||||
the constructor, which keeps every `acquire` constant-time in steady
|
||||
state.
|
||||
@@ -0,0 +1,118 @@
|
||||
# SessionPool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionPool` is a session-specific wrapper around `ObjectPool<T>` that
|
||||
recycles `PooledSession` records — plain-data session containers
|
||||
(`id`, `type`, `metadata`, `children[]`) — between game-loop frames.
|
||||
The wrapper presents a "fresh session per call" API
|
||||
(`acquireSession(data) → PooledSession`) while internally amortising
|
||||
the underlying allocation across borrows, which keeps GC pressure low
|
||||
in workloads that churn through thousands of short-lived game sessions
|
||||
per minute (matchmaking server, tournament orchestrator, tutorial sim).
|
||||
|
||||
The pool is universal. It owns no logic beyond what `ObjectPool`
|
||||
already provides; the wrapper exists so the factory + reset semantics
|
||||
stay close to the session shape and so callers can inject a `Clock` for
|
||||
deterministic placeholder timestamps without poking at the underlying
|
||||
pool's `factory`.
|
||||
|
||||
## Features
|
||||
|
||||
- Typed wrapper over `ObjectPool<PooledSession>`.
|
||||
- Configurable `initialSize` (default 20), `maxSize` (default 100), and
|
||||
`preAllocate` toggle.
|
||||
- Injectable `Clock` for deterministic placeholder timestamps.
|
||||
- `acquireSession(data)`, `releaseSession(session)`, and auto-release
|
||||
`withSession(data, callback)`.
|
||||
- `getStats()` forwards `ObjectPool`'s `PoolStats` for dashboard reuse.
|
||||
- Forwarded lifecycle: `clear()`, `dispose()`, `disposed` getter.
|
||||
- Default session type configurable via `defaultSessionType`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { SessionPool } from "@suaveplan/game-server/performance/session-pool";
|
||||
|
||||
const pool = new SessionPool();
|
||||
const session = pool.acquireSession({
|
||||
id: "match-42",
|
||||
type: "game",
|
||||
metadata: { createdAt: Date.now(), updatedAt: Date.now() },
|
||||
});
|
||||
session.children.push("player-alice", "player-bob");
|
||||
|
||||
pool.releaseSession(session);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { SessionPool } from "@suaveplan/game-server/performance/session-pool";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const pool = new SessionPool(
|
||||
{
|
||||
initialSize: 100,
|
||||
maxSize: 2_000,
|
||||
defaultSessionType: "queue",
|
||||
},
|
||||
{
|
||||
clock: () => Date.now(),
|
||||
meter: metrics.getMeter("game-server.sessions"),
|
||||
},
|
||||
);
|
||||
|
||||
// Hot loop: every match-found event borrows a session, populates it,
|
||||
// hands it to a downstream coordinator, and releases it on cleanup.
|
||||
await pool.withSession(
|
||||
{
|
||||
id: `match-${crypto.randomUUID()}`,
|
||||
type: "game",
|
||||
metadata: {
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
queueKey: "ranked",
|
||||
},
|
||||
},
|
||||
async (session) => {
|
||||
for (const playerId of matchedPlayers) {
|
||||
session.children.push(playerId);
|
||||
}
|
||||
await coordinator.start(session);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SessionPool(config?: SessionPoolConfig, deps?: SessionPoolDeps)`.
|
||||
- `acquireSession(data: PooledSessionData): PooledSession` — stamps the
|
||||
borrowed record with caller fields, clears `children`, returns.
|
||||
- `releaseSession(session: PooledSession): void` — forwards to
|
||||
`ObjectPool.release`; throws `RELEASE_FOREIGN_OBJECT` on a foreign
|
||||
object, `POOL_DISPOSED` after dispose.
|
||||
- `withSession(data, callback): Promise<R>` — borrows + finally-releases.
|
||||
- `getStats(): PoolStats`.
|
||||
- `clear()` / `dispose()` / `disposed: boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`acquireSession` always overwrites the record's identity and metadata
|
||||
fields before returning it, so callers never observe stale placeholder
|
||||
data left over from a previous borrow. The `children` array is
|
||||
emptied via `length = 0` rather than reassigned to a new array, which
|
||||
keeps the underlying buffer (and its hidden-class shape) alive — a
|
||||
small but measurable win in V8 hot loops. The factory's placeholder id
|
||||
counter increments monotonically across both factory + reset paths so
|
||||
production logs that capture a borrowed session before the caller
|
||||
overwrote its id can be debugged unambiguously. The wrapper does not
|
||||
re-export `ObjectPool`'s underlying counters — instead the OTel meter
|
||||
is forwarded into the underlying pool so the same `pool.acquire_total`
|
||||
/ `pool.release_total` instruments carry the session pool's
|
||||
contribution. Dashboards can disambiguate by attaching pool-name
|
||||
attributes on a thin wrapper if needed.
|
||||
@@ -0,0 +1,103 @@
|
||||
# ABTestPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ABTestPlugin` registers weighted experiments and assigns players to variants
|
||||
deterministically via an injected `Rng`. It records conversion events per
|
||||
(experiment, player) pair and exposes per-variant summary statistics so the
|
||||
game engine can drive data-informed balance decisions without taking a
|
||||
runtime dep on a separate experiments service.
|
||||
|
||||
The plugin is intentionally self-contained: it owns the experiment catalog,
|
||||
the assignment map, and the conversion log in memory. Persistence and
|
||||
statistical tests (z-test, chi-squared) are out of scope — callers compose
|
||||
the plugin with their preferred storage and analysis tooling.
|
||||
|
||||
## Features
|
||||
|
||||
- Weighted variant bucketing via `Rng.nextFloat()` (deterministic with a
|
||||
seeded RNG).
|
||||
- Idempotent assignment: re-calling `assignPlayer` returns the same variant.
|
||||
- Per-experiment conversion log with arbitrary `goalId` and numeric value.
|
||||
- Per-variant summary: assignments, conversions, total value, conversion rate.
|
||||
- Zod-validated experiment input (id, name, ≥1 variant with positive weight).
|
||||
- Lifecycle-safe: clears every experiment on `onUninstall`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ABTestPlugin } from "@suaveplan/game-server/plugins/ab-test-plugin";
|
||||
|
||||
const rng = { nextFloat: () => Math.random(), nextUint32: () => 0 };
|
||||
const plugin = new ABTestPlugin({ rng });
|
||||
plugin.createExperiment({
|
||||
id: "exp-1",
|
||||
name: "Drop rate test",
|
||||
variants: [
|
||||
{ id: "control", weight: 1 },
|
||||
{ id: "treatment", weight: 1 },
|
||||
],
|
||||
});
|
||||
const a = plugin.assignPlayer("exp-1", "player-1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ABTestPlugin } from "@suaveplan/game-server/plugins/ab-test-plugin";
|
||||
|
||||
// Seeded RNG for deterministic test runs.
|
||||
let s = 1;
|
||||
const rng = {
|
||||
nextUint32: () => (s = (s * 9301 + 49297) % 233280),
|
||||
nextFloat: () => {
|
||||
s = (s * 9301 + 49297) % 233280;
|
||||
return s / 233280;
|
||||
},
|
||||
};
|
||||
|
||||
const plugin = new ABTestPlugin({ rng });
|
||||
plugin.createExperiment({
|
||||
id: "drop",
|
||||
name: "Drop rate test",
|
||||
variants: [
|
||||
{ id: "low", weight: 3 },
|
||||
{ id: "high", weight: 1 },
|
||||
],
|
||||
});
|
||||
plugin.assignPlayer("drop", "p1");
|
||||
plugin.recordConversion("drop", "p1", "first-kill", 1);
|
||||
const summary = plugin.getSummary("drop");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ABTestPlugin extends BasePlugin`
|
||||
- `constructor(options: ABTestPluginOptions)`
|
||||
- `createExperiment(input: ExperimentInput): void`
|
||||
- `hasExperiment(id: string): boolean`
|
||||
- `assignPlayer(experimentId: string, playerId: string): Assignment`
|
||||
- `getAssignment(experimentId: string, playerId: string): Assignment | undefined`
|
||||
- `recordConversion(experimentId: string, playerId: string, goalId: string, value?: number): Conversion`
|
||||
- `getSummary(experimentId: string): readonly VariantSummary[]`
|
||||
- `listExperiments(): readonly string[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Bucketing accumulates variant weights and compares against
|
||||
`nextFloat() * totalWeight`. The implementation falls back to the last
|
||||
variant if the accumulator never exceeds the threshold (a defensive
|
||||
guard against floating-point ulp drift when weights are tiny).
|
||||
- Recording a conversion for an unassigned player throws
|
||||
`GAME_SERVER.AB_TEST_INVALID_VARIANT` so callers can route the error to
|
||||
their analytics dead-letter queue rather than corrupting the report.
|
||||
- The summary recomputes from raw records on each call. The data sets are
|
||||
small (tens of variants, thousands of players); pre-aggregation would
|
||||
add latency on a rare path. Callers needing high-frequency dashboards
|
||||
should snapshot the summary at a checkpoint instead of polling.
|
||||
- The plugin uses `BasePlugin.assertLive` on every public method so a
|
||||
destroyed plugin cannot accidentally serve stale data.
|
||||
@@ -0,0 +1,97 @@
|
||||
# AnalyticsPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AnalyticsPlugin` tracks per-session counters and per-event activity for a
|
||||
`@suaveplan/game-server` engine. It subscribes to a `GameEventEmitter` at
|
||||
install time and routes structured `AnalyticsEvent` payloads through an
|
||||
injectable sink so callers can ship them anywhere — OTel, a queue, an HTTP
|
||||
endpoint, or stdout in tests.
|
||||
|
||||
The plugin keeps the sink decoupled from any concrete observability stack:
|
||||
the engine emits domain events; the plugin shapes them into a stable
|
||||
analytics envelope; the sink decides what to do next. That separation means
|
||||
the same plugin works in unit tests, local development, and production
|
||||
without a behaviour fork.
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to `session:created`, `session:destroyed`, `game:started`,
|
||||
`game:ended`, `player:joined`, `player:left`, and `move:processed`.
|
||||
- Per-session row with `startTime`, `endTime`, `playerIds`, `moveCount`,
|
||||
`completed`.
|
||||
- Injectable `Clock` (defaults to `Date.now`) for deterministic timestamps.
|
||||
- Injectable `AnalyticsSink` — receives `AnalyticsEvent` payloads.
|
||||
- Aggregate `GameMetrics` snapshot (`getMetrics`) including average session
|
||||
duration, abandonment count, and total player count.
|
||||
- Per-session snapshot (`getSession`, `exportSessions`).
|
||||
- Zod-validated `abandonAfterMs` option with a 10-minute default.
|
||||
- Lifecycle-safe: unsubscribes from the emitter on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
import { AnalyticsPlugin } from "@suaveplan/game-server/plugins/analytics-plugin";
|
||||
|
||||
const emitter = new GameEventEmitter();
|
||||
const sink = (event) => console.log(event);
|
||||
|
||||
const plugin = new AnalyticsPlugin({ emitter, sink });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
import { AnalyticsPlugin } from "@suaveplan/game-server/plugins/analytics-plugin";
|
||||
|
||||
const emitter = new GameEventEmitter();
|
||||
const events: unknown[] = [];
|
||||
|
||||
const plugin = new AnalyticsPlugin({
|
||||
emitter,
|
||||
sink: (e) => events.push(e),
|
||||
clock: () => 1_700_000_000_000,
|
||||
abandonAfterMs: 300_000,
|
||||
});
|
||||
|
||||
await plugin.onInstall(ctx);
|
||||
emitter.emit("game:started", { sessionId: "s1" });
|
||||
emitter.emit("player:joined", { playerId: "p1", sessionId: "s1" });
|
||||
emitter.emit("move:processed", { playerId: "p1", move: {}, newState: {} });
|
||||
emitter.emit("game:ended", {
|
||||
sessionId: "s1",
|
||||
result: { winnerId: "p1", scores: {} },
|
||||
});
|
||||
|
||||
const metrics = plugin.getMetrics();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class AnalyticsPlugin extends BasePlugin`
|
||||
- `constructor(options: AnalyticsPluginOptions)`
|
||||
- `getMetrics(): GameMetrics`
|
||||
- `getSession(sessionId): SessionAnalyticsSnapshot | undefined`
|
||||
- `exportSessions(): readonly SessionAnalyticsSnapshot[]`
|
||||
- `reset(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `session:created` from the engine surfaces a `SessionNode`; the plugin
|
||||
reads only `session.id` and never retains a reference to the node.
|
||||
- `game:started` is treated as session-create as well; back-to-back
|
||||
emissions of the same session ID are idempotent.
|
||||
- A session counts as abandoned once `now - startTime > abandonAfterMs`
|
||||
AND it is still active (not completed). The default 10-minute window
|
||||
matches the upstream behaviour but can be tightened via `abandonAfterMs`.
|
||||
- The plugin unsubscribes from every event on `onUninstall`, so a
|
||||
re-installable engine cannot accumulate dangling listeners.
|
||||
- The sink call is synchronous; callers that need async fan-out should
|
||||
wrap their async logic in their own queue. This keeps the engine's hot
|
||||
event path off the microtask queue.
|
||||
@@ -0,0 +1,106 @@
|
||||
# BasePlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BasePlugin` is the abstract base class every concrete `@suaveplan/game-server`
|
||||
plugin extends. It implements the `EnginePlugin` contract that the
|
||||
`PluginManager` consumes (`onInstall`, `onUninstall`, `onEnable`, `onDisable`)
|
||||
and forwards each call to a `protected` template-method hook
|
||||
(`onInstallHook` etc.) that subclasses override.
|
||||
|
||||
The base also owns a small lifecycle state machine
|
||||
(`created → installed → enabled → disabled → destroyed`) and refuses to run
|
||||
any hook once the plugin has reached `destroyed`. This catches the common
|
||||
"resurrect a torn-down plugin" bug at the boundary instead of letting it
|
||||
corrupt downstream state.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `EnginePlugin` so the `PluginManager` can install/enable/disable
|
||||
any subclass without per-plugin glue.
|
||||
- Template-method hooks (`onInstallHook`, `onUninstallHook`,
|
||||
`onEnableHook`, `onDisableHook`) — subclasses override only what they need.
|
||||
- Injectable `Clock` for deterministic timestamping; defaults to `Date.now`.
|
||||
- Injectable `LoggerLike` (defaults to no-op until installed; install
|
||||
promotes the manager-supplied logger).
|
||||
- Public `state` getter and `snapshot()` accessor for observability.
|
||||
- `assertLive(op)` helper subclasses call from public methods to fail fast
|
||||
on use-after-destroy with `GAME_SERVER.PLUGIN_BASE_DESTROYED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BasePlugin } from "@suaveplan/game-server/plugins/base-plugin";
|
||||
|
||||
class HelloPlugin extends BasePlugin {
|
||||
public readonly name = "hello";
|
||||
public readonly version = "1.0.0";
|
||||
|
||||
protected async onInstallHook(): Promise<void> {
|
||||
this.logger.info("hello plugin installed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BasePlugin } from "@suaveplan/game-server/plugins/base-plugin";
|
||||
import type { EnginePluginContext } from "@suaveplan/game-server/plugins/base-plugin";
|
||||
|
||||
class CountingPlugin extends BasePlugin {
|
||||
public readonly name = "counter";
|
||||
public readonly version = "1.0.0";
|
||||
public readonly dependencies = ["hello"] as const;
|
||||
|
||||
private installs = 0;
|
||||
|
||||
public constructor() {
|
||||
super({ clock: () => 1_700_000_000_000 });
|
||||
}
|
||||
|
||||
protected async onInstallHook(_ctx: EnginePluginContext): Promise<void> {
|
||||
this.installs += 1;
|
||||
}
|
||||
|
||||
public count(): number {
|
||||
this.assertLive("count");
|
||||
return this.installs;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BasePlugin implements EnginePlugin`
|
||||
- `abstract readonly name: string`
|
||||
- `abstract readonly version: string`
|
||||
- `readonly dependencies?: readonly string[]`
|
||||
- `get state(): BasePluginState`
|
||||
- `snapshot(): BasePluginSnapshot`
|
||||
- `onInstall(ctx): Promise<void>`
|
||||
- `onUninstall(ctx): Promise<void>`
|
||||
- `onEnable(ctx): Promise<void>`
|
||||
- `onDisable(ctx): Promise<void>`
|
||||
- `protected onInstallHook(ctx): Promise<void>` (override)
|
||||
- `protected onUninstallHook(ctx): Promise<void>` (override)
|
||||
- `protected onEnableHook(ctx): Promise<void>` (override)
|
||||
- `protected onDisableHook(ctx): Promise<void>` (override)
|
||||
- `protected assertLive(op: string): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The state machine is deliberately minimal: every hook except `onUninstall`
|
||||
throws `PLUGIN_BASE_DESTROYED` when the plugin has already been torn down.
|
||||
`onUninstall` itself is also rejected after destroy because the
|
||||
`PluginManager` removes the entry from its map on the first uninstall.
|
||||
- The logger field starts as a no-op so subclass constructors can log
|
||||
defensively without crashing tests that omit a logger; once
|
||||
`onInstall` runs, the context logger from `PluginManager` is adopted
|
||||
unless the caller supplied an explicit override via `BasePluginOptions`.
|
||||
- The clock is captured at construction time so callers can substitute a
|
||||
`FakeClock` from `@suaveplan/testing` for deterministic snapshots.
|
||||
@@ -0,0 +1,100 @@
|
||||
# FeatureFlagPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FeatureFlagPlugin` wraps a `@suaveplan/feature-flags` `FlagManager` and
|
||||
exposes its evaluation surface as a lifecycle-managed game-server plugin.
|
||||
It folds a per-player `userId` into the evaluation context automatically
|
||||
and records every evaluation to a rotating log so callers can replay or
|
||||
audit flag decisions without instrumenting every callsite by hand.
|
||||
|
||||
The plugin is intentionally a thin adapter: it does not own the
|
||||
provider chain, override semantics, or persistence story — those remain
|
||||
in `@suaveplan/feature-flags`. Errors raised by the underlying manager
|
||||
(notably `FLAG_NOT_FOUND`) propagate unchanged so callers can catch the
|
||||
canonical `FeatureFlagsError` discriminator.
|
||||
|
||||
## Features
|
||||
|
||||
- Lifecycle-managed via `BasePlugin` — clears the evaluation log on
|
||||
uninstall.
|
||||
- Per-player `EvaluationContext` building from `defaultContext + playerId`.
|
||||
- `isEnabled` / `evaluate` / `getString` / `getNumber` helpers wrapping
|
||||
the underlying manager API.
|
||||
- Rotating evaluation log (FIFO; default cap 1 000) for downstream
|
||||
analytics.
|
||||
- Zod-validated `maxLogEntries` option.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
FlagManager,
|
||||
FlagRegistry,
|
||||
Evaluator,
|
||||
MemoryProvider,
|
||||
} from "@suaveplan/feature-flags";
|
||||
import { FeatureFlagPlugin } from "@suaveplan/game-server/plugins/feature-flag-plugin";
|
||||
|
||||
const provider = new MemoryProvider({
|
||||
flags: [
|
||||
{ key: "boss-rush", type: "boolean", defaultValue: false },
|
||||
],
|
||||
});
|
||||
const registry = new FlagRegistry({ provider });
|
||||
await registry.load();
|
||||
const evaluator = new Evaluator();
|
||||
const flagManager = new FlagManager({ registry, evaluator });
|
||||
|
||||
const plugin = new FeatureFlagPlugin({ flagManager });
|
||||
plugin.isEnabled("boss-rush", "player-1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FeatureFlagPlugin } from "@suaveplan/game-server/plugins/feature-flag-plugin";
|
||||
|
||||
const plugin = new FeatureFlagPlugin({
|
||||
flagManager,
|
||||
defaultContext: { attributes: { region: "us-west" } },
|
||||
maxLogEntries: 50,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
await plugin.onInstall(ctx);
|
||||
plugin.evaluate("boss-rush", "p1");
|
||||
plugin.getString("hero-skin", "p1");
|
||||
plugin.getNumber("drop-rate-multiplier", "p1");
|
||||
const log = plugin.getEvaluationLog();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FeatureFlagPlugin extends BasePlugin`
|
||||
- `constructor(options: FeatureFlagPluginOptions)`
|
||||
- `isEnabled(key, playerId?): boolean`
|
||||
- `evaluate<T>(key, playerId?): FlagEvaluationResult<T>`
|
||||
- `getString(key, playerId?): string`
|
||||
- `getNumber(key, playerId?): number`
|
||||
- `getEvaluationLog(): readonly EvaluationLogEntry[]`
|
||||
- `clearLog(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The evaluation log is bounded; once `evaluationLog.length` exceeds
|
||||
`maxLogEntries`, the oldest entries are pruned via `slice(-max)`.
|
||||
- `buildContext` merges `defaultContext` with the per-call player id.
|
||||
`userId` from the default context is preserved when the caller does
|
||||
not supply a `playerId`.
|
||||
- Errors thrown by `FlagManager` (e.g. `FLAG_NOT_FOUND`) bubble up as
|
||||
`FeatureFlagsError`. The plugin does not translate them so callers
|
||||
retain access to the underlying error code without an extra cause
|
||||
unwrap.
|
||||
- The plugin holds the `flagManager` by reference and never disposes it.
|
||||
Disposal is the caller's responsibility because the manager may be
|
||||
shared across plugins.
|
||||
@@ -0,0 +1,89 @@
|
||||
# LoggingPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LoggingPlugin` subscribes to a `GameEventEmitter` at install time and
|
||||
forwards every observed event to an injected `LoggerLike` sink. In addition,
|
||||
it retains a bounded ring buffer of structured `LogEntry` records that the
|
||||
caller can inspect by recency, level, or event name. The buffer is meant for
|
||||
local diagnostics — production deployments rely on the `LoggerLike` sink
|
||||
plus a separate log-aggregation backend.
|
||||
|
||||
The plugin is composable: subscriptions for player, game, and session events
|
||||
are individually toggleable so callers can opt out of high-volume topics
|
||||
(`move:processed` is `debug`-level by default and easy to silence by
|
||||
disabling game logging).
|
||||
|
||||
## Features
|
||||
|
||||
- Structured `LogEntry` ring buffer with FIFO eviction at `maxEntries`
|
||||
(default 5 000).
|
||||
- Per-event level mapping: `info` for player/game/session lifecycle,
|
||||
`debug` for `move:processed`, `warn` for `move:rejected` and
|
||||
`player:disconnected`.
|
||||
- Injectable `LoggerLike` sink; the plugin forwards every entry verbatim.
|
||||
- Granular toggles: `enablePlayerLogging`, `enableGameLogging`,
|
||||
`enableSessionLogging`.
|
||||
- Read-only inspection: `getRecent`, `getByLevel`, `getByEvent`, `getStats`.
|
||||
- Lifecycle-safe: unsubscribes from the emitter on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
import { LoggingPlugin } from "@suaveplan/game-server/plugins/logging-plugin";
|
||||
|
||||
const emitter = new GameEventEmitter();
|
||||
const logger = console;
|
||||
const plugin = new LoggingPlugin({ emitter, logger });
|
||||
await plugin.onInstall(ctx);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LoggingPlugin } from "@suaveplan/game-server/plugins/logging-plugin";
|
||||
|
||||
const plugin = new LoggingPlugin({
|
||||
emitter,
|
||||
logger,
|
||||
maxEntries: 100,
|
||||
enableGameLogging: false,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
await plugin.onInstall(ctx);
|
||||
emitter.emit("player:joined", { playerId: "p1", sessionId: "s1" });
|
||||
emitter.emit("player:disconnected", { playerId: "p1", reason: "timeout" });
|
||||
const warns = plugin.getByLevel("warn");
|
||||
const stats = plugin.getStats();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class LoggingPlugin extends BasePlugin`
|
||||
- `constructor(options: LoggingPluginOptions)`
|
||||
- `getRecent(count?: number): readonly LogEntry[]`
|
||||
- `getByLevel(level: LogLevel): readonly LogEntry[]`
|
||||
- `getByEvent(name: string): readonly LogEntry[]`
|
||||
- `getStats(): { total: number; byLevel: Record<LogLevel, number> }`
|
||||
- `clear(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `LoggerLike` sink is invoked for every entry, regardless of whether
|
||||
the buffer is at capacity. Buffer trimming happens after the sink call
|
||||
so the underlying sink sees the full firehose.
|
||||
- Event subscriptions are stored as an array of unsubscribers; uninstall
|
||||
iterates and calls each one so the emitter is left exactly as it was
|
||||
before install. The plugin therefore composes with `installAll`/
|
||||
`disposeAll` cycles on the `PluginManager` without leaking handlers.
|
||||
- The `session:created` payload includes the full `SessionNode`; the
|
||||
plugin extracts only `session.id` for logging to keep entries small
|
||||
and avoid retaining a reference to mutable engine state.
|
||||
- `getRecent(count)` uses `Array.prototype.slice(-count)`; passing a
|
||||
count larger than the buffer length returns the full buffer.
|
||||
@@ -0,0 +1,92 @@
|
||||
# PersistencePlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PersistencePlugin` tracks per-session game state via a `GameEventEmitter`
|
||||
and persists it to an injectable `StorageLike` backend. It subscribes to
|
||||
`session:created`, `game:started`, `game:state:changed`, `player:joined`,
|
||||
`player:left`, and `game:ended` so engine state is captured without
|
||||
intrusive instrumentation.
|
||||
|
||||
The plugin saves on `game:ended` automatically (toggleable) and exposes
|
||||
explicit `save`, `load`, and `delete` operations so callers can drive
|
||||
persistence from outside the event stream too — for example, during an
|
||||
emergency shutdown hook.
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to the game emitter for transparent state capture.
|
||||
- Accumulates `gameState` from `game:state:changed` events.
|
||||
- Tracks per-session player membership.
|
||||
- Save record is Zod-validated on load — corrupt files throw
|
||||
`GAME_SERVER.PERSISTENCE_LOAD_FAILED`.
|
||||
- Saves on `game:ended` (configurable via `saveOnGameEnd`).
|
||||
- Customisable key prefix (`prefix`, default `"game:save:"`).
|
||||
- Lifecycle-safe: unsubscribes and clears tracked sessions on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GameEventEmitter } from "@suaveplan/game-server";
|
||||
import { PersistencePlugin } from "@suaveplan/game-server/plugins/persistence-plugin";
|
||||
|
||||
const emitter = new GameEventEmitter();
|
||||
const storage = createMyKvAdapter();
|
||||
const plugin = new PersistencePlugin({ emitter, storage });
|
||||
await plugin.onInstall(ctx);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PersistencePlugin } from "@suaveplan/game-server/plugins/persistence-plugin";
|
||||
|
||||
const plugin = new PersistencePlugin({
|
||||
emitter,
|
||||
storage,
|
||||
prefix: "tournament:",
|
||||
saveOnGameEnd: false,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
await plugin.onInstall(ctx);
|
||||
emitter.emit("game:started", { sessionId: "s1" });
|
||||
emitter.emit("game:state:changed", {
|
||||
sessionId: "s1",
|
||||
newState: { score: 42 },
|
||||
});
|
||||
const saved = await plugin.save("s1");
|
||||
const loaded = await plugin.load("s1");
|
||||
await plugin.delete("s1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PersistencePlugin extends BasePlugin`
|
||||
- `constructor(options: PersistencePluginOptions)`
|
||||
- `save(sessionId): Promise<SavedGameState>`
|
||||
- `load(sessionId): Promise<SavedGameState | null>`
|
||||
- `delete(sessionId): Promise<boolean>`
|
||||
- `peek(sessionId): SavedGameState | undefined`
|
||||
- `mergeState(sessionId, fragment): void`
|
||||
- `size(): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `game:state:changed` carries `newState: unknown`. The plugin merges
|
||||
object payloads into the tracked map and wraps non-object payloads as
|
||||
`{ value: <payload> }` so the saved record always has a stable shape.
|
||||
- `load` performs three checks: storage `get`, JSON parse, and Zod
|
||||
schema validation. Any failure surfaces as
|
||||
`PERSISTENCE_LOAD_FAILED` with the original error preserved as the
|
||||
cause; `null` is reserved for "nothing stored at this key".
|
||||
- Errors in the auto-save path (`saveOnGameEnd`) are caught and
|
||||
swallowed after a logger.error so a storage outage cannot stall the
|
||||
emitter. Manual `save` calls still surface the error to the caller.
|
||||
- `mergeState` is a public hook for callers that want to capture state
|
||||
from a non-event source (e.g. an external scheduler). It writes
|
||||
through to the in-memory row only; persistence still requires `save`.
|
||||
@@ -0,0 +1,89 @@
|
||||
# RulesPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`RulesPlugin` integrates `@suaveplan/rules` with the game-server engine. It
|
||||
maintains three rule sets — move validation, game start, and game end — and
|
||||
exposes `evaluateMove`, `evaluateGameStart`, and `evaluateGameEnd` so the
|
||||
engine can apply declarative checks without leaking the underlying engine
|
||||
abstraction into game logic.
|
||||
|
||||
The plugin's role is integration glue: it does not interpret rules itself,
|
||||
it just hands them to the injected `RuleEngine` and records the pass/fail
|
||||
outcomes in a rolling history. Callers compose rule sets at construction
|
||||
time or replace them at runtime via `setMoveRules` / `addMoveRules`.
|
||||
|
||||
## Features
|
||||
|
||||
- Three named rule slots (move / start / end) plus an ad-hoc `evaluate`
|
||||
helper.
|
||||
- Empty rule sets are treated as a "passed" no-op (recorded in history).
|
||||
- Rolling evaluation history with a configurable cap (default 1 000).
|
||||
- Mutable move rule set: append (`addMoveRules`) or replace
|
||||
(`setMoveRules`) at runtime.
|
||||
- Zod-validated `historyCap` option.
|
||||
- Lifecycle-safe: clears rule sets and history on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RuleEngine } from "@suaveplan/rules";
|
||||
import { RulesPlugin } from "@suaveplan/game-server/plugins/rules-plugin";
|
||||
|
||||
const engine = new RuleEngine();
|
||||
const plugin = new RulesPlugin({ engine });
|
||||
await plugin.evaluateMove("p1", { kind: "attack" }, { hp: 10 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RuleEngine, type Rule } from "@suaveplan/rules";
|
||||
import { RulesPlugin } from "@suaveplan/game-server/plugins/rules-plugin";
|
||||
|
||||
const engine = new RuleEngine();
|
||||
const damageCap: Rule = /* ... discriminated rule ... */ ({} as Rule);
|
||||
|
||||
const plugin = new RulesPlugin({
|
||||
engine,
|
||||
moveValidationRules: [damageCap],
|
||||
historyCap: 50,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
plugin.addMoveRules(/* another rule */);
|
||||
const results = await plugin.evaluateMove("p1", { dmg: 5 }, { hp: 10 });
|
||||
const history = plugin.getHistory();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RulesPlugin extends BasePlugin`
|
||||
- `constructor(options: RulesPluginOptions)`
|
||||
- `evaluateMove(playerId, move, gameState): Promise<readonly EvaluationResult[]>`
|
||||
- `evaluateGameStart(sessionId): Promise<readonly EvaluationResult[]>`
|
||||
- `evaluateGameEnd(sessionId): Promise<readonly EvaluationResult[]>`
|
||||
- `evaluate(rules, context): Promise<readonly EvaluationResult[]>`
|
||||
- `addMoveRules(...rules): void`
|
||||
- `setMoveRules(rules): void`
|
||||
- `getHistory(): readonly EvaluationHistoryEntry[]`
|
||||
- `clearHistory(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The engine API used here is `evaluateAll(rules, context)`, which
|
||||
returns an array of `EvaluationResult` (one per rule). The plugin
|
||||
records a single history entry per evaluation, with `passed` reflecting
|
||||
the conjunction over the result set.
|
||||
- `evaluate(rules, ctx)` rejects non-object contexts with
|
||||
`GAME_SERVER.INVALID_RULE_CONTEXT` so callers cannot silently pass
|
||||
primitives through to the rule engine.
|
||||
- Empty rule sets short-circuit before hitting the engine — they record
|
||||
a `passed: true, resultCount: 0` history entry so callers still see
|
||||
the checkpoint was reached.
|
||||
- The history is bounded; once the length exceeds `historyCap`, the
|
||||
oldest entries are dropped via `slice(-historyCap)`.
|
||||
@@ -0,0 +1,102 @@
|
||||
# WorkflowPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WorkflowPlugin` lets the game-server orchestrate multi-step `@suaveplan/workflow`
|
||||
engines under a stable id. Callers register a `WorkflowEngine` once, then
|
||||
`execute(id, initialData)` to run it; the plugin records each attempt in a
|
||||
rolling history and tracks in-flight executions so callers can issue
|
||||
cancellation.
|
||||
|
||||
Typical uses include tournament-bracket progression, boss-phase saga flows
|
||||
(deal → fight → loot), or post-match payout pipelines. The plugin keeps the
|
||||
workflow surface intentionally thin: it does not build engines for you and
|
||||
it does not serialize state — those remain in `@suaveplan/workflow`.
|
||||
|
||||
## Features
|
||||
|
||||
- Id-keyed workflow registry; duplicate registrations throw
|
||||
`GAME_SERVER.DUPLICATE_WORKFLOW`.
|
||||
- `execute` wraps `engine.run` and records start/complete timestamps plus
|
||||
status/error.
|
||||
- `cancel(id)` defers to `engine.cancel()` for in-flight runs.
|
||||
- Active-execution count via `activeCount()` for back-pressure logic.
|
||||
- Rolling history with `getHistory()` and `clearHistory()`.
|
||||
- Lifecycle-safe: cancels every active engine on uninstall.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createWorkflowEngine } from "@suaveplan/workflow";
|
||||
import { WorkflowPlugin } from "@suaveplan/game-server/plugins/workflow-plugin";
|
||||
|
||||
const engine = createWorkflowEngine({
|
||||
id: "payout",
|
||||
name: "Payout",
|
||||
steps: [
|
||||
{ id: "credit", name: "Credit", execute: () => ({ ok: true }) },
|
||||
],
|
||||
});
|
||||
const plugin = new WorkflowPlugin();
|
||||
plugin.registerWorkflow("payout", "Payout", engine);
|
||||
const result = await plugin.execute("payout", { matchId: "m1" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createWorkflowEngine } from "@suaveplan/workflow";
|
||||
import { WorkflowPlugin } from "@suaveplan/game-server/plugins/workflow-plugin";
|
||||
|
||||
const plugin = new WorkflowPlugin({ clock: () => 1_700_000_000_000 });
|
||||
|
||||
const slow = createWorkflowEngine({
|
||||
id: "slow",
|
||||
name: "Slow",
|
||||
steps: [
|
||||
{
|
||||
id: "wait",
|
||||
name: "Wait",
|
||||
execute: async () => new Promise((r) => setTimeout(r, 1000)),
|
||||
},
|
||||
],
|
||||
});
|
||||
plugin.registerWorkflow("slow", "Slow", slow);
|
||||
|
||||
const pending = plugin.execute("slow", { x: 1 });
|
||||
plugin.cancel("slow");
|
||||
await pending.catch(() => undefined);
|
||||
|
||||
const history = plugin.getHistory();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class WorkflowPlugin extends BasePlugin`
|
||||
- `constructor(options?: WorkflowPluginOptions)`
|
||||
- `registerWorkflow<TData>(id, name, engine): void`
|
||||
- `has(id): boolean`
|
||||
- `list(): readonly string[]`
|
||||
- `activeCount(): number`
|
||||
- `execute<TData>(id, initialData): Promise<WorkflowResult<TData>>`
|
||||
- `cancel(id): boolean`
|
||||
- `getHistory(): readonly WorkflowExecutionRecord[]`
|
||||
- `clearHistory(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry stores engines under the widest possible generic
|
||||
(`WorkflowEngine<Record<string, unknown>>`). `execute` casts back to
|
||||
the caller-supplied `TData` so the public API stays type-safe while
|
||||
internal storage uses a single map.
|
||||
- A history record is appended at the start of every execution and
|
||||
mutated in place on completion via `replaceLast`. This keeps the
|
||||
invariant that `history.length` equals the number of attempts.
|
||||
- `cancel` returns `false` when there is no active execution for the id;
|
||||
this lets callers retry idempotently from a shutdown hook.
|
||||
- Uninstall cancels every active engine via its own `cancel()` method
|
||||
and clears all in-memory tables.
|
||||
@@ -0,0 +1,122 @@
|
||||
# DeltaCompressor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DeltaCompressor` is the wire-format layer that sits between `StateDiffer`
|
||||
and `StateSync`. It computes a `StateDelta` between two snapshots,
|
||||
JSON-encodes the operation list as UTF-8 bytes, attaches versioning and
|
||||
compression statistics, and produces a `CompressedDelta` packet suitable
|
||||
for any binary transport — WebSocket frames, WebRTC data channels, or
|
||||
HTTP/2 streams. On the receiving side it reverses the pipeline: parse,
|
||||
Zod-validate, apply, and increment OTel decompression counters.
|
||||
|
||||
The Wave B-3 port adds Zod-validated payload boundaries, an injectable
|
||||
OTel `Meter` for instrumentation, and typed errors (`STATE_DECOMPRESS_FAILED`)
|
||||
wrapping every wire-parse fault. The upstream's `try/catch` plus
|
||||
`logger.error` is replaced by structured `GameServerError`s carrying the
|
||||
parse cause, so observability and recovery happen through the same channel
|
||||
as every other game-server failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Versioned wire format (`COMPRESSED_DELTA_VERSION`); incompatible version → typed error.
|
||||
- Zod-validated payload schema — malformed peers cannot inject arbitrary operations.
|
||||
- Three OTel instruments: `delta_compressed_total`, `delta_decompressed_total`, `compression_ratio` histogram.
|
||||
- `compressSelective` filters operations by path prefix for partial sync.
|
||||
- `batchCompress` packs multiple pair-deltas in one call.
|
||||
- `estimateSavings` returns the byte budget delta-vs-full-state for adaptive sync.
|
||||
- Empty deltas short-circuit to a zero-byte packet with no allocation.
|
||||
- Injectable `Clock` and `StateDiffer` for deterministic tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { DeltaCompressor } from "@suaveplan/game-server/state/delta-compressor";
|
||||
|
||||
const compressor = new DeltaCompressor();
|
||||
const packet = compressor.compress(
|
||||
{ score: 0, lives: 3 },
|
||||
{ score: 5, lives: 3 }
|
||||
);
|
||||
|
||||
// Send packet.data over the wire …
|
||||
|
||||
const restored = compressor.decompress({ score: 0, lives: 3 }, packet);
|
||||
// { score: 5, lives: 3 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { DeltaCompressor } from "@suaveplan/game-server/state/delta-compressor";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const meter = metrics.getMeter("my-game", "1.0.0");
|
||||
const compressor = new DeltaCompressor({
|
||||
clock: () => 1_700_000_000_000,
|
||||
meter,
|
||||
});
|
||||
|
||||
// Only sync the `players[*]` subtree to spectators.
|
||||
const spectatorPacket = compressor.compressSelective(
|
||||
oldWorld,
|
||||
newWorld,
|
||||
["players"]
|
||||
);
|
||||
|
||||
// Budget-aware sync: skip if savings < 25%.
|
||||
const { savingsPercent } = compressor.estimateSavings(oldWorld, newWorld);
|
||||
if (savingsPercent > 25) {
|
||||
transport.send(spectatorPacket.data);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DeltaCompressor`
|
||||
|
||||
#### `constructor(options?: DeltaCompressorOptions)`
|
||||
|
||||
- `clock?: Clock` — default `Date.now`.
|
||||
- `differ?: StateDiffer` — injected differ instance.
|
||||
- `meter?: Meter` — OTel meter; defaults to the global meter for `@suaveplan/game-server`.
|
||||
|
||||
#### `compress<T>(oldState, newState): CompressedDelta`
|
||||
|
||||
#### `compressSelective<T>(oldState, newState, paths): CompressedDelta`
|
||||
|
||||
#### `decompress<T>(state, packet): T`
|
||||
|
||||
Throws `GameServerError(GAME_SERVER.STATE_DECOMPRESS_FAILED)` on version
|
||||
mismatch or malformed payload.
|
||||
|
||||
#### `batchCompress<T>(pairs): CompressedDelta[]`
|
||||
|
||||
#### `estimateSavings<T>(oldState, newState): { fullStateSize, deltaSize, savings, savingsPercent }`
|
||||
|
||||
### Types
|
||||
|
||||
- `CompressedDelta` — `{ version, timestamp, data: Uint8Array, stats }`.
|
||||
- `CompressionStats` — `{ originalSize, compressedSize, compressionRatio, operationCount }`.
|
||||
- `COMPRESSED_DELTA_VERSION` — current wire-format version (`1`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The payload is a JSON-encoded array of `DiffOperation`s rather than a
|
||||
custom binary format because typical game deltas are small (≤ 200 bytes)
|
||||
and the per-packet UTF-8 overhead is negligible compared to the TLS
|
||||
record framing that always wraps the transport. A future binary codec
|
||||
can ship behind a `version === 2` branch without breaking callers — the
|
||||
`CompressedDelta.version` field is the upgrade hatch.
|
||||
|
||||
Selective compression uses `startsWith` matching on `${path}.` and
|
||||
`${path}[` to avoid matching `playerScore` when the caller wanted only
|
||||
`player`. Exact-equality fallback covers the leaf-replace case.
|
||||
|
||||
`emptyStats` returns `compressionRatio = 1` for empty packets so consumers
|
||||
graphing the ratio histogram see a meaningful baseline rather than a
|
||||
divide-by-zero NaN.
|
||||
@@ -0,0 +1,113 @@
|
||||
# StateDiffer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StateDiffer` is a pure structural diff and patch utility for plain-object
|
||||
game state. It produces a compact, deterministic `StateDelta` between two
|
||||
snapshots and applies that delta back onto a snapshot to reconstruct the
|
||||
target — the symmetric pair `patch(a, diff(a, b)) ≡ b` is the round-trip
|
||||
invariant on which `DeltaCompressor` and `StateSync` depend. Wave B-3
|
||||
ships this as a leaf utility under `@suaveplan/game-server/state/state-differ`.
|
||||
|
||||
The migration replaces the upstream's `"unchanged"` operation type — which
|
||||
inflated wire size for no benefit — with a delta that records only the
|
||||
operations that actually change something. Arrays use index-aligned diffing
|
||||
(positional), objects use key-set diffing, and primitives fall through to a
|
||||
single `replace`. Every traversal failure is wrapped in
|
||||
`GameServerError` with code `GAME_SERVER.STATE_DIFF_FAILED` or
|
||||
`STATE_PATCH_FAILED`, so callers never see a bare runtime exception.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-op grammar: `add`, `remove`, `replace` (no `unchanged` noise).
|
||||
- Object diff via key-set comparison; array diff via positional alignment.
|
||||
- Injectable `Clock` from `@suaveplan/types` for deterministic timestamps.
|
||||
- `mergeDeltas` collapses a sequence of deltas into a last-writer-wins set.
|
||||
- `isEmpty` predicate for skipping zero-change sync ticks.
|
||||
- `parsePath` handles nested arrays (`outer[0][1].inner`).
|
||||
- Plain-object guard rejects class instances (preserves identity semantics).
|
||||
- Typed errors via `GameServerError` — `STATE_DIFF_FAILED`, `STATE_PATCH_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { StateDiffer } from "@suaveplan/game-server/state/state-differ";
|
||||
|
||||
const differ = new StateDiffer();
|
||||
const delta = differ.diff(
|
||||
{ score: 0, players: ["a"] },
|
||||
{ score: 1, players: ["a", "b"] }
|
||||
);
|
||||
|
||||
console.log(delta.operations);
|
||||
// [{ type: "replace", path: "score", value: 1, oldValue: 0 },
|
||||
// { type: "add", path: "players[1]", value: "b" }]
|
||||
|
||||
const restored = differ.patch({ score: 0, players: ["a"] }, delta);
|
||||
// { score: 1, players: ["a", "b"] }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { StateDiffer } from "@suaveplan/game-server/state/state-differ";
|
||||
|
||||
const clock = () => 1_700_000_000_000;
|
||||
const differ = new StateDiffer({ clock });
|
||||
|
||||
// Compose multiple ticks into one wire packet.
|
||||
const a = differ.diff({ x: 0 }, { x: 1 });
|
||||
const b = differ.diff({ x: 1 }, { x: 2 });
|
||||
const c = differ.diff({ x: 2 }, { x: 3 });
|
||||
const merged = differ.mergeDeltas([a, b, c]);
|
||||
// merged.operations === [{ type: "replace", path: "x", value: 3, oldValue: 2 }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StateDiffer`
|
||||
|
||||
#### `constructor(options?: StateDifferOptions)`
|
||||
|
||||
`options.clock` — `Clock` from `@suaveplan/types`. Defaults to `Date.now`.
|
||||
|
||||
#### `diff<T>(oldState: T, newState: T): StateDelta`
|
||||
|
||||
Throws `GameServerError(GAME_SERVER.STATE_DIFF_FAILED)` on traversal failure.
|
||||
|
||||
#### `patch<T>(state: T, delta: StateDelta): T`
|
||||
|
||||
Returns a deeply-cloned, patched copy. Throws
|
||||
`GameServerError(GAME_SERVER.STATE_PATCH_FAILED)` on apply failure.
|
||||
|
||||
#### `isEmpty(delta: StateDelta): boolean`
|
||||
|
||||
#### `mergeDeltas(deltas: StateDelta[]): StateDelta`
|
||||
|
||||
### Types
|
||||
|
||||
- `DiffOperation` — discriminated union of `add | remove | replace`.
|
||||
- `StateDelta` — `{ timestamp, operations, size }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The omission of an `unchanged` operation is deliberate: the upstream emitted
|
||||
one for every untouched leaf, ballooning a single-field update into a delta
|
||||
proportional to the snapshot size. Removing it shrinks typical 1-KB
|
||||
snapshots to 30-byte deltas without losing round-trip fidelity, because
|
||||
`patch` only walks the operations that exist.
|
||||
|
||||
`Object.is` (rather than `===`) is used for equality so `NaN` and `-0`
|
||||
diff correctly. The plain-object guard rejects class instances — game
|
||||
states routinely carry `Date`, `Map`, `Set`, or domain classes whose
|
||||
internal slots would not survive a structural diff; treating them as
|
||||
opaque `replace` values is safer than producing a half-mutated clone.
|
||||
|
||||
`deepClone` is a hand-rolled recursive walker rather than
|
||||
`structuredClone` because the diffed values are constrained to plain JSON
|
||||
shapes and the recursion limit on game state (depth ≤ 16 in practice) is
|
||||
far below the V8 stack budget.
|
||||
@@ -0,0 +1,145 @@
|
||||
# StateManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StateManager` is the canonical, observable container for game-session
|
||||
state. It holds a single typed value, exposes read/write APIs (`get`,
|
||||
`update`, `updateMany`, `setState`, `reset`, `restore`), and emits a
|
||||
`StateChangeEvent` on every mutation so downstream consumers — UI
|
||||
binders, sync engines, persistence layers, replay recorders — can react
|
||||
without polling. Snapshots are returned deep-frozen so reckless callers
|
||||
can't reach into the manager's internal state and break its invariants.
|
||||
|
||||
The Wave B-3 port replaces the upstream's `Logger` and `GameEventEmitter`
|
||||
dependencies with injectable `Clock` and `IdGenerator` from
|
||||
`@suaveplan/types` plus OTel-native instrumentation. Listener faults are
|
||||
counted and absorbed — a misbehaving subscriber can never destabilise
|
||||
the state machine. `restore` accepts an optional validator predicate and
|
||||
throws `GAME_SERVER.INVALID_SNAPSHOT` when the snapshot fails it; clone
|
||||
failures surface as `GAME_SERVER.STATE_RESTORE_FAILED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-value typed store with deep-frozen reads.
|
||||
- Six mutation paths (`update`, `updateMany`, `setState`, `reset`, `restore`, plus `subscribe`).
|
||||
- Bounded change history (default 100 entries, configurable).
|
||||
- Synchronous listener notification with isolated error handling.
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic test scenarios.
|
||||
- Three OTel instruments: `mutation_total`, `listener_error_total`, `mutation_duration_ms`.
|
||||
- Snapshot validation via predicate; typed errors on rejection.
|
||||
- Discriminated `StateChangeEvent.kind` for downstream switch logic.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { StateManager } from "@suaveplan/game-server/state/state-manager";
|
||||
|
||||
interface World { score: number; alive: boolean }
|
||||
const world = new StateManager<World>({ score: 0, alive: true });
|
||||
|
||||
const unsub = world.subscribe((event) => {
|
||||
console.log(event.kind, event.currentState.score);
|
||||
});
|
||||
|
||||
world.update("score", 1);
|
||||
world.update("score", 2);
|
||||
world.setState({ score: 5, alive: false });
|
||||
|
||||
unsub();
|
||||
world.reset(); // back to initial { score: 0, alive: true }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { StateManager } from "@suaveplan/game-server/state/state-manager";
|
||||
import { GAME_SERVER_CODES, GameServerError } from "@suaveplan/game-server/errors";
|
||||
|
||||
interface Session { round: number; players: string[] }
|
||||
|
||||
const isSession = (v: unknown): v is Session =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
typeof (v as Session).round === "number" &&
|
||||
Array.isArray((v as Session).players);
|
||||
|
||||
const manager = new StateManager<Session>(
|
||||
{ round: 0, players: [] },
|
||||
{ maxHistorySize: 500, clock: () => 1700000000000, idGenerator: () => "test" }
|
||||
);
|
||||
|
||||
try {
|
||||
manager.restore({ wrong: true }, isSession);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof GameServerError &&
|
||||
err.code === GAME_SERVER_CODES.INVALID_SNAPSHOT
|
||||
) {
|
||||
// Surface a typed diagnostic; state is unchanged.
|
||||
}
|
||||
}
|
||||
|
||||
manager.restore({ round: 3, players: ["a", "b"] }, isSession);
|
||||
console.log(manager.getStats()); // { updateCount, listenerCount, ... }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StateManager<TState extends object>`
|
||||
|
||||
#### `constructor(initialState, options?)`
|
||||
|
||||
`options` — `{ maxHistorySize?, clock?, idGenerator?, meter? }`.
|
||||
|
||||
#### `getState(): DeepReadonly<TState>`
|
||||
|
||||
#### `get<K>(key): DeepReadonly<TState[K]>`
|
||||
|
||||
#### `has(key): boolean` / `keys(): Array<keyof TState>`
|
||||
|
||||
#### `update(key, value): void` / `updateMany(partial): void`
|
||||
|
||||
#### `setState(next): void` / `reset(): void`
|
||||
|
||||
#### `restore(snapshot, validator?): void`
|
||||
|
||||
Throws `GAME_SERVER.INVALID_SNAPSHOT` (validator) or
|
||||
`GAME_SERVER.STATE_RESTORE_FAILED` (clone).
|
||||
|
||||
#### `subscribe(listener): () => void`
|
||||
|
||||
#### `getHistory()` / `getRecentHistory(count)` / `clearHistory()`
|
||||
|
||||
#### `getStats(): StateManagerStats` / `dispose(): void`
|
||||
|
||||
### Types
|
||||
|
||||
- `StateChangeEvent<TState>` — `{ id, timestamp, kind, previousState, currentState }`.
|
||||
- `StateChangeListener<TState>`, `StateManagerStats`, `SnapshotValidator<TState>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getState` deep-clones then deep-freezes the snapshot so callers cannot
|
||||
mutate internal state, and so two consecutive `getState` calls return
|
||||
distinct frozen objects — important for downstream code that uses
|
||||
referential equality to detect change. The clone is recursive and pure
|
||||
(plain objects + arrays + primitives) because the manager is constrained
|
||||
to JSON-serialisable shapes; class instances are treated as opaque
|
||||
values and would survive clone by reference. Game code that wants
|
||||
identity-preserving state should split such objects into a sibling
|
||||
registry rather than embedding them in the state tree.
|
||||
|
||||
`update` short-circuits via `Object.is` when the value is unchanged,
|
||||
avoiding spurious history entries and listener invocations. The
|
||||
listener loop catches every thrown exception, counts it on
|
||||
`listener_error_total`, and continues — a misbehaving subscriber
|
||||
cannot block other subscribers or corrupt the state machine.
|
||||
|
||||
`maxHistorySize` is enforced by `slice(-max)` after every push so the
|
||||
array is never more than `max + 1` long; this keeps memory bounded on
|
||||
long-running sessions where a million mutations would otherwise pin the
|
||||
entire history in RAM.
|
||||
121
docs/gaming/games/game-server/src/state/state-sync/state-sync.md
Normal file
121
docs/gaming/games/game-server/src/state/state-sync/state-sync.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# StateSync
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StateSync` is the orchestration layer that turns `StateManager` mutations
|
||||
into wire packets and applies received packets back into the manager. It
|
||||
brokers between a single `StateManager<TState>` and a pluggable
|
||||
`StateSyncTransport` — WebSocket, WebRTC data channel, in-process pipe —
|
||||
deciding per call whether to send a delta, a full snapshot, or honour an
|
||||
inbound request. The state-sync algorithm is otherwise transport-agnostic.
|
||||
|
||||
The Wave B-3 port replaces the upstream's hard dependency on
|
||||
`NetworkAdapter`, `GameEventEmitter`, and `Logger` with a single
|
||||
`StateSyncTransport` interface and OTel-native instrumentation. Push
|
||||
cadence is left to callers — there is no internal `setInterval` —
|
||||
because game loops, dirty-flag policies, and bandwidth limiters belong
|
||||
to the application layer, not the sync engine.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-message protocol (`delta`, `full`, `request`) over an abstract transport.
|
||||
- Automatic fallback to full snapshot when the delta exceeds `maxDeltaSize`.
|
||||
- Initial-sync handshake: when no previous state has been synced, sends a full snapshot.
|
||||
- Inbound `request` triggers an outbound full snapshot.
|
||||
- Per-kind OTel counters + bytes histogram.
|
||||
- Typed errors: transport faults surface as `STATE_SYNC_TRANSPORT_FAILED`.
|
||||
- Statistics snapshot and reset.
|
||||
- Disposal removes the transport listener.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { StateManager } from "@suaveplan/game-server/state/state-manager";
|
||||
import { StateSync } from "@suaveplan/game-server/state/state-sync";
|
||||
|
||||
interface World { tick: number }
|
||||
|
||||
const manager = new StateManager<World>({ tick: 0 });
|
||||
const sync = new StateSync(manager, transport);
|
||||
|
||||
// On each game tick:
|
||||
manager.update("tick", manager.get("tick") + 1);
|
||||
await sync.sync();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import type { StateSyncTransport, StateSyncMessage } from "@suaveplan/game-server/state/state-sync";
|
||||
|
||||
// In-process pipe for tests / single-process play
|
||||
class InProcessTransport implements StateSyncTransport {
|
||||
private readonly handlers = new Set<(m: StateSyncMessage) => void>();
|
||||
public peer?: InProcessTransport;
|
||||
isConnected() { return this.peer !== undefined; }
|
||||
onMessage(h: (m: StateSyncMessage) => void) {
|
||||
this.handlers.add(h);
|
||||
return () => { this.handlers.delete(h); };
|
||||
}
|
||||
async send(m: StateSyncMessage) {
|
||||
for (const h of this.peer?.handlers ?? []) h(m);
|
||||
}
|
||||
}
|
||||
|
||||
const t1 = new InProcessTransport();
|
||||
const t2 = new InProcessTransport();
|
||||
t1.peer = t2; t2.peer = t1;
|
||||
|
||||
const sync = new StateSync(manager, t1, {
|
||||
config: { maxDeltaSize: 4096 },
|
||||
});
|
||||
|
||||
await sync.requestFullSync(); // ask peer for full state
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StateSync<TState extends object>`
|
||||
|
||||
#### `constructor(manager, transport, options?)`
|
||||
|
||||
`options` — `{ compressor?, clock?, meter?, config?: { maxDeltaSize? } }`.
|
||||
|
||||
#### `sync(): Promise<void>`
|
||||
|
||||
Sends a delta or, on first call or when the delta exceeds `maxDeltaSize`,
|
||||
a full snapshot.
|
||||
|
||||
#### `syncFull(): Promise<void>` / `requestFullSync(): Promise<void>`
|
||||
|
||||
#### `getStats(): SyncStats` / `resetStats(): void`
|
||||
|
||||
#### `dispose(): void`
|
||||
|
||||
### Types
|
||||
|
||||
- `StateSyncTransport` — `{ isConnected, send, onMessage }`.
|
||||
- `StateSyncMessage` — `delta | full | request` discriminated union.
|
||||
- `SyncStats` — counters for deltas/fulls/requests sent + received, bytes, ratio, last timestamp.
|
||||
- `StateSyncConfig` — `{ maxDeltaSize? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `snapshotCurrent` step explicitly deep-clones via `JSON.parse(JSON.stringify(...))`
|
||||
so the `lastSyncedState` does not retain references to the manager's
|
||||
deep-frozen snapshots — any subsequent attempt to compute a delta would
|
||||
otherwise compare two snapshots whose primitive leaves are identical
|
||||
but whose container references differ, producing spurious replace ops.
|
||||
JSON-clone is acceptable because the sync protocol already constrains
|
||||
the state to JSON-serialisable shapes.
|
||||
|
||||
Transport errors are wrapped in `GameServerError(STATE_SYNC_TRANSPORT_FAILED)`
|
||||
with the failing message kind on `context` so structured logs and
|
||||
observability dashboards can pinpoint the misbehaving leg of the
|
||||
connection. Inbound message handling does not catch transport-side
|
||||
errors — those are the transport's problem; the `StateSync` contract is
|
||||
that `onMessage` only delivers well-formed `StateSyncMessage` values.
|
||||
109
docs/gaming/games/game-server/src/types/common.md
Normal file
109
docs/gaming/games/game-server/src/types/common.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Core types and schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines the runtime Zod schemas and validator helpers for the
|
||||
core boundary types of `@suaveplan/game-server`: `GameConfig`,
|
||||
`SessionMetadata`, `PlayerInfo`, `MoveValidationResult`, and `GameResult`.
|
||||
The TypeScript interfaces live in `./common.types.ts` so they can be
|
||||
imported in zero-runtime contexts; this file wires real Zod parsers that
|
||||
turn untrusted input into discriminated, typed values or throw a typed
|
||||
`GameServerError` with a stable `GAME_SERVER.*` code.
|
||||
|
||||
Every public boundary of the package — session creation, player registration,
|
||||
move validation, game result emission — is expected to either call
|
||||
`parseGameConfig`/`parseSessionMetadata`/`parsePlayerInfo` directly or
|
||||
inline-validate against the same schema instances re-exported from
|
||||
`@suaveplan/game-server/types`.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed-union `SessionTypeSchema` over the six recognised session roles.
|
||||
- `GameConfigSchema` with cross-field invariant `maxPlayers >= minPlayers`.
|
||||
- `SessionMetadataSchema` uses `.passthrough()` to accept caller-defined extras.
|
||||
- Throwing validators (`parseGameConfig`, `parseSessionMetadata`, `parsePlayerInfo`).
|
||||
- Non-throwing probes (`tryParseMoveValidationResult`, `tryParseGameResult`).
|
||||
- Each thrower attaches the original Zod error as `cause` and the raw input as `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { parseGameConfig } from "@suaveplan/game-server/types";
|
||||
|
||||
const cfg = parseGameConfig({
|
||||
gameType: "tic-tac-toe",
|
||||
maxPlayers: 2,
|
||||
minPlayers: 2,
|
||||
rules: { allowDraws: true },
|
||||
});
|
||||
// cfg is typed as GameConfig and guaranteed schema-valid.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import {
|
||||
GameConfigSchema,
|
||||
parseGameConfig,
|
||||
tryParseMoveValidationResult,
|
||||
} from "@suaveplan/game-server/types";
|
||||
import { GameServerError } from "@suaveplan/game-server/errors";
|
||||
|
||||
// Compose the schema into a larger request schema:
|
||||
const CreateSessionRequest = GameConfigSchema.extend({
|
||||
requestedBy: z.string().min(1),
|
||||
});
|
||||
|
||||
try {
|
||||
parseGameConfig({ gameType: "chess", maxPlayers: 1, minPlayers: 2, rules: {} });
|
||||
} catch (err) {
|
||||
if (err instanceof GameServerError) {
|
||||
// err.code === "GAME_SERVER.INVALID_GAME_CONFIG"
|
||||
// err.cause is the underlying ZodError with rich path/issue info.
|
||||
}
|
||||
}
|
||||
|
||||
const probe = tryParseMoveValidationResult(maybeResult);
|
||||
if (probe === null) {
|
||||
// host-supplied logic returned an off-spec value; reject the move.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Schemas
|
||||
|
||||
- `SessionTypeSchema` — `z.ZodEnum<[...]>` over six session-role strings.
|
||||
- `PlayerInfoSchema` — strict object; `metadata` is `Record<string, unknown>`.
|
||||
- `SessionMetadataSchema` — passthrough object; `createdAt`/`updatedAt` are
|
||||
non-negative integers.
|
||||
- `GameConfigSchema` — passthrough object; cross-field invariant enforced.
|
||||
- `MoveValidationResultSchema` — strict object; `reason` only on the failure shape.
|
||||
- `GameResultSchema` — strict object; `winner` is `string | null`.
|
||||
|
||||
### Throwing validators
|
||||
|
||||
- `parseGameConfig(input): GameConfig` — throws `GAME_SERVER.INVALID_GAME_CONFIG`.
|
||||
- `parseSessionMetadata(input): SessionMetadata` — throws `GAME_SERVER.INVALID_SESSION_METADATA`.
|
||||
- `parsePlayerInfo(input): PlayerInfo` — throws `GAME_SERVER.INVALID_PLAYER_INFO`.
|
||||
|
||||
### Non-throwing probes
|
||||
|
||||
- `tryParseMoveValidationResult(input): MoveValidationResult | null`.
|
||||
- `tryParseGameResult(input): GameResult | null`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas tighten the upstream `Record<string, any>` slots to
|
||||
`Record<string, unknown>` because `any` defeats the lint profile.
|
||||
`GameConfigSchema` uses `.passthrough()` so callers can attach
|
||||
game-specific keys without the framework owning their shape — the
|
||||
`refine` cross-field check is still enforced. `parseGameConfig` etc. cast
|
||||
the parsed value to the public interface so the readonly modifiers in
|
||||
`./common.types.ts` flow to callers; the Zod output type is structurally
|
||||
compatible, and the cast is the canonical pattern for Zod ↔ TS interface
|
||||
bridges across the suaveplan codebase.
|
||||
129
docs/gaming/games/game-server/src/types/common.types.md
Normal file
129
docs/gaming/games/game-server/src/types/common.types.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Core type interfaces
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines the pure TypeScript interfaces for the public
|
||||
boundary of `@suaveplan/game-server`: `GameConfig`, `SessionMetadata`,
|
||||
`PlayerInfo`, `MoveValidationResult`, `GameResult`, plus the
|
||||
`SessionType` string-union and the `DeepReadonly` mapped-type helper.
|
||||
Keeping the type definitions in a separate `.types.ts` file lets
|
||||
zero-runtime consumers (other type-only barrels, doc-generators,
|
||||
`tsc --emitDeclarationOnly` integrations) import these shapes without
|
||||
pulling Zod into the bundle.
|
||||
|
||||
The runtime Zod schemas and validators that align with these shapes
|
||||
live alongside in `./common.ts`. The pair is the canonical pattern: one
|
||||
file for the static types, one for the runtime parsers, and a tiny
|
||||
barrel (`./index.ts`) that re-exports both. This package sits this file at
|
||||
`src/types/` depth 2 because the docs-check rule (see
|
||||
`scripts/docs-check.ts::isExcluded`) treats the shared `src/types/`
|
||||
directory as documentation-bearing even for `.types.ts` files. The
|
||||
co-located sibling `common.types.md` (this file) keeps the rule green.
|
||||
|
||||
## Features
|
||||
|
||||
- `DeepReadonly<T>` mapped type — used wherever a snapshot of an
|
||||
internal structure is exposed to callers.
|
||||
- `SessionType` — closed union over the six recognised session-role
|
||||
strings (`venue`, `table`, `queue`, `game`, `player`, `spectator`).
|
||||
- `PlayerInfo` — opaque player record with stable id, optional
|
||||
session-binding, optional display name, and open `metadata` bag.
|
||||
- `SessionMetadata` — open-ended record keyed by string with mandatory
|
||||
`createdAt` / `updatedAt` timestamps and a permissive
|
||||
`[key: string]: unknown` tail.
|
||||
- `GameConfig` — typed game-config envelope with `gameType`,
|
||||
`minPlayers`, `maxPlayers`, `rules`, plus an open-ended tail.
|
||||
- `MoveValidationResult` — `{ valid, reason? }` shape for generic move
|
||||
validators returned by game-logic plugins.
|
||||
- `GameResult` — `{ winner, rankings?, statistics? }` outcome shape with
|
||||
a `winner: string | null` channel (null encodes a draw).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
GameConfig,
|
||||
GameResult,
|
||||
PlayerInfo,
|
||||
} from "@suaveplan/game-server/types";
|
||||
|
||||
function summarise(cfg: GameConfig, players: ReadonlyArray<PlayerInfo>): GameResult {
|
||||
return { winner: null };
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
DeepReadonly,
|
||||
SessionMetadata,
|
||||
SessionType,
|
||||
} from "@suaveplan/game-server/types";
|
||||
|
||||
interface SessionView {
|
||||
readonly id: string;
|
||||
readonly type: SessionType;
|
||||
readonly metadata: DeepReadonly<SessionMetadata>;
|
||||
}
|
||||
|
||||
function expose(meta: SessionMetadata): DeepReadonly<SessionMetadata> {
|
||||
// structurally compatible because every field on SessionMetadata is
|
||||
// already `readonly` and primitive or nested-readonly via the mapper.
|
||||
return meta;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type DeepReadonly<T>`
|
||||
|
||||
Maps every property of `T` to `readonly`, recursing into nested objects.
|
||||
Primitives, functions, and arrays of primitives are passed through.
|
||||
|
||||
### `type SessionType`
|
||||
|
||||
Closed union `"venue" | "table" | "queue" | "game" | "player" | "spectator"`.
|
||||
|
||||
### `interface PlayerInfo`
|
||||
|
||||
- `id: string` — stable identifier.
|
||||
- `sessionId?: string` — optional session binding.
|
||||
- `name?: string` — optional display name.
|
||||
- `metadata: Record<string, unknown>` — open caller-owned bag.
|
||||
|
||||
### `interface SessionMetadata`
|
||||
|
||||
- `createdAt: number` — non-negative integer epoch.
|
||||
- `updatedAt: number` — non-negative integer epoch.
|
||||
- Open tail `[key: string]: unknown`.
|
||||
|
||||
### `interface GameConfig`
|
||||
|
||||
- `gameType: string`
|
||||
- `maxPlayers: number` (must satisfy `maxPlayers >= minPlayers`).
|
||||
- `minPlayers: number`
|
||||
- `rules: Record<string, unknown>` — caller-owned rule bag.
|
||||
- Open tail `[key: string]: unknown`.
|
||||
|
||||
### `interface MoveValidationResult`
|
||||
|
||||
- `valid: boolean`
|
||||
- `reason?: string` — only meaningful on the failure shape.
|
||||
|
||||
### `interface GameResult`
|
||||
|
||||
- `winner: string | null` — `null` encodes a draw.
|
||||
- `rankings?: ReadonlyArray<{ playerId: string; rank: number; score?: number }>`.
|
||||
- `statistics?: Record<string, unknown>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every field is declared `readonly` to match the lint profile,
|
||||
which prefers immutable shapes at boundaries. The open `[key: string]:
|
||||
unknown` tails on `SessionMetadata` and `GameConfig` mirror the
|
||||
upstream `Record<string, any>` slots but tighten the value type from
|
||||
`any` to `unknown`. Callers therefore must narrow before reading
|
||||
caller-owned fields — a deliberate trade-off versus the upstream
|
||||
`any` ergonomics, because `any` defeats every downstream type
|
||||
inference along the consumer chain.
|
||||
Reference in New Issue
Block a user