Files
2026-05-27 23:29:53 +01:00

1906 lines
85 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec: `game-server`
**Package**: `@suaveplan/game-server`
**Status**: SHIPPED
Built across 7 OpenSpec changes archived under `openspec/archive/2026-05-24-game-server-*/`. This canonical spec consolidates the ADDED requirements from each.
---
## From change: `game-server-core`
### Requirement: `IGameLogic` strategy contract
The package SHALL export an `IGameLogic<TState, TMove>` interface declaring
`validateMove`, `processMove`, `getGameState`, `isGameOver`, `getGameResult`,
`initializeGame`, and `dispose`, fully typed without `any`, plus a runtime
conformance harness `runIGameLogicConformance(factory, fixtures)` that exercises
every method and returns a `ConformanceCheckResult[]`.
#### Scenario: a compliant implementation passes every harness scenario
- **WHEN** a caller passes a factory returning a complete implementation
(correct return shapes, validation truthy + falsy branches, terminal state)
- **THEN** every entry in the returned list has `passed === true`
**Verified by:** `src/core/interfaces/i-game-logic/i-game-logic.test.ts::"passes every scenario for a compliant implementation"` AND `"reports failure when an impl violates the contract"`
---
### Requirement: `IPlayerManager` lifecycle contract
The package SHALL export an `IPlayerManager` interface with the eleven methods
`connect`, `disconnect`, `getConnection`, `getSessionPlayers`,
`getAllConnections`, `heartbeat`, `checkTimeouts`, `updateMetadata`,
`getPlayer`, `startHeartbeatMonitor`, `stopHeartbeatMonitor`, plus a conformance
harness `runIPlayerManagerConformance(factory)` that exercises each.
#### Scenario: a compliant implementation passes every harness scenario
- **WHEN** the conformance harness runs against a Map-backed reference impl
- **THEN** every entry in the result list has `passed === true`
**Verified by:** `src/core/interfaces/i-player-manager/i-player-manager.test.ts::"passes every scenario for a compliant implementation"` AND `"reports session/all/getPlayer failures for an empty-returning impl"`
---
### Requirement: `ISessionManager` hierarchical contract
The package SHALL export an `ISessionManager` interface supporting unlimited
parent/child nesting with O(1) lookup by id, cascading destroy, partial-
metadata update, and async `persist` / `restore`, plus an async conformance
harness `runISessionManagerConformance(factory)`.
#### Scenario: destroySession cascades to descendants
- **WHEN** a `venue` containing a `table` is destroyed
- **THEN** the harness reports `destroySession removes the node and its descendants` as passed; an implementation that fails to cascade reports the scenario as failed
**Verified by:** `src/core/interfaces/i-session-manager/i-session-manager.test.ts::"passes every scenario for a compliant implementation"` AND `"reports failure when destroySession does not cascade to children"`
---
### Requirement: `IStateManager` observable-state contract
The package SHALL export an `IStateManager<TState extends object>` interface
declaring `getState`, `update`, `updateMany`, `setState`, `subscribe`, `reset`,
`getHistory`, and `clearHistory`, plus a conformance harness
`runIStateManagerConformance(factory, fixtures)`.
#### Scenario: subscribers receive events for every mutation
- **WHEN** the harness calls `update`, `updateMany`, and `setState` against a
compliant manager
- **THEN** the harness records at least three observed events; the
unsubscribe handle stops further events
**Verified by:** `src/core/interfaces/i-state-manager/i-state-manager.test.ts::"passes every scenario for a compliant implementation"` AND `"flags failure when unsubscribe does not stop events"`
---
### Requirement: `GameEvents` map and `GAME_EVENT_KEYS` registry
The package SHALL export a typed `GameEvents` map covering player, session,
game, move, and error event families, plus a frozen `GAME_EVENT_KEYS` array
enumerating every well-known key and an `isGameEventKey(value)` predicate.
#### Scenario: predicate narrows arbitrary strings
- **WHEN** `isGameEventKey("player:connected")` is called
- **THEN** it returns `true`; for `"not:a:real:event"` it returns `false`
**Verified by:** `src/core/events/game-events/game-events.test.ts::"returns true for every registered key"` AND `"returns false for unknown keys"`
---
### Requirement: `GameEventEmitter` typed pub/sub
The package SHALL export a `GameEventEmitter` class supporting `on`/`once`/
`off`/`emit`, ergonomic helpers `safeEmit` / `waitFor(timeoutMs?)` / `onN` /
`onMultiple`, a configurable per-key listener cap (default 100), and `removeAllListeners` / `listenerCount` introspection.
#### Scenario: safeEmit routes listener errors to the error channel
- **WHEN** a listener subscribed to `game:started` throws
- **THEN** `safeEmit` returns `false` (no successful listener), re-emits the
error as `error` payload on the `"error"` channel, and does not throw
**Verified by:** `src/core/events/game-event-emitter/game-event-emitter.test.ts::"safeEmit routes listener errors to the error channel"` AND `"safeEmit wraps non-Error throws into Error"`
#### Scenario: listener cap is enforced
- **WHEN** more than `maxListenersPerKey` listeners are registered on one key
- **THEN** `on(...)` throws `GameServerError(LISTENER_LIMIT_EXCEEDED)`
**Verified by:** `src/core/events/game-event-emitter/game-event-emitter.test.ts::"throws GameServerError(LISTENER_LIMIT_EXCEEDED) past the cap"`
#### Scenario: waitFor rejects on timeout
- **WHEN** `waitFor("game:started", 5)` is called and no emission occurs
- **THEN** the returned promise rejects with `GameServerError(EVENT_WAIT_TIMEOUT)`
**Verified by:** `src/core/events/game-event-emitter/game-event-emitter.test.ts::"waitFor rejects with GameServerError on timeout"`
---
### Requirement: `AbstractGameLogic` Template-Method base
The package SHALL export an `AbstractGameLogic<TState, TMove>` abstract class
implementing `IGameLogic`, accepting `{ initialState, clock?, idGenerator?,
meterName? }`, orchestrating `processMove` as validate → before hook → apply
→ install → after hook, managing a player roster with typed-error duplicate /
missing handling, and emitting OTel instruments
`game_server.logic.tick_duration_ms` (Histogram),
`game_server.logic.moves_total` (Counter), and
`game_server.logic.rejections_total` (Counter).
#### Scenario: processMove on a valid move records duration + total
- **WHEN** a subclass processes a valid move with an injected fake clock
- **THEN** the histogram and `moves_total` counter are recorded; the state
installs from `applyMove`
**Verified by:** `src/core/abstract-game-logic/abstract-game-logic.test.ts::"processMove records OTel instruments"` AND `"processMove validates, applies, mutates state, runs hooks"`
#### Scenario: processMove on an invalid move throws and increments rejections
- **WHEN** `validateMove` returns `{ valid: false }`
- **THEN** `processMove` throws `GameServerError(INVALID_MOVE)` with the
reason in `error.context`; `rejections_total` increments
**Verified by:** `src/core/abstract-game-logic/abstract-game-logic.test.ts::"processMove throws GameServerError(INVALID_MOVE) on rejection"`
#### Scenario: roster mutations throw typed errors
- **WHEN** `addPlayer` is called twice with the same id, or `removePlayer` is
called on an unknown id
- **THEN** `GameServerError(PLAYER_ALREADY_EXISTS)` / `(PLAYER_NOT_FOUND)`
is thrown respectively
**Verified by:** `src/core/abstract-game-logic/abstract-game-logic.test.ts::"addPlayer throws GameServerError(PLAYER_ALREADY_EXISTS) on duplicate"` AND `"removePlayer throws GameServerError(PLAYER_NOT_FOUND) for unknown id"`
---
### Requirement: error-code registry extended for Wave A
The package SHALL extend `GAME_SERVER_CODES` with `INVALID_MOVE`,
`PLAYER_ALREADY_EXISTS`, `PLAYER_NOT_FOUND`, `LISTENER_LIMIT_EXCEEDED`,
`INVALID_LISTENER_COUNT`, and `EVENT_WAIT_TIMEOUT`. Every new code MUST be
thrown by at least one production path inside `src/`.
#### Scenario: every new code is reachable from production
- **WHEN** the unit suites for `AbstractGameLogic` and `GameEventEmitter` run
- **THEN** each of the six new codes is observed thrown by at least one test
scenario
**Verified by:** `src/errors/game-server-error.test.ts::"registers all expected codes under GAME_SERVER namespace"` AND `src/core/abstract-game-logic/abstract-game-logic.test.ts::"processMove throws GameServerError(INVALID_MOVE) on rejection"` AND `src/core/events/game-event-emitter/game-event-emitter.test.ts::"onN throws GameServerError(INVALID_LISTENER_COUNT) for non-positive counts"`
---
### Requirement: subpath exports per feature module
The package SHALL declare a `package.json` `exports` subpath for every Wave A
feature module so consumers can tree-shake at module-level granularity.
#### Scenario: each new feature dir has its own export
- **WHEN** a consumer imports `@suaveplan/game-server/core/events/game-event-emitter` or any other Wave A subpath
- **THEN** the import resolves to the feature module's compiled `index.js`
**Verified by:** `bunx turbo build --filter=@suaveplan/game-server` (vite-plugin-dts emits matching `.d.ts` per subpath)
---
## From change: `game-server-engine-core`
### Requirement: GameLoop deterministic ticking
The package SHALL provide `GameLoop` driving a deterministic fixed-step
loop with `tick-based`, `event-driven`, and `hybrid` modes. All wall-clock
reads SHALL route through an injected `Clock`; all interval scheduling
SHALL route through an injected `TimersLike`. The loop SHALL NOT call
`Date.now()` or `setTimeout`/`setInterval` directly.
#### Scenario: tick-based mode schedules at the configured tick rate
- **WHEN** `start()` is called with `mode: "tick-based", tickRate: 30`
- **THEN** the loop schedules a recurring timer via `TimersLike.setTimer` with delay `1000 / 30` ms and invokes registered fixed-update callbacks each tick
#### Scenario: pause halts ticking, resume restarts without retroactive deltas
- **WHEN** `pause()` then later `resume()` is called
- **THEN** the resume call refreshes `lastTickTime` from the injected `Clock` and the next tick's `deltaTime` is bounded by `maxDeltaTime`
#### Scenario: manual `tick()` invokes all callbacks in registration order
- **WHEN** `tick()` is called with two registered fixed-update callbacks and one update callback
- **THEN** both fixed-update callbacks fire (in insertion order) then the update callback fires once
#### Scenario: invalid options reject with INVALID_GAME_LOOP_OPTIONS
- **WHEN** the loop is constructed with `tickRate: 0` or a negative `maxDeltaTime`
- **THEN** a `GameServerError(GAME_SERVER.INVALID_GAME_LOOP_OPTIONS)` is thrown
#### Scenario: starting a running loop rejects with GAME_LOOP_ALREADY_RUNNING
- **WHEN** `start()` is called twice in a row
- **THEN** the second call throws `GameServerError(GAME_SERVER.GAME_LOOP_ALREADY_RUNNING)`
#### Scenario: pause/resume/stop on idle loop rejects with GAME_LOOP_NOT_RUNNING
- **WHEN** `pause()`, `resume()`, or `stop()` is called before `start()`
- **THEN** each call throws `GameServerError(GAME_SERVER.GAME_LOOP_NOT_RUNNING)`
#### Scenario: callback errors propagate via injected EventBus
- **WHEN** a registered update callback throws inside `tick()`
- **THEN** the loop emits a `loop:error` event with the original error and continues processing remaining callbacks
#### Scenario: OTel histogram records each tick duration
- **WHEN** `tick()` completes
- **THEN** the `game_server.loop.tick_duration_ms` histogram receives one sample tagged with the current `mode`
**Verified by:** `src/engine/game-loop/game-loop.test.ts::"schedules tick-based ticks via injected TimersLike at the configured rate"` AND `src/engine/game-loop/game-loop.test.ts::"pause halts ticking and resume refreshes lastTickTime"` AND `src/engine/game-loop/game-loop.test.ts::"manual tick invokes fixed-update then update callbacks in order"` AND `src/engine/game-loop/game-loop.test.ts::"rejects construction with invalid options as INVALID_GAME_LOOP_OPTIONS"` AND `src/engine/game-loop/game-loop.test.ts::"throws GAME_LOOP_ALREADY_RUNNING on second start"` AND `src/engine/game-loop/game-loop.test.ts::"throws GAME_LOOP_NOT_RUNNING when paused/resumed/stopped before start"` AND `src/engine/game-loop/game-loop.test.ts::"callback errors emit loop:error and do not stop the loop"` AND `src/engine/game-loop/game-loop.test.ts::"records a tick_duration_ms histogram sample per tick"`
---
### Requirement: PluginManager lifecycle registry
The package SHALL provide `PluginManager` accepting a typed `EnginePlugin`
contract. Install SHALL reject duplicates with `DUPLICATE_PLUGIN`, lookup
SHALL reject unknown names with `PLUGIN_NOT_FOUND`, and every plugin
lifecycle hook (`onInstall`, `onUninstall`, `onEnable`, `onDisable`) that
throws SHALL be wrapped in `PLUGIN_LIFECYCLE_FAILED` carrying the plugin
name as context.
#### Scenario: install registers and emits OTel counter
- **WHEN** `install(plugin)` is called with a fresh plugin
- **THEN** `getPlugin(name)` returns the plugin, `onInstall` was invoked once with the engine handle, and the `game_server.plugin.lifecycle_total` counter increments with `op: "install"`
#### Scenario: duplicate install rejects
- **WHEN** `install(plugin)` is called twice with the same plugin name
- **THEN** the second call throws `GameServerError(GAME_SERVER.DUPLICATE_PLUGIN)`
#### Scenario: unknown plugin name on uninstall/enable/disable/get rejects
- **WHEN** `uninstall("nope")`, `enable("nope")`, `disable("nope")`, or `get("nope")` is called
- **THEN** the call throws `GameServerError(GAME_SERVER.PLUGIN_NOT_FOUND)`
#### Scenario: enable then disable toggles state and fires hooks
- **WHEN** an installed plugin is enabled then disabled
- **THEN** `onEnable` and then `onDisable` each fire once and `isEnabled(name)` returns the matching state
#### Scenario: plugin lifecycle errors wrap to PLUGIN_LIFECYCLE_FAILED
- **WHEN** a plugin's `onInstall` throws
- **THEN** `install` rejects with `GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)` whose `cause` is the original error and `context` includes the plugin name and hook name
#### Scenario: dependency topological install order
- **WHEN** `installAll([pluginA depends on B, pluginB])` is called
- **THEN** `pluginB.onInstall` fires before `pluginA.onInstall`
#### Scenario: missing dependency rejects
- **WHEN** `installAll([pluginA depends on Z])` is called with no `pluginZ`
- **THEN** the call throws `GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)` with the missing dependency name in `context`
#### Scenario: circular dependency rejects
- **WHEN** `installAll([A depends on B, B depends on A])` is called
- **THEN** the call throws `GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)` with `cycle` in `context`
**Verified by:** `src/engine/plugin-manager/plugin-manager.test.ts::"install registers the plugin and fires onInstall + OTel counter"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"second install with the same name rejects with DUPLICATE_PLUGIN"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"unknown plugin name rejects with PLUGIN_NOT_FOUND on uninstall/enable/disable/get"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"enable then disable toggles state and fires both hooks"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"hook errors wrap into PLUGIN_LIFECYCLE_FAILED with cause and context"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"installAll honours topological dependency order"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"installAll rejects when a declared dependency is missing"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"installAll rejects on circular dependency"`
---
### Requirement: GameEngine top-level orchestrator
The package SHALL provide `GameEngine` wiring a `DIContainer`, a
`GameLoop`, and a `PluginManager` plus opaque service slots
(`logger`, `events`, `sessionManager`, `playerManager`, `stateManager`,
`gameLogic`). Construction SHALL Zod-validate options and reject with
`INVALID_ENGINE_OPTIONS`. The engine SHALL refuse `tick()`/`stop()` before
`start()` (`ENGINE_NOT_STARTED`), refuse double `start()`
(`ENGINE_ALREADY_STARTED`), and refuse any method after `dispose()`
(`ENGINE_DISPOSED`).
#### Scenario: constructor registers core services in DIContainer
- **WHEN** the engine is constructed with a logger, events, gameLoop, pluginManager, and IdGenerator
- **THEN** each is resolvable from `engine.container` under its canonical identifier
#### Scenario: start emits OTel + transitions running state
- **WHEN** `start()` is called on a fresh engine
- **THEN** the underlying `GameLoop.start()` is invoked, `isRunning()` returns `true`, and `game_server.engine.lifecycle_total{op: "start"}` increments
#### Scenario: double start rejects
- **WHEN** `start()` is called a second time
- **THEN** the call throws `GameServerError(GAME_SERVER.ENGINE_ALREADY_STARTED)`
#### Scenario: tick without start rejects
- **WHEN** `tick()` is called before `start()`
- **THEN** the call throws `GameServerError(GAME_SERVER.ENGINE_NOT_STARTED)`
#### Scenario: dispose clears container, stops loop, blocks future calls
- **WHEN** `dispose()` is called then `start()` is called
- **THEN** the second `start()` throws `GameServerError(GAME_SERVER.ENGINE_DISPOSED)`
#### Scenario: registerPlugin delegates to PluginManager
- **WHEN** `registerPlugin(plugin)` is called
- **THEN** the underlying `PluginManager.install(plugin)` runs and `getPlugin(name)` returns it
#### Scenario: invalid options rejected at construction
- **WHEN** the engine is constructed with `playerTimeout: -1`
- **THEN** the constructor throws `GameServerError(GAME_SERVER.INVALID_ENGINE_OPTIONS)`
#### Scenario: getStats reports lifecycle + child stats
- **WHEN** `getStats()` is called on a running engine
- **THEN** the result includes `{ running: true, disposed: false, loop: <GameLoop.getStats()>, plugins: <PluginManager.getStats()> }`
**Verified by:** `src/engine/game-engine/game-engine.test.ts::"constructor registers logger/events/loop/plugins in the container"` AND `src/engine/game-engine/game-engine.test.ts::"start invokes loop.start, transitions running, increments OTel"` AND `src/engine/game-engine/game-engine.test.ts::"second start throws ENGINE_ALREADY_STARTED"` AND `src/engine/game-engine/game-engine.test.ts::"tick before start throws ENGINE_NOT_STARTED"` AND `src/engine/game-engine/game-engine.test.ts::"dispose blocks future start with ENGINE_DISPOSED"` AND `src/engine/game-engine/game-engine.test.ts::"registerPlugin delegates to the underlying PluginManager"` AND `src/engine/game-engine/game-engine.test.ts::"invalid options throw INVALID_ENGINE_OPTIONS"` AND `src/engine/game-engine/game-engine.test.ts::"getStats reports running/disposed/loop/plugins"`
---
### Requirement: Subpath exports
The package SHALL expose the three feature directories via additive
`exports` map entries so consumers can tree-shake.
#### Scenario: each feature dir is independently importable
- **WHEN** a consumer imports from `@suaveplan/game-server/engine/game-engine`, `@suaveplan/game-server/engine/game-loop`, and `@suaveplan/game-server/engine/plugin-manager`
- **THEN** the import resolves to the corresponding feature barrel without pulling sibling code
**Verified by:** `packages/gaming/games/game-server/package.json::"exports./engine/game-engine"` AND `packages/gaming/games/game-server/package.json::"exports./engine/game-loop"` AND `packages/gaming/games/game-server/package.json::"exports./engine/plugin-manager"`
<!-- markdownlint-enable MD024 -->
---
## From change: `game-server-engine-sessions`
### Requirement: SessionGraphNode internal mutable graph node
The package SHALL provide `SessionGraphNode` as the internal mutable
representation of a hierarchical session. The class SHALL be structurally
compatible with Wave A's read-only `SessionNode` interface (it
implements `SessionNode`). All wall-clock reads SHALL route through an
injected `Clock`; the class SHALL NOT call `Date.now()` directly. The
constructor SHALL accept an optional preserved-timestamps slot used by
`SessionStore.restore` to keep `createdAt` / `updatedAt` byte-equivalent
across persistence round-trips.
#### Scenario: constructs with clock-supplied timestamps by default
- **WHEN** a node is constructed without the `timestamps` arg
- **THEN** `createdAt` and `updatedAt` are both set from the injected `Clock`
#### Scenario: addChild is idempotent
- **WHEN** `addChild(c)` is called twice with the same child
- **THEN** the child appears exactly once in `children`
#### Scenario: tree-walking helpers respect depth and ancestry
- **WHEN** `getDepth`, `getDescendants` (DFS pre-order), `getAncestors`, and `getRoot` are called on a populated tree
- **THEN** each helper returns the documented walk; `isAncestorOf` / `isDescendantOf` exclude self (strict)
#### Scenario: toJSON serialises root with null parentId
- **WHEN** a root node is serialised
- **THEN** `parentId` is `null`; for a non-root the parent's id is preserved
**Verified by:** `src/engine/session-node/session-node.test.ts::"constructs with clock-supplied createdAt/updatedAt when no timestamps passed"` AND `src/engine/session-node/session-node.test.ts::"preserves timestamps when explicitly passed (restore path)"` AND `src/engine/session-node/session-node.test.ts::"addChild is idempotent — re-adding the same child is a no-op"` AND `src/engine/session-node/session-node.test.ts::"getDepth returns 0 for root, distance otherwise"` AND `src/engine/session-node/session-node.test.ts::"getDescendants returns DFS pre-order array"` AND `src/engine/session-node/session-node.test.ts::"isAncestorOf / isDescendantOf are strict (exclude self)"` AND `src/engine/session-node/session-node.test.ts::"toJSON serialises a node with null parentId for a root"`
---
### Requirement: SessionTree hierarchical graph
The package SHALL provide `SessionTree` owning a `Map<id, SessionGraphNode>`
plus a `Set<rootId>` and exposing the mutation surface used by
`SessionStore`. The `createSession` boundary SHALL parse `SessionData`
through Zod and reject failures with `INVALID_SESSION_DATA`. Duplicate
ids SHALL throw `DUPLICATE_SESSION_ID`; unknown parents SHALL throw
`PARENT_SESSION_NOT_FOUND`. `destroySession` SHALL cascade bottom-up
through every descendant.
#### Scenario: createSession registers a root and emits session:created
- **WHEN** `createSession(null, data)` is called with valid data
- **THEN** the node is registered as a root and `session:created` is emitted
#### Scenario: invalid SessionData rejects with INVALID_SESSION_DATA
- **WHEN** `createSession` is called with `id: ""` or any schema-failing input
- **THEN** a `GameServerError(GAME_SERVER.INVALID_SESSION_DATA)` is thrown
#### Scenario: duplicate id rejects with DUPLICATE_SESSION_ID
- **WHEN** a second `createSession` reuses an existing id
- **THEN** `GameServerError(GAME_SERVER.DUPLICATE_SESSION_ID)` is thrown
#### Scenario: unknown parent rejects with PARENT_SESSION_NOT_FOUND
- **WHEN** `createSession(parentId, ...)` references a missing id
- **THEN** `GameServerError(GAME_SERVER.PARENT_SESSION_NOT_FOUND)` is thrown
#### Scenario: destroySession cascades bottom-up
- **WHEN** `destroySession` is called on an ancestor with descendants
- **THEN** `session:destroyed` events fire in deepest-first order
#### Scenario: attachRestored re-attaches a deserialised node without firing session:created
- **WHEN** `attachRestored(node, parentId)` is called during `SessionStore.restore`
- **THEN** the node is inserted into the index, the `session:created` event is NOT emitted, and a duplicate id throws `DUPLICATE_SESSION_ID`
#### Scenario: validateIntegrity catches corrupted invariants
- **WHEN** the bidirectional parent/children invariant is forced into an inconsistent state
- **THEN** `validateIntegrity()` returns `{valid: false, errors: [...]}` with at least one diagnostic
**Verified by:** `src/engine/session-tree/session-tree.test.ts::"createSession registers a root and emits session:created"` AND `src/engine/session-tree/session-tree.test.ts::"createSession rejects invalid SessionData with INVALID_SESSION_DATA"` AND `src/engine/session-tree/session-tree.test.ts::"createSession rejects duplicate ids with DUPLICATE_SESSION_ID"` AND `src/engine/session-tree/session-tree.test.ts::"createSession rejects unknown parents with PARENT_SESSION_NOT_FOUND"` AND `src/engine/session-tree/session-tree.test.ts::"destroySession cascades through descendants bottom-up"` AND `src/engine/session-tree/session-tree.test.ts::"attachRestored attaches a pre-built node without firing session:created"` AND `src/engine/session-tree/session-tree.test.ts::"validateIntegrity flags a tampered parent pointer"`
---
### Requirement: SessionStore implements ISessionManager
The package SHALL provide `SessionStore` implementing Wave A's
`ISessionManager` interface (`createSession`, `getSession`,
`destroySession`, `getSessionsByType`, `getChildren`, `getParent`,
`updateMetadata`, `getAllSessions`, `persist`, `restore`). The store
SHALL accept an injectable `SessionPersistence` adapter and fall back to
an in-memory default when none is supplied. Construction SHALL
Zod-validate options and reject with `INVALID_SESSION_STORE_OPTIONS`.
`restore` SHALL handle arbitrary record orderings via a fixed-point walk
that attaches any record whose parent is already present.
#### Scenario: ISessionManager conformance harness passes
- **WHEN** `runISessionManagerConformance(() => new SessionStore(deps))` is invoked
- **THEN** every scenario in the harness reports `passed: true`
#### Scenario: invalid options reject with INVALID_SESSION_STORE_OPTIONS
- **WHEN** `new SessionStore(deps, { storageKey: "" })`
- **THEN** the constructor throws `GameServerError(GAME_SERVER.INVALID_SESSION_STORE_OPTIONS)`
#### Scenario: persist + restore round-trip preserves the tree
- **WHEN** a populated `SessionStore` is persisted, then cleared, then restored
- **THEN** the restored tree has the same node count and parent/child relationships
#### Scenario: restore handles permuted record orderings
- **WHEN** a persisted payload lists children before their parents
- **THEN** the fixed-point walk attaches every record and the final tree shape is identical to the pre-persist state
#### Scenario: restore logs orphan records and skips them
- **WHEN** a payload references a parent that doesn't exist in the same payload
- **THEN** the orphan id is logged via `logger.warn` and the rest of the records still come online
**Verified by:** `src/engine/session-store/session-store.test.ts::"ISessionManager conformance harness passes"` AND `src/engine/session-store/session-store.test.ts::"rejects invalid options with INVALID_SESSION_STORE_OPTIONS"` AND `src/engine/session-store/session-store.test.ts::"persist+restore round-trips with the in-memory default"` AND `src/engine/session-store/session-store.test.ts::"restore handles permuted record ordering via fixed-point walk"` AND `src/engine/session-store/session-store.test.ts::"restore logs orphan records as warnings and skips them"`
---
### Requirement: SessionLifecycle per-session FSM
The package SHALL provide `SessionLifecycle` implementing a per-session
finite-state machine with the closed three-state vocabulary
`lobby | active | ended`. The transition table SHALL be:
`lobby → active`, `lobby → ended`, `active → ended`. Every other pair
SHALL be rejected with `INVALID_SESSION_TRANSITION`. Transitions out of
`ended` SHALL throw `SESSION_ALREADY_ENDED`. Transitions on unknown ids
SHALL throw `SESSION_NOT_FOUND`. Every transition SHALL emit a
`session:transitioned` event and record both
`game_server.session.lifecycle_total` (counter) and
`game_server.session.dwell_ms` (histogram) OTel instruments.
#### Scenario: register places the session in lobby
- **WHEN** `register(id)` is called
- **THEN** the new entry is in state `lobby` with `enteredStateAt = clock()`
#### Scenario: transition validates against the allow-table
- **WHEN** `transition("s1", "active")` is called on a `lobby` entry
- **THEN** the FSM advances to `active` and emits `session:transitioned`
#### Scenario: backwards transition rejects with INVALID_SESSION_TRANSITION
- **WHEN** `transition("s1", "lobby")` is called on an `active` entry
- **THEN** `GameServerError(GAME_SERVER.INVALID_SESSION_TRANSITION)` is thrown
#### Scenario: transition out of ended rejects with SESSION_ALREADY_ENDED
- **WHEN** `transition("s1", "active")` is called on an `ended` entry
- **THEN** `GameServerError(GAME_SERVER.SESSION_ALREADY_ENDED)` is thrown
#### Scenario: unknown id rejects with SESSION_NOT_FOUND
- **WHEN** `transition("nope", "active")` is called
- **THEN** `GameServerError(GAME_SERVER.SESSION_NOT_FOUND)` is thrown
#### Scenario: dwell histogram receives a sample per transition
- **WHEN** the FSM transitions out of `lobby`
- **THEN** `game_server.session.dwell_ms` records one sample tagged `state: "lobby"`
#### Scenario: forget drops the entry but preserves audit history
- **WHEN** `forget(id)` is called after one or more transitions
- **THEN** `getState(id)` returns `null` while `getHistoryFor(id)` still returns the transitions
**Verified by:** `src/engine/session-lifecycle/session-lifecycle.test.ts::"register creates an entry in lobby state"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"lobby → active transition succeeds and emits event"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"active → lobby (backwards) is rejected with INVALID_SESSION_TRANSITION"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"transition out of ended is rejected with SESSION_ALREADY_ENDED"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"transition on unknown id is rejected with SESSION_NOT_FOUND"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"emits session.dwell_ms histogram sample for the state being left"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"history preserves transitions across forget()"`
---
### Requirement: PlayerManager implements IPlayerManager
The package SHALL provide `PlayerManager` implementing Wave A's
`IPlayerManager` interface (11 methods). Construction SHALL Zod-validate
options and reject with `INVALID_PLAYER_MANAGER_OPTIONS`.
`startHeartbeatMonitor` SHALL Zod-validate the interval and reject
non-positive integers with `INVALID_HEARTBEAT_INTERVAL`. The heartbeat
monitor SHALL schedule via the injected `TimersLike`, NOT `setInterval`.
`checkTimeouts` SHALL transition connected players past
`timeoutThresholdMs` to `timeout` and evict disconnected entries past
`reconnectionGraceMs`. A `connect` against an existing disconnected entry
within the grace window SHALL walk the reconnection path
(preserving `connectedAt`).
#### Scenario: IPlayerManager conformance harness passes
- **WHEN** `runIPlayerManagerConformance(() => new PlayerManager(deps))` is invoked
- **THEN** every scenario in the harness reports `passed: true`
#### Scenario: invalid options reject with INVALID_PLAYER_MANAGER_OPTIONS
- **WHEN** `new PlayerManager(deps, { timeoutThresholdMs: 0 })`
- **THEN** the constructor throws `GameServerError(GAME_SERVER.INVALID_PLAYER_MANAGER_OPTIONS)`
#### Scenario: startHeartbeatMonitor rejects non-positive intervals
- **WHEN** `startHeartbeatMonitor(0)` or `startHeartbeatMonitor(1.5)` is called
- **THEN** the call throws `GameServerError(GAME_SERVER.INVALID_HEARTBEAT_INTERVAL)`
#### Scenario: heartbeat monitor schedules via TimersLike
- **WHEN** `startHeartbeatMonitor(50)` is called
- **THEN** `TimersLike.setTimer` is invoked with delay 50; firing a due timer triggers `checkTimeouts` and the monitor self-reschedules
#### Scenario: stopHeartbeatMonitor cancels the next scheduled tick
- **WHEN** `stopHeartbeatMonitor()` is called after `startHeartbeatMonitor`
- **THEN** the pending timer is cleared and the captured callback, if fired post-stop, does not re-schedule itself
#### Scenario: connect walks the reconnection path for disconnected entries
- **WHEN** `connect("p1", "s1", { region: "eu" })` is called after `disconnect("p1", "x")`
- **THEN** `connectedAt` is preserved from the original entry, metadata is shallow-merged, and `player:reconnected` is emitted
#### Scenario: checkTimeouts evicts disconnected entries past grace window
- **WHEN** `checkTimeouts` is called with `now - conn.disconnectedAt > reconnectionGraceMs`
- **THEN** the connection is deleted; a subsequent `getConnection` returns `null`
**Verified by:** `src/engine/player-manager/player-manager.test.ts::"IPlayerManager conformance harness passes"` AND `src/engine/player-manager/player-manager.test.ts::"rejects invalid options with INVALID_PLAYER_MANAGER_OPTIONS"` AND `src/engine/player-manager/player-manager.test.ts::"startHeartbeatMonitor rejects non-positive intervals"` AND `src/engine/player-manager/player-manager.test.ts::"startHeartbeatMonitor schedules ticks via TimersLike"` AND `src/engine/player-manager/player-manager.test.ts::"heartbeat tick stops re-scheduling if monitor was stopped mid-tick"` AND `src/engine/player-manager/player-manager.test.ts::"connect on a disconnected player walks the reconnection path"` AND `src/engine/player-manager/player-manager.test.ts::"checkTimeouts evicts disconnected entries past grace window"`
---
### Requirement: Subpath exports
The package SHALL expose each new feature directory via additive
`exports` map entries so consumers can tree-shake.
#### Scenario: each feature dir is independently importable
- **WHEN** a consumer imports from `@suaveplan/game-server/engine/session-lifecycle`, `@suaveplan/game-server/engine/session-node`, `@suaveplan/game-server/engine/session-store`, `@suaveplan/game-server/engine/session-tree`, or `@suaveplan/game-server/engine/player-manager`
- **THEN** the import resolves to the corresponding feature barrel
**Verified by:** `packages/gaming/games/game-server/package.json::"exports./engine/session-lifecycle"` AND `packages/gaming/games/game-server/package.json::"exports./engine/session-node"` AND `packages/gaming/games/game-server/package.json::"exports./engine/session-store"` AND `packages/gaming/games/game-server/package.json::"exports./engine/session-tree"` AND `packages/gaming/games/game-server/package.json::"exports./engine/player-manager"`
<!-- markdownlint-enable MD024 -->
---
## From change: `game-server-state`
### Requirement: Observable state container
The package SHALL expose a `StateManager<TState extends object>` class
that holds a single typed state value, returns deep-frozen snapshots
through `getState`/`get`, and emits a `StateChangeEvent` on every
mutation. Listener faults SHALL be isolated and counted but MUST NOT
propagate or skip remaining listeners.
#### Scenario: getState returns deep-frozen snapshot
- **WHEN** a caller invokes `getState()`
- **THEN** the returned value is `Object.isFrozen`-true at every level
reachable through plain object/array traversal.
#### Scenario: listener errors do not interrupt other listeners
- **WHEN** one subscribed listener throws and another is registered
- **THEN** the non-throwing listener is still invoked exactly once per
mutation and the `listener_error_total` OTel counter increments.
**Verified by:**
`src/state/state-manager/state-manager.test.ts::"getState returns deep-frozen current state"`
AND
`src/state/state-manager/state-manager.test.ts::"listener errors are isolated and counted"`
AND
`src/state/state-manager/state-manager.test.ts::"event carries previous and current snapshots and id+timestamp"`.
---
### Requirement: Snapshot restore with typed validation
`StateManager.restore(snapshot, validator?)` SHALL run the optional
predicate before cloning. A failing predicate SHALL throw
`GameServerError(GAME_SERVER.INVALID_SNAPSHOT)` and leave the manager
state unchanged. A clone failure SHALL be wrapped as
`GameServerError(GAME_SERVER.STATE_RESTORE_FAILED)`.
#### Scenario: validator rejects malformed snapshot
- **WHEN** `restore` is invoked with a snapshot the validator rejects
- **THEN** a `GameServerError` with code `GAME_SERVER.INVALID_SNAPSHOT`
is thrown and `getState()` returns the previous state unchanged.
#### Scenario: clone failure wrapped as STATE_RESTORE_FAILED
- **WHEN** the snapshot has a throwing getter that triggers during clone
- **THEN** a `GameServerError` with code
`GAME_SERVER.STATE_RESTORE_FAILED` is thrown.
**Verified by:**
`src/state/state-manager/state-manager.test.ts::"restore throws INVALID_SNAPSHOT when validator fails"`
AND
`src/state/state-manager/state-manager.test.ts::"restore wraps clone failures as STATE_RESTORE_FAILED"`.
---
### Requirement: Structural diff and patch utility
The package SHALL expose a `StateDiffer` class with `diff(a, b)`,
`patch(s, d)`, `isEmpty(d)`, and `mergeDeltas(deltas)` methods that
produce and consume a discriminated `DiffOperation` grammar (`add`,
`remove`, `replace`). For any plain-JSON pair `(a, b)`, `patch(a, diff(a, b))`
SHALL be deep-equal to `b`.
#### Scenario: round-trip on nested object + array state
- **WHEN** `patch(a, diff(a, b))` is computed for any pair of plain
JSON-serialisable values `a` and `b`
- **THEN** the result is deep-equal to `b`.
#### Scenario: typed errors on diff/patch failure
- **WHEN** an internal traversal throws
- **THEN** the error surfaces as `GameServerError` with code
`GAME_SERVER.STATE_DIFF_FAILED` or `GAME_SERVER.STATE_PATCH_FAILED`
respectively.
**Verified by:**
`src/state/state-differ/state-differ.test.ts::"round-trips: patch(a, diff(a, b)) deep-equals b"`
AND
`src/state/state-differ/state-differ.test.ts::"wraps internal failures as STATE_DIFF_FAILED"`
AND
`src/state/state-differ/state-differ.test.ts::"wraps patch failures as STATE_PATCH_FAILED"`.
---
### Requirement: Versioned, Zod-validated delta compressor
The package SHALL expose a `DeltaCompressor` class that produces a
`CompressedDelta` packet `{ version, timestamp, data: Uint8Array, stats }`
from any pair of plain-JSON snapshots, and decompresses such a packet
back to a snapshot. The wire payload SHALL be Zod-validated on receipt.
Version mismatches and parse failures SHALL surface as
`GameServerError(GAME_SERVER.STATE_DECOMPRESS_FAILED)`.
#### Scenario: round-trip via compress + decompress
- **WHEN** a delta produced by `compress(a, b)` is applied with
`decompress(a, packet)`
- **THEN** the result equals `b`.
#### Scenario: version mismatch raises typed error
- **WHEN** `decompress` receives a packet whose `version` differs from
`COMPRESSED_DELTA_VERSION`
- **THEN** a `GameServerError` with code
`GAME_SERVER.STATE_DECOMPRESS_FAILED` is thrown.
#### Scenario: schema-invalid payload raises typed error
- **WHEN** `decompress` receives a packet whose data parses to a value
that fails the operation schema
- **THEN** a `GameServerError` with code
`GAME_SERVER.STATE_DECOMPRESS_FAILED` is thrown.
**Verified by:**
`src/state/delta-compressor/delta-compressor.test.ts::"round-trips compress + decompress"`
AND
`src/state/delta-compressor/delta-compressor.test.ts::"throws STATE_DECOMPRESS_FAILED on version mismatch"`
AND
`src/state/delta-compressor/delta-compressor.test.ts::"throws STATE_DECOMPRESS_FAILED on schema-invalid payload"`.
---
### Requirement: Transport-agnostic state synchroniser
The package SHALL expose a `StateSync<TState>` class that orchestrates
delta and full-snapshot synchronisation between a `StateManager` and an
injectable `StateSyncTransport`. The first call to `sync()` SHALL send
a full snapshot. Subsequent calls SHALL send a delta unless the delta
payload exceeds the configurable `maxDeltaSize`, in which case a full
snapshot SHALL be sent instead. Transport send failures SHALL surface
as `GameServerError(GAME_SERVER.STATE_SYNC_TRANSPORT_FAILED)`.
#### Scenario: initial sync sends full snapshot, subsequent sends delta
- **WHEN** `sync()` is called twice on a freshly-constructed `StateSync`
with an intervening `StateManager.update`
- **THEN** the first transport message has `kind === "full"` and the
second has `kind === "delta"`.
#### Scenario: large delta forces full-snapshot fallback
- **WHEN** the computed delta exceeds `maxDeltaSize`
- **THEN** a full snapshot is sent instead.
#### Scenario: transport failure wrapped as STATE_SYNC_TRANSPORT_FAILED
- **WHEN** `transport.send` rejects or throws
- **THEN** a `GameServerError` with code
`GAME_SERVER.STATE_SYNC_TRANSPORT_FAILED` propagates to the caller.
**Verified by:**
`src/state/state-sync/state-sync.test.ts::"first sync() sends a full snapshot"`
AND
`src/state/state-sync/state-sync.test.ts::"subsequent sync() sends a delta"`
AND
`src/state/state-sync/state-sync.test.ts::"falls back to full snapshot when delta exceeds maxDeltaSize"`
AND
`src/state/state-sync/state-sync.test.ts::"wraps transport send errors as STATE_SYNC_TRANSPORT_FAILED"`.
---
## From change: `game-server-network`
### Requirement: Unified network adapter contract
The package SHALL expose a `NetworkAdapter` class that wraps a structural
`Transport` interface and provides single-initialisation, multi-subscriber
fan-out, Zod-validated outbound + inbound frames, and OpenTelemetry
counters for messages sent / received / errors.
#### Scenario: single-initialisation guard
- **WHEN** `initialize(...)` is called twice without an intervening
`dispose()` / `disconnect()`
- **THEN** the second call throws `GameServerError` with code
`GAME_SERVER.TRANSPORT_ALREADY_INITIALIZED`.
#### Scenario: send before initialise
- **WHEN** `send(...)` is called before `initialize(...)`
- **THEN** it rejects with code `GAME_SERVER.TRANSPORT_NOT_INITIALIZED`.
#### Scenario: send while transport disconnected
- **WHEN** the underlying transport never fires `onConnect` and `send`
is invoked
- **THEN** it rejects with code `GAME_SERVER.TRANSPORT_NOT_CONNECTED`.
#### Scenario: outbound message validation
- **WHEN** a frame with empty `type` is passed to `send`
- **THEN** it rejects with code `GAME_SERVER.INVALID_MESSAGE_FORMAT`.
#### Scenario: fan-out and handler isolation
- **WHEN** multiple subscribers are attached via `onMessage` and one of
them throws
- **THEN** every other subscriber still receives the frame and the
failure is logged through the injected logger.
**Verified by:**
`src/network/network-adapter/network-adapter.test.ts::"initialises a transport and connects"`
AND
`src/network/network-adapter/network-adapter.test.ts::"rejects double-initialisation"`
AND
`src/network/network-adapter/network-adapter.test.ts::"rejects send before initialize"`
AND
`src/network/network-adapter/network-adapter.test.ts::"rejects send when not connected"`
AND
`src/network/network-adapter/network-adapter.test.ts::"rejects malformed outgoing message"`
AND
`src/network/network-adapter/network-adapter.test.ts::"fans inbound messages to every subscriber"`
AND
`src/network/network-adapter/network-adapter.test.ts::"isolates handler errors and still delivers to others"`
AND
`src/network/network-adapter/network-adapter.test.ts::"drops malformed inbound frames"`
AND
`src/network/network-adapter/network-adapter.test.ts::"exposes stats counts"`.
---
### Requirement: Reconnection with exponential backoff
The package SHALL expose a `ReconnectionManager` that schedules
reconnection attempts through an injectable `TimersLike`, delegates
backoff calculation to `@suaveplan/retry`'s `createExponentialBackoff`,
and reports outcomes through OTel counter + histogram.
#### Scenario: success on first attempt
- **WHEN** the supplied `reconnect` callback resolves on the first try
- **THEN** the manager resets to `{ attempts: 0, isReconnecting: false }`
and stops scheduling.
#### Scenario: exponential retry until success
- **WHEN** `reconnect` fails twice and then succeeds
- **THEN** the manager invokes the callback exactly three times,
doubling the envelope between attempts.
#### Scenario: exhaustion
- **WHEN** `maxAttempts` is `n` and `reconnect` always rejects
- **THEN** the manager emits `network:reconnect-exhausted` after `n`
failures and stops scheduling further attempts.
#### Scenario: double-start guard
- **WHEN** `start()` is invoked while a reconnection is already in
progress
- **THEN** it throws `GameServerError` with code
`GAME_SERVER.RECONNECT_IN_PROGRESS`.
#### Scenario: use after dispose
- **WHEN** `start()` or `forceReconnect()` is invoked after `dispose()`
- **THEN** it throws `GameServerError` with code
`GAME_SERVER.RECONNECT_EXHAUSTED`.
**Verified by:**
`src/network/reconnection-manager/reconnection-manager.test.ts::"succeeds on first attempt"`
AND
`src/network/reconnection-manager/reconnection-manager.test.ts::"retries with exponential backoff on failure"`
AND
`src/network/reconnection-manager/reconnection-manager.test.ts::"exhausts after maxAttempts and emits exhausted event"`
AND
`src/network/reconnection-manager/reconnection-manager.test.ts::"rejects start when already reconnecting"`
AND
`src/network/reconnection-manager/reconnection-manager.test.ts::"start throws after dispose"`
AND
`src/network/reconnection-manager/reconnection-manager.test.ts::"forceReconnect throws after dispose"`.
---
### Requirement: WebSocket transport wrapper
The package SHALL expose a `WebSocketTransport` class implementing the
unified `Transport` contract by wrapping a structural `UnderlyingWebSocket`
client. JSON framing SHALL be applied to outgoing messages and inbound
parse failures SHALL surface through `onError` rather than throwing.
#### Scenario: connect forwards protocols
- **WHEN** `connect(url, { protocols: ["v1"] })` is invoked
- **THEN** the underlying client receives the URL and protocols.
#### Scenario: send rejects when not connected
- **WHEN** `send(...)` is invoked before `connect(...)`
- **THEN** it rejects with code `GAME_SERVER.TRANSPORT_NOT_CONNECTED`.
#### Scenario: parse error surfaces through onError
- **WHEN** the underlying client delivers a non-JSON frame
- **THEN** the registered `onError` handler receives a typed
`GameServerError` with code `GAME_SERVER.INVALID_MESSAGE_FORMAT`.
**Verified by:**
`src/network/websocket-transport/websocket-transport.test.ts::"connects with protocols"`
AND
`src/network/websocket-transport/websocket-transport.test.ts::"send serialises message as JSON"`
AND
`src/network/websocket-transport/websocket-transport.test.ts::"send rejects when disconnected"`
AND
`src/network/websocket-transport/websocket-transport.test.ts::"reports parse errors through onError"`
AND
`src/network/websocket-transport/websocket-transport.test.ts::"propagates connect/disconnect callbacks"`.
---
### Requirement: WebRTC transport wrapper
The package SHALL expose a `WebRTCTransport` class implementing the
unified `Transport` contract by wrapping a structural `UnderlyingWebRTC`
client. The wrapper SHALL provide pass-through signalling helpers
(`addIceCandidate`, `applyAnswer`) and SHALL throw a typed error when
the underlying client does not implement them.
#### Scenario: connect with options bag
- **WHEN** `connect(url, { iceServers: [...] })` is invoked
- **THEN** the options bag is forwarded verbatim to the underlying
client's `open(...)`.
#### Scenario: signalling pass-through
- **WHEN** `addIceCandidate(c)` and `applyAnswer(a)` are invoked on a
client that implements them
- **THEN** the candidate / answer is delivered verbatim to the client.
#### Scenario: signalling unsupported
- **WHEN** the underlying client omits `addIceCandidate` / `applyAnswer`
- **THEN** the wrapper throws `GameServerError` with code
`GAME_SERVER.TRANSPORT_NOT_INITIALIZED`.
**Verified by:**
`src/network/webrtc-transport/webrtc-transport.test.ts::"connects with options bag"`
AND
`src/network/webrtc-transport/webrtc-transport.test.ts::"send serialises message as JSON"`
AND
`src/network/webrtc-transport/webrtc-transport.test.ts::"addIceCandidate forwards to underlying"`
AND
`src/network/webrtc-transport/webrtc-transport.test.ts::"applyAnswer forwards to underlying"`
AND
`src/network/webrtc-transport/webrtc-transport.test.ts::"addIceCandidate throws when unsupported"`
AND
`src/network/webrtc-transport/webrtc-transport.test.ts::"applyAnswer throws when unsupported"`.
---
### Requirement: Extended error code registry
The package SHALL register six additional `GAME_SERVER.*` codes
introduced by the network slice and SHALL expose them on the
`GAME_SERVER_CODES` constant alongside the existing core codes.
#### Scenario: registry shape
- **WHEN** the test suite inspects `GAME_SERVER_CODES`
- **THEN** it contains `TRANSPORT_ALREADY_INITIALIZED`,
`TRANSPORT_NOT_INITIALIZED`, `TRANSPORT_NOT_CONNECTED`,
`INVALID_MESSAGE_FORMAT`, `RECONNECT_EXHAUSTED`,
`RECONNECT_IN_PROGRESS`, each fully-qualified as
`GAME_SERVER.<NAME>`.
**Verified by:**
`src/errors/game-server-error.test.ts::"registers all expected codes under GAME_SERVER namespace"`.
---
## From change: `game-server-matchmaking`
### Requirement: QueueManager primary-priority queue
`QueueManager` SHALL maintain an in-memory priority queue keyed by an
arbitrary string (typically a game mode or queue type) and prioritise
entries by a documented mix of premium bonus, wait-time bonus, and an
optional caller-supplied base offset.
#### Scenario: addToQueue accepts a valid request
- **WHEN** a caller invokes `addToQueue("ranked", {...validRequest})`
- **THEN** the request is inserted into the queue and the queue's
size grows by one
- **AND** the `game_server.matchmaking.queue_enqueue_total` counter is
incremented
#### Scenario: addToQueue rejects when the queue is at capacity
- **GIVEN** a `QueueManager` configured with `maxQueueSize: 1`
- **AND** one request has been accepted
- **WHEN** a second request is submitted
- **THEN** `addToQueue` throws
`GameServerError(GAME_SERVER.MATCHMAKING_QUEUE_FULL)`
- **AND** the `game_server.matchmaking.queue_reject_total{reason=full}`
counter is incremented
#### Scenario: findMatch atomically pops the top N entries
- **GIVEN** three queued players
- **WHEN** the caller invokes `findMatch("ranked", 2)`
- **THEN** the top two entries (by priority) are returned in order
- **AND** the queue's size shrinks by two
- **AND** the wait-time histogram is sampled twice
### Requirement: SkillMatcher Elo-style closest-rating selection
`SkillMatcher` SHALL select the candidate closest in rating to the
seeker, widening the acceptance band proportional to the seeker's
waiting time and respecting a configurable hard ceiling.
#### Scenario: closest opponent is returned
- **GIVEN** a seeker rated 1500 and candidates rated 1400, 1510, 1700
- **AND** the configured `ratingRange` is 500
- **WHEN** the caller invokes `findMatch(seeker, candidates)`
- **THEN** the matcher returns the 1510-rated candidate
#### Scenario: rejection on a non-finite rating
- **WHEN** the caller submits a `SkillEntry` whose rating is `NaN`
- **THEN** `findMatch` throws
`GameServerError(GAME_SERVER.INVALID_SKILL_SCORE)`
### Requirement: SpectatorManager per-session cap
`SpectatorManager` SHALL enforce a configurable per-session spectator
cap and surface typed-error rejections on cap violation or duplicate
spectator registration.
#### Scenario: cap rejection
- **GIVEN** a `SpectatorManager` configured with
`maxSpectatorsPerSession: 1`
- **AND** one spectator has been added to session "s1"
- **WHEN** a second spectator is added to the same session
- **THEN** `addSpectator` throws
`GameServerError(GAME_SERVER.SPECTATOR_LIMIT_REACHED)`
#### Scenario: duplicate spectator rejection
- **GIVEN** a spectator "alice" already attached to session "s1"
- **WHEN** `addSpectator("s1", "alice", perms)` is invoked again
- **THEN** the call throws
`GameServerError(GAME_SERVER.INVALID_SPECTATOR_REQUEST)`
### Requirement: WinnerStaysOnManager streak tracking
`WinnerStaysOnManager` SHALL track the current champion, their
consecutive win streak, and a FIFO challenger queue, and expose an
`isChampionRotationDue` flag once the streak meets `maxWinStreak`.
#### Scenario: throne transfer
- **GIVEN** "alice" recorded as winner against "bob"
- **WHEN** the manager records "bob" as winner against "alice"
- **THEN** the champion becomes "bob" with `winStreak === 1`
- **AND** "alice" is enqueued at the back of the challenger list
### Requirement: MatchmakingEngine façade
`MatchmakingEngine` SHALL expose `createMatch(players, criteria,
algorithm)` and `createMatchFromQueue(queueKey, criteria, algorithm)`
that pick the algorithm, validate inputs via Zod, mint a stable
`matchId` via an injected `IdGenerator`, and notify any registered
`onMatchFound` listener.
#### Scenario: quick algorithm matches FIFO
- **GIVEN** three queued players and `MatchCriteria` of
`{ minPlayers: 2, maxPlayers: 4 }`
- **WHEN** `createMatch(players, criteria, "quick")` is invoked
- **THEN** the engine returns a `Match` containing all three players
#### Scenario: skill-based algorithm uses the largest viable group
- **GIVEN** four players with ratings 1000, 1100, 1200, 2500
- **AND** `MatchCriteria` of `{ minPlayers: 2, maxPlayers: 4, skillRange: 250 }`
- **WHEN** `createMatch(players, criteria, "skill-based")` is invoked
- **THEN** the engine returns the three rated 1000/1100/1200
#### Scenario: unknown algorithm rejection
- **WHEN** the caller passes `algorithm: "nuke"`
- **THEN** `createMatch` throws
`GameServerError(GAME_SERVER.UNKNOWN_MATCHING_ALGORITHM)`
#### Scenario: createMatchFromQueue restores on shortfall
- **GIVEN** a `QueueManager` with one queued player and
`MatchCriteria` of `{ minPlayers: 2, maxPlayers: 4 }`
- **WHEN** `createMatchFromQueue("ranked", criteria)` is invoked
- **THEN** the call returns `null`
- **AND** the queue's size remains 1
---
## From change: `game-server-performance`
### Requirement: ObjectPool typed reuse
`ObjectPool<T>` SHALL recycle typed objects between a configurable
`factory`, `reset`, and optional `dispose` to dampen GC pressure, and
SHALL enforce a configurable `maxSize` cap by raising typed errors
when callers misuse the pool.
#### Scenario: pre-allocation populates the pool
- **GIVEN** an `ObjectPool` configured with `initialSize: 2` and
`preAllocate: true`
- **WHEN** the pool is constructed
- **THEN** `getStats().available` reports `2`
#### Scenario: acquire returns a recycled object
- **GIVEN** a pool with two pre-allocated objects
- **WHEN** the caller invokes `acquire()`
- **THEN** the returned object came from the pool
- **AND** `getStats().totalReused` reports `1`
#### Scenario: cap rejection
- **GIVEN** a pool with `maxSize: 1` and one object outstanding
- **WHEN** the caller invokes `acquire()` again
- **THEN** the call throws
`GameServerError(GAME_SERVER.POOL_EXHAUSTED)`
#### Scenario: foreign-object release rejection
- **WHEN** the caller releases an object not handed out by this pool
- **THEN** `release` throws
`GameServerError(GAME_SERVER.RELEASE_FOREIGN_OBJECT)`
#### Scenario: dispose lockout
- **GIVEN** a disposed `ObjectPool`
- **WHEN** the caller invokes `acquire()`, `release(obj)`, or `clear()`
- **THEN** each call throws
`GameServerError(GAME_SERVER.POOL_DISPOSED)`
### Requirement: SessionPool plain-data session recycling
`SessionPool` SHALL pool plain-data `PooledSession` records and
expose a `acquireSession(data)` API that stamps the borrowed record
with the caller-supplied identity and metadata before returning it.
#### Scenario: acquireSession stamps the record
- **WHEN** the caller invokes
`acquireSession({ id: "match-42", type: "game", metadata: {...} })`
- **THEN** the returned `PooledSession` carries `id === "match-42"`
- **AND** its `children` array is empty
- **AND** its `metadata.createdAt` mirrors the caller's input
#### Scenario: releaseSession recycles the record
- **GIVEN** a pool with `initialSize: 1, maxSize: 5`
- **WHEN** the caller acquires, mutates, and releases a session
- **AND** the caller immediately re-acquires
- **THEN** the same underlying object is returned with a freshly
empty `children` array
### Requirement: NetworkTierAdapter capability-driven tier selection
`NetworkTierAdapter` SHALL accept a non-empty, validated tier list at
construction time, track per-node capability assessments, and
recommend an optimal tier per node based on its composite score.
#### Scenario: empty tier list rejection
- **WHEN** the adapter is constructed with `tiers: []`
- **THEN** the constructor throws
`GameServerError(GAME_SERVER.INVALID_TIER_CONFIG)`
#### Scenario: score-based optimal tier
- **GIVEN** an adapter with `host`, `sub-host`, `player` tiers
- **WHEN** `getOptimalTier({score: 92, ...})` is invoked
- **THEN** the returned tier carries `nodeType === "host"`
- **AND** `getOptimalTier({score: 60, ...})` returns `sub-host`
- **AND** `getOptimalTier({score: 20, ...})` returns `player`
#### Scenario: load-balancing recommendation sort order
- **GIVEN** three registered nodes with scores 90, 60, 20
- **WHEN** `getLoadBalancingRecommendations()` is invoked
- **THEN** the result is sorted by capability score descending
- **AND** the first entry's `recommendedLoad` is `floor(90 / 10) === 9`
#### Scenario: unknown-tier rejection
- **WHEN** `getTierForNodeType("nope")` is invoked
- **THEN** the call throws
`GameServerError(GAME_SERVER.UNKNOWN_TIER)`
---
## From change: `game-server-plugins`
### Requirement: BasePlugin SHALL implement the EnginePlugin contract
`BasePlugin` is an abstract base class that implements the four lifecycle
hooks of `EnginePlugin` and forwards each to a `protected` template-method
hook subclasses override. Subclasses declare `name`, `version`, and an
optional `dependencies` array.
#### Scenario: invokes onInstallHook on install
- WHEN `BasePlugin.onInstall(ctx)` is awaited
- THEN the subclass's `onInstallHook(ctx)` runs first
- AND the plugin state transitions to `"installed"`
- AND `snapshot().installedAt` is set to `clock()`
**Verified by:** `it("invokes onInstallHook and transitions to 'installed'")`
#### Scenario: rejects any hook after destroy
- WHEN a plugin has reached `"destroyed"` (after `onUninstall`)
- AND `onInstall`/`onEnable`/`onDisable`/`onUninstall` is called again
- THEN the call rejects with `GAME_SERVER.PLUGIN_BASE_DESTROYED`
**Verified by:** `it("rejects any hook after destroy")`
#### Scenario: adopts context logger on install
- WHEN a `BasePlugin` is constructed without an explicit `logger`
- AND `onInstall(ctx)` runs
- THEN subsequent `this.logger` calls forward to `ctx.logger`
**Verified by:** `it("adopts the context logger on install when no override given")`
### Requirement: ABTestPlugin SHALL bucket players via injectable Rng
`ABTestPlugin` registers weighted experiments and assigns players to
variants by accumulating variant weights and comparing against
`rng.nextFloat() * totalWeight`. Assignment is idempotent per player.
#### Scenario: deterministic assignment with seeded Rng
- GIVEN a seeded `Rng`
- WHEN `assignPlayer(experimentId, playerId)` is called twice for the same
`playerId`
- THEN both calls return the same `variantId`
**Verified by:** `it("assigns players to a variant deterministically and idempotently")`
#### Scenario: weighted distribution honoured
- GIVEN an experiment with two equal-weight variants
- WHEN `rng.nextFloat()` returns `0.0, 0.4, 0.6, 0.9` in sequence
- THEN the first two assignments land in variant `a` and the next two in `b`
**Verified by:** `it("assigns according to weighting")`
#### Scenario: missing experiment surfaces stable error code
- WHEN `assignPlayer` or `getSummary` is called for an unknown id
- THEN `GameServerError` with code
`GAME_SERVER.AB_TEST_EXPERIMENT_NOT_FOUND` is thrown
**Verified by:** `it("requireExperiment throws on missing")`
### Requirement: AnalyticsPlugin SHALL subscribe via GameEventEmitter
`AnalyticsPlugin` subscribes to `session:created`, `session:destroyed`,
`game:started`, `game:ended`, `player:joined`, `player:left`, and
`move:processed` on install and routes structured `AnalyticsEvent` payloads
to an injectable sink.
#### Scenario: dispatches to sink on subscribed events
- WHEN `game:started` and `player:joined` are emitted
- THEN the sink receives `session:started` and `player:joined` analytics
events with `timestamp = clock()`
**Verified by:** `it("subscribes to game events on install and dispatches to sink")`
#### Scenario: marks abandoned sessions
- WHEN a session is active for longer than `abandonAfterMs`
- THEN `getMetrics().sessionsAbandoned` is incremented
**Verified by:** `it("marks abandoned sessions")`
#### Scenario: unsubscribes on uninstall
- WHEN the plugin is uninstalled
- THEN subsequent emitter events are not delivered to the sink
**Verified by:** `it("unsubscribes on uninstall — emitter events stop reaching the plugin")`
### Requirement: FeatureFlagPlugin SHALL adapt FlagManager with per-player context
`FeatureFlagPlugin` wraps a `@suaveplan/feature-flags` `FlagManager` and
merges the per-call `playerId` into the evaluation context.
#### Scenario: per-player evaluation context
- WHEN `isEnabled("flag", "p1")` is called
- THEN the underlying `FlagManager.isEnabled` is invoked with a context
whose `userId === "p1"`
**Verified by:** `it("evaluates a boolean flag via isEnabled")` and
`it("builds context with defaultContext merged")`
#### Scenario: rolling evaluation log
- WHEN evaluations exceed `maxLogEntries`
- THEN the oldest entries are dropped FIFO
**Verified by:** `it("records evaluations and trims to maxLogEntries")`
### Requirement: LoggingPlugin SHALL forward emitter events to a LoggerLike sink
`LoggingPlugin` subscribes to player, game, and session events (each
individually toggleable) and forwards them to a `LoggerLike` sink, also
retaining a bounded FIFO ring buffer.
#### Scenario: level mapping per event class
- WHEN `player:joined`, `move:rejected`, `move:processed`, and
`game:started` are emitted
- THEN the buffer carries one `info`, one `warn`, one `debug`, and one
`info` entry respectively
**Verified by:** `it("forwards game events including move levels")`
#### Scenario: ring buffer trims to maxEntries
- WHEN more than `maxEntries` events are emitted
- THEN the oldest entries are dropped
**Verified by:** `it("trims buffer to maxEntries")`
### Requirement: PersistencePlugin SHALL persist via StorageLike with Zod-validated load
`PersistencePlugin` writes per-session save records as UTF-8 JSON via
`StorageLike.set` and verifies records on `load` against a Zod schema.
#### Scenario: round-trip save → load
- WHEN a session is tracked via `game:started` + `game:state:changed`
- AND `save` is awaited then `load` is awaited
- THEN the loaded record reflects the accumulated state
**Verified by:** `it("manual save / load / delete round-trip")`
#### Scenario: corrupt record surfaces stable error code
- WHEN the stored bytes do not decode to valid JSON
- THEN `load` throws `GAME_SERVER.PERSISTENCE_LOAD_FAILED`
**Verified by:** `it("load throws on invalid JSON")` and
`it("load throws on schema mismatch")`
#### Scenario: auto-save errors do not propagate to the emitter
- GIVEN `saveOnGameEnd: true`
- WHEN the storage `set` call rejects
- THEN the error is logged and swallowed; the emitter continues to deliver
subsequent events
**Verified by:** `it("auto-save swallows storage errors to keep emitter alive")`
### Requirement: RulesPlugin SHALL evaluate three named rule sets
`RulesPlugin` exposes `evaluateMove`, `evaluateGameStart`, and
`evaluateGameEnd`, each backed by an independent `Rule[]` slot. Empty
slots short-circuit and record a passing history entry.
#### Scenario: ad-hoc evaluate rejects non-object context
- WHEN `evaluate(rules, null)` is awaited
- THEN it rejects with `GAME_SERVER.INVALID_RULE_CONTEXT`
**Verified by:** `it("evaluate rejects non-object context")`
#### Scenario: history is bounded by historyCap
- WHEN more than `historyCap` evaluations are recorded
- THEN the history retains only the most recent `historyCap` entries
**Verified by:** `it("history is bounded by historyCap")`
### Requirement: WorkflowPlugin SHALL orchestrate registered engines
`WorkflowPlugin` keys `WorkflowEngine` instances by `id` and exposes
`execute` (run-and-record), `cancel` (defer to engine), and a rolling
history with `getHistory`/`clearHistory`.
#### Scenario: duplicate registration rejected
- WHEN `registerWorkflow` is called twice with the same id
- THEN the second call throws `GAME_SERVER.DUPLICATE_WORKFLOW`
**Verified by:** `it("rejects duplicate registration")`
#### Scenario: execute records failed status on rejected promise
- GIVEN an engine whose `run` rejects
- WHEN `execute(id, data)` is awaited
- THEN the history entry status is `"failed"` and `error` carries the
message
**Verified by:** `it("execute records error on rejected promise")`
#### Scenario: cancel returns false when no execution is active
- WHEN `cancel(id)` is called with no in-flight execution
- THEN the call returns `false`
**Verified by:** `it("cancel returns true for active and false for absent")`
---
## From change: `game-server-enhanced-anti-cheat-lag`
### Requirement: Anti-Cheat Validator
`@suaveplan/game-server` SHALL expose an `AntiCheatValidator<TMove, TAction>`
class for server-authoritative validation of player movement and actions.
#### Scenario: Movement under maximum speed is accepted
- **WHEN** the validator receives two successive `MovementData` records for
a player whose Euclidean distance over the elapsed time is below
`movement.maxSpeed × movement.toleranceFactor`
- **THEN** `validateMovement` SHALL return `{ valid: true }`
- **AND** SHALL record the latest position as the player's reference for
subsequent calls.
#### Scenario: Teleport above threshold is rejected as `major`
- **WHEN** the Euclidean distance exceeds `movement.maxTeleportDistance`
- **THEN** `validateMovement` SHALL return a `ValidationResult` whose
`violation.type === "teleport"` and `violation.severity === "major"`
- **AND** SHALL emit a `violation:detected` event.
#### Scenario: Rate limit blocks bursts beyond `maxActions`
- **WHEN** more than `maxActions` actions of the matching `actionType`
(or `"all"`) arrive inside a rolling `windowMs`
- **THEN** `validateAction` SHALL return a `ValidationResult` whose
`violation.type === "rate_limit"`
- **AND** SHALL emit a `rate_limit:exceeded` event.
#### Scenario: Auto-flag triggers at threshold
- **WHEN** the player accumulates ≥ `autoFlagThreshold` violations in the
trailing 60 seconds
- **THEN** the validator SHALL flag the player automatically
- **AND** the most recent recorded violation SHALL have `autoFlagged: true`.
#### Scenario: Invalid configuration is rejected
- **WHEN** caller-supplied `AntiCheatConfig` fails Zod validation
- **THEN** the constructor SHALL throw a `GameServerError` with code
`GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`.
### Requirement: Interest Manager
`@suaveplan/game-server` SHALL expose an `InterestManager<TData>` class
that performs area-of-interest culling for broadcast scope reduction.
#### Scenario: Entity inside view distance is visible
- **WHEN** a player and an entity are within `viewDistance`
- **THEN** `getVisibleEntities` SHALL include the entity in its result.
#### Scenario: Visibility delta omits unchanged set
- **WHEN** `calculateVisibilityChanges` is called twice with no
intervening position change
- **THEN** the second call SHALL return `null`
- **AND** SHALL NOT emit a `visibility:changed` event.
#### Scenario: Rectangular zone containment
- **WHEN** a 3D point's `(x, y, z)` lies within `±width/2`, `±height/2`,
`±depth/2` of a rectangular zone's centre
- **THEN** `isInZone` SHALL return `true`.
#### Scenario: Custom zone delegates to provided predicate
- **WHEN** a `CustomZone` is registered with `containsPoint`
- **THEN** `isInZone` SHALL invoke that predicate and return its result.
#### Scenario: Invalid configuration is rejected
- **WHEN** caller-supplied `InterestManagerConfig` fails Zod validation
- **THEN** the constructor SHALL throw `GameServerError` with code
`GAME_SERVER.INVALID_INTEREST_CONFIG`.
### Requirement: Lag Compensator
`@suaveplan/game-server` SHALL expose a `LagCompensator<TState>` class for
server-side time-rewind of game state during hit validation.
#### Scenario: Bounded history pruning
- **WHEN** the number of recorded snapshots for an entity exceeds
`maxHistorySize`
- **THEN** the oldest snapshots SHALL be evicted
- **AND** the count SHALL never grow beyond `maxHistorySize`.
#### Scenario: Compensation is clamped
- **WHEN** the requested rewind exceeds `maxCompensationMs`
- **THEN** the returned `CompensatedState.compensationMs` SHALL equal
`maxCompensationMs`
- **AND** the returned `CompensatedState.clamped` SHALL be `true`.
#### Scenario: Interpolation between snapshots
- **WHEN** the rewind timestamp falls between two adjacent snapshots and
a `lerp` function is supplied
- **THEN** `getStateAtTime` SHALL invoke `lerp` to produce the returned
state
- **AND** the returned `CompensatedState.interpolated` SHALL be `true`.
#### Scenario: Hit validation requires both states
- **WHEN** the shooter or the target has no recorded state at the
perceived time
- **THEN** `validateHit` SHALL return `{ valid: false }` with a `reason`
describing the missing side.
#### Scenario: Invalid configuration is rejected
- **WHEN** caller-supplied `LagCompensatorConfig` fails Zod validation
- **THEN** the constructor SHALL throw `GameServerError` with code
`GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG`.
---
## From change: `game-server-enhanced-content`
### Requirement: LeaderboardManager multi-board ranking
`LeaderboardManager` SHALL maintain independent leaderboards keyed by
string id, each configured with one of three ranking modes
(`score` / `wins` / `elo`) and an optional `maxEntries` cap.
#### Scenario: submitScore inserts and re-ranks
- **GIVEN** a leaderboard `"global"` configured for `score` mode
- **WHEN** the caller invokes
`submitScore("global", { playerId: "alice", displayName: "Alice", score: 100 })`
- **THEN** Alice's entry has `rank === 1`
- **AND** the `game_server.leaderboard.submit_total` counter is
incremented
#### Scenario: maxEntries evicts the lowest-ranked entry
- **GIVEN** a leaderboard with `maxEntries: 2` and entries `(a:10, b:20, c:30)`
- **WHEN** all three submissions complete
- **THEN** the leaderboard retains only two entries
- **AND** the lowest-ranked entry (`a`) has been evicted
#### Scenario: submitScore rejects an unknown leaderboard
- **WHEN** the caller invokes `submitScore("missing", ...)`
- **THEN** `GameServerError(GAME_SERVER.LEADERBOARD_NOT_FOUND)` is thrown
#### Scenario: Elo update follows the standard formula
- **GIVEN** a leaderboard configured for `elo` mode with `initialElo: 1200`
- **WHEN** a player submits `{ isWin: true, opponentElo: 1200 }`
- **THEN** the player's Elo strictly increases above 1200
### Requirement: GamePersistence StorageLike-backed saves
`GamePersistence` SHALL persist game state through any conforming
`StorageLike` implementation, encoding `SaveRecord` payloads as
UTF-8 JSON under the key
`game-server:save:<gameId>:<id>`.
#### Scenario: save writes a record and returns its id
- **WHEN** the caller invokes `save(state, { name: "quicksave" })`
- **THEN** the result's `success === true`
- **AND** subsequent `load(id)` returns the same `state`
- **AND** the `game_server.persistence.save_total{outcome=ok}` counter
is incremented
#### Scenario: load() of unknown id throws PERSISTENCE_NOT_FOUND
- **WHEN** the caller invokes `load("missing")`
- **THEN** `GameServerError(GAME_SERVER.PERSISTENCE_NOT_FOUND)` is thrown
#### Scenario: createSnapshot rotates past maxSnapshots
- **GIVEN** a persistence configured with `maxSnapshots: 2`
- **WHEN** three snapshots are created in sequence
- **THEN** only the most recent two are retained on the record
#### Scenario: corrupt storage throws PERSISTENCE_LOAD_FAILED
- **GIVEN** a `StorageLike` returning invalid JSON bytes
- **WHEN** the caller invokes `load(id)`
- **THEN** `GameServerError(GAME_SERVER.PERSISTENCE_LOAD_FAILED)` is thrown
### Requirement: ReplayRecorder deterministic input + snapshot capture
`ReplayRecorder` SHALL record inputs with monotonic `sequence` numbers
and periodic state snapshots into an in-memory buffer that is emitted as
a `ReplayData<TState, TInput>` document on `stop()`.
#### Scenario: start from non-idle is rejected
- **GIVEN** a recorder in `recording` status
- **WHEN** the caller invokes `start(initialState)`
- **THEN** `GameServerError(GAME_SERVER.REPLAY_RECORDING_STATE_INVALID)` is
thrown
#### Scenario: recordInput respects buffer cap
- **GIVEN** a recorder configured with `maxInputBuffer: 1`
- **AND** one input recorded
- **WHEN** the caller invokes `recordInput(input)`
- **THEN** `GameServerError(GAME_SERVER.REPLAY_RECORDING_STATE_INVALID)` is
thrown
#### Scenario: tick auto-snapshots at the configured interval
- **GIVEN** a recorder with `snapshotInterval: 2`
- **WHEN** `tick(state)` is invoked twice with a state
- **THEN** the buffer contains at least one snapshot with `reason === "interval"`
#### Scenario: pause/resume excludes paused interval from elapsed time
- **GIVEN** a recorder started at clock 1000
- **AND** paused at clock 2000 then resumed at clock 5000
- **WHEN** elapsed time is read
- **THEN** the elapsed time equals 1000 (the unpaused gameplay duration)
### Requirement: ReplayPlayer tick-driven deterministic playback
`ReplayPlayer` SHALL load `ReplayData<TState, TInput>` documents and
advance playback exclusively in response to `tick()` calls, applying
the configured `PlaybackSpeed` multiplier to each tick.
#### Scenario: play before load throws REPLAY_NOT_LOADED
- **WHEN** the caller invokes `play()` without a loaded replay
- **THEN** `GameServerError(GAME_SERVER.REPLAY_NOT_LOADED)` is thrown
#### Scenario: tick delivers replayed inputs to listeners
- **GIVEN** a player with a loaded replay containing two inputs
- **AND** an `onInput` listener subscribed
- **WHEN** `tick()` is invoked enough times to reach the second input's tick
- **THEN** the listener receives both inputs in `sequence` order
#### Scenario: setSpeed rejects values outside the canonical set
- **WHEN** the caller invokes `setSpeed(7)`
- **THEN** `GameServerError(GAME_SERVER.REPLAY_INVALID_SPEED)` is thrown
#### Scenario: loop=true rewinds at end
- **GIVEN** a player configured with `loop: true`
- **AND** a replay of 3 ticks
- **WHEN** `tick()` is invoked 5 times during playback
- **THEN** status remains `"playing"` and the cursor wraps to ≤ 3
### Requirement: TournamentManager bracket generation + advancement
`TournamentManager` SHALL accept participants in the `pending` phase,
build a single-elimination or round-robin bracket on `start()`, and
advance winners through `nextMatchId` links as results are recorded.
#### Scenario: start requires at least 2 participants
- **GIVEN** a tournament with one registered participant
- **WHEN** the caller invokes `start()`
- **THEN** `GameServerError(GAME_SERVER.TOURNAMENT_INSUFFICIENT_PARTICIPANTS)`
is thrown
#### Scenario: single-elimination propagates first-round byes
- **GIVEN** a single-elimination tournament with 3 participants
- **WHEN** `start()` is invoked
- **THEN** the first round contains at least one match with `status === "bye"`
- **AND** the surviving participant is advanced into the next round
#### Scenario: round-robin completes after every match recorded
- **GIVEN** a round-robin tournament with 4 participants
- **WHEN** every scheduled match has a recorded result
- **THEN** the tournament status transitions to `"completed"`
- **AND** `getWinner()` returns the standings leader
#### Scenario: recordResult rejects a non-participant winner
- **GIVEN** an in-progress match between `a` and `b`
- **WHEN** the caller invokes `recordResult(matchId, "z")`
- **THEN** `GameServerError(GAME_SERVER.TOURNAMENT_INVALID_RESULT)` is thrown
#### Scenario: shuffleSeeds uses injected Rng deterministically
- **GIVEN** a tournament configured with `shuffleSeeds: true` and a
deterministic `Rng`
- **WHEN** `start()` is invoked
- **THEN** the participants are re-ordered using the `Rng`'s
`nextIntInRange` and their `seed` fields are renumbered 1..N
---
## From change: `game-server-enhanced-infra`
### Requirement: Game instance registry with TTL eviction and load-balancing
The package SHALL expose a `GameInstanceRegistry<TCustom>` class that registers live game-server instances, evicts them when their last heartbeat is older than `instanceTTL` milliseconds (as reported by an injected `Clock`), supports filter-based discovery, and selects one healthy instance per request using one of four load-balancing strategies (`least-players`, `random`, `region-based`, `round-robin`). All clock and randomness reads SHALL flow through injected `Clock` and `Rng` dependencies.
#### Scenario: register / discover / deregister round-trip
- **WHEN** the caller registers an instance, then calls `discover()` with no filter
- **THEN** the registered instance appears in the result, defaulted to `HEALTHY`
- **WHEN** the caller deregisters
- **THEN** the next `discover()` returns an empty array
#### Scenario: heartbeat expiry triggers eviction
- **WHEN** the injected clock advances past `instanceTTL * 2` since the last heartbeat
- **THEN** the registry transitions the instance to `UNHEALTHY` first, then deregisters it on the second sweep
- **AND** the corresponding `GameInstanceEvent.HEALTH_CHANGED` and `GameInstanceEvent.DEREGISTERED` events fire
#### Scenario: least-players strategy picks lowest player count
- **WHEN** two healthy instances exist with `playerCount = 5` and `playerCount = 2`
- **THEN** `selectInstance(..., 'least-players')` returns the second one with `score = 2`
#### Scenario: region-based strategy honours preferred region
- **WHEN** the caller passes `filter.region = "eu-west"` and at least one EU instance exists
- **THEN** the selected instance has `metadata.region === "eu-west"`
#### Scenario: round-robin rotates across calls
- **WHEN** three instances are available and `selectInstance(..., 'round-robin')` is called three times
- **THEN** each call returns a different instance, then the rotation repeats
**Verified by:** `src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"registers and discovers an instance"` AND `src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"evicts after TTL expiry"` AND `src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"least-players selects lowest count"` AND `src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"region-based prefers configured region"` AND `src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"round-robin rotates across calls"`
---
### Requirement: BitPacker-backed binary state synchronisation
The package SHALL expose a `BitPackerStateSync<TState, TSchema extends BitPackerSchema>` class that packs `TState` snapshots to `Uint8Array` via `@suaveplan/bitpacker`, supports client-side prediction with a bounded history window, supports server reconciliation against a tick-tagged authoritative state, supports linear interpolation between two history snapshots, and aggregates wire-byte counters into a `SyncStats` view. All timestamps SHALL flow through an injected `Clock`. Pack/unpack failures SHALL throw `GameServerError` with `BITPACK_ENCODE_FAILED` / `BITPACK_DECODE_FAILED`.
#### Scenario: pack then unpack round-trip preserves state
- **WHEN** the caller packs a `TState` value and unpacks the resulting `Uint8Array`
- **THEN** the unpacked value equals the original
#### Scenario: prediction records a history frame
- **WHEN** `predict(state, input, simulate)` is called
- **THEN** the returned state equals `simulate(state, input.data)`
- **AND** `getStats().predictionsMade` increments by one
#### Scenario: reconciliation re-applies pending inputs above server tick
- **WHEN** the client has predicted three frames at ticks 11, 12, 13 and the server delivers a state at tick 10
- **AND** the client's snapshot at tick 10 differs from the server state beyond the threshold
- **THEN** `reconcile(...)` returns `needed: true` with `framesResimulated = 3` and a state derived from re-applying the three pending inputs
#### Scenario: interpolation lerps between snapshots
- **WHEN** the caller passes two snapshots at timestamps 100 and 200 and a render time bracketing the targetTime
- **THEN** the returned state equals `lerp(before, after, t)` for some `t ∈ [0, 1]`
#### Scenario: bitpack failure routes to typed error
- **WHEN** `BitPacker.pack` throws
- **THEN** `packState` re-throws a `GameServerError` carrying code `GAME_SERVER.BITPACK_ENCODE_FAILED`
**Verified by:** `src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"packs and unpacks state"` AND `src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"prediction records a history frame"` AND `src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"reconciliation re-applies pending inputs above the server tick"` AND `src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"interpolation lerps between two snapshots"` AND `src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"throws BITPACK_ENCODE_FAILED when bitpacker throws"`
---
### Requirement: Vector-clock distributed action ordering
The package SHALL expose a `VectorClockSync<TEventData>` class that records game actions per session, tags each action with a vector-clock snapshot, detects concurrent actions, resolves conflicts via one of three strategies (`last-write-wins`, `merge`, `custom`), produces a causality chain in happens-before order, and exposes request/response sync round-trips between nodes. The internal vector clock SHALL be the `createVectorClock` primitive from `@suaveplan/timing`. Action IDs SHALL be minted through an injected `IdGenerator`; timestamps SHALL flow through an injected `Clock`.
#### Scenario: recordAction tags with current vector clock
- **WHEN** `recordAction(sessionId, "move", data, tick)` is called
- **THEN** the returned `GameAction.vectorClock` carries this node's counter at >= 1
- **AND** `getStats().totalActions` equals the count of recordings
#### Scenario: receiveAction detects concurrent conflict
- **WHEN** two actions are recorded by different nodes with vector clocks that are mutually concurrent (`compare === "concurrent"`)
- **THEN** `receiveAction(remote)` returns a `ConflictResolution` with `conflictDetected: true`
- **AND** the chosen winner under `last-write-wins` is the one with the larger timestamp
#### Scenario: custom resolver overrides default strategy
- **WHEN** the registry is constructed with a `conflictResolver`
- **THEN** the resolver function is called with each (winner, candidate) pair and its return value becomes the resolved action
- **AND** `ConflictResolution.strategy === "custom"`
#### Scenario: causality chain sorts by happens-before
- **WHEN** action A is recorded, then action B is recorded on the same node
- **THEN** `getCausalityChain(sessionId).actions[0]` is A and `[1]` is B
#### Scenario: sync request returns missing actions
- **WHEN** node X has recorded actions [a1, a2] and node Y has recorded none
- **AND** Y issues `requestSync(X, sessionId)` with its current (empty) vector clock
- **THEN** X's `handleSyncRequest(request)` returns a response carrying both `a1` and `a2`
**Verified by:** `src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"recordAction tags with the current vector clock"` AND `src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"receiveAction detects concurrent conflict and resolves last-write-wins"` AND `src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"custom resolver overrides default strategy"` AND `src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"causality chain sorts by happens-before"` AND `src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"sync request returns missing actions"`
---
## From change: `game-server-integrations`
### Requirement: EnhancedGameServer top-of-stack bootstrap
The `@suaveplan/game-server` package SHALL expose an `EnhancedGameServer`
class under the `./integrations/enhanced-server` subpath export that
bootstraps the canonical engine + plugin stack via a single
`bootstrap(options)` entry point.
#### Scenario: Bootstrap installs all eight plugins
- **WHEN** `bootstrap()` is invoked with valid options
- **THEN** a `GameLoop`, `PluginManager`, and `GameEngine` SHALL be
constructed and wired
- **AND** all eight built-in plugins (`ABTestPlugin`, `AnalyticsPlugin`,
`FeatureFlagPlugin`, `LoggingPlugin`, `PersistencePlugin`, `RulesPlugin`,
`WorkflowPlugin`, and one further reserved slot satisfied by
`BasePlugin` derivatives) SHALL be installed in dependency order via
`PluginManager.installAll`
- **AND** the `GameEngine` SHALL be returned ready to `start()`.
#### Scenario: Double bootstrap rejected
- **WHEN** `bootstrap()` is invoked twice on the same `EnhancedGameServer`
- **THEN** the second call SHALL throw `GameServerError` with code
`GAME_SERVER.ALREADY_BOOTSTRAPPED`.
#### Scenario: Plugin install failure rolls back
- **WHEN** any plugin install fails during `bootstrap()`
- **THEN** the failure SHALL be wrapped as `GameServerError` with code
`GAME_SERVER.BOOTSTRAP_FAILED`
- **AND** the underlying `cause` SHALL be the original error.
#### Scenario: Lifecycle deferral
- **WHEN** the caller invokes `start()` / `tick()` / `stop()` / `dispose()`
- **THEN** the `EnhancedGameServer` SHALL delegate to the underlying
`GameEngine` so existing engine lifecycle codes propagate unchanged.
#### Scenario: Injected dependencies are honoured
- **WHEN** `bootstrap(options)` is invoked
- **THEN** every plugin SHALL receive the injected `Clock` and
`LoggerLike` so deterministic tests observe stable timestamps and
captured logs.