4.9 KiB
PlayerManager
(no submodules)
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
IPlayerManagercontract. - Injectable
ClockandTimersLike— fully deterministic under fakes. - Zod-validated options boundary (
timeoutThresholdMs,reconnectionGraceMs) with stableINVALID_PLAYER_MANAGER_OPTIONSfailure code. INVALID_HEARTBEAT_INTERVALfor non-positive integer interval inputs.- Reconnection path: a
connectagainst an existing disconnected / timeout entry transitions toreconnecting/connectedrather than building a fresh record (preservesconnectedAt). - Grace-window cleanup: in
checkTimeouts, disconnected entries older thanreconnectionGraceMsare evicted so a futureconnectbuilds a fresh record. - Defensive copies — public API returns frozen plain-object snapshots rather than mutable internals.
Usage: Basic
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
// 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 markedtimeout.reconnectionGraceMs(default 10000) — ms after a disconnect during which aconnect()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 tostartHeartbeatMonitor.
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).