# Capability Spec Delta: game-server (game-server-engine-sessions) **Package**: `@suaveplan/game-server` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### 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"`