first commit
This commit is contained in:
@@ -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`).
|
||||
Reference in New Issue
Block a user