# 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` 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` 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` 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: , plugins: }` **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"` --- ## 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` plus a `Set` 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"` --- ## From change: `game-server-state` ### Requirement: Observable state container The package SHALL expose a `StateManager` 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` 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.`. **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` 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` 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` 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` 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::`. #### 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` 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` 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` 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` 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` 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.