Files
suaveplan/openspec/archive/2026-05-24-game-server-engine-sessions/specs/game-server/spec.md
2026-05-27 23:29:53 +01:00

255 lines
15 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<id, SessionGraphNode>`
plus a `Set<rootId>` and exposing the mutation surface used by
`SessionStore`. The `createSession` boundary SHALL parse `SessionData`
through Zod and reject failures with `INVALID_SESSION_DATA`. Duplicate
ids SHALL throw `DUPLICATE_SESSION_ID`; unknown parents SHALL throw
`PARENT_SESSION_NOT_FOUND`. `destroySession` SHALL cascade bottom-up
through every descendant.
#### Scenario: createSession registers a root and emits session:created
- **WHEN** `createSession(null, data)` is called with valid data
- **THEN** the node is registered as a root and `session:created` is emitted
#### Scenario: invalid SessionData rejects with INVALID_SESSION_DATA
- **WHEN** `createSession` is called with `id: ""` or any schema-failing input
- **THEN** a `GameServerError(GAME_SERVER.INVALID_SESSION_DATA)` is thrown
#### Scenario: duplicate id rejects with DUPLICATE_SESSION_ID
- **WHEN** a second `createSession` reuses an existing id
- **THEN** `GameServerError(GAME_SERVER.DUPLICATE_SESSION_ID)` is thrown
#### Scenario: unknown parent rejects with PARENT_SESSION_NOT_FOUND
- **WHEN** `createSession(parentId, ...)` references a missing id
- **THEN** `GameServerError(GAME_SERVER.PARENT_SESSION_NOT_FOUND)` is thrown
#### Scenario: destroySession cascades bottom-up
- **WHEN** `destroySession` is called on an ancestor with descendants
- **THEN** `session:destroyed` events fire in deepest-first order
#### Scenario: attachRestored re-attaches a deserialised node without firing session:created
- **WHEN** `attachRestored(node, parentId)` is called during `SessionStore.restore`
- **THEN** the node is inserted into the index, the `session:created` event is NOT emitted, and a duplicate id throws `DUPLICATE_SESSION_ID`
#### Scenario: validateIntegrity catches corrupted invariants
- **WHEN** the bidirectional parent/children invariant is forced into an inconsistent state
- **THEN** `validateIntegrity()` returns `{valid: false, errors: [...]}` with at least one diagnostic
**Verified by:** `src/engine/session-tree/session-tree.test.ts::"createSession registers a root and emits session:created"` AND `src/engine/session-tree/session-tree.test.ts::"createSession rejects invalid SessionData with INVALID_SESSION_DATA"` AND `src/engine/session-tree/session-tree.test.ts::"createSession rejects duplicate ids with DUPLICATE_SESSION_ID"` AND `src/engine/session-tree/session-tree.test.ts::"createSession rejects unknown parents with PARENT_SESSION_NOT_FOUND"` AND `src/engine/session-tree/session-tree.test.ts::"destroySession cascades through descendants bottom-up"` AND `src/engine/session-tree/session-tree.test.ts::"attachRestored attaches a pre-built node without firing session:created"` AND `src/engine/session-tree/session-tree.test.ts::"validateIntegrity flags a tampered parent pointer"`
---
### Requirement: SessionStore implements ISessionManager
The package SHALL provide `SessionStore` implementing Wave A's
`ISessionManager` interface (`createSession`, `getSession`,
`destroySession`, `getSessionsByType`, `getChildren`, `getParent`,
`updateMetadata`, `getAllSessions`, `persist`, `restore`). The store
SHALL accept an injectable `SessionPersistence` adapter and fall back to
an in-memory default when none is supplied. Construction SHALL
Zod-validate options and reject with `INVALID_SESSION_STORE_OPTIONS`.
`restore` SHALL handle arbitrary record orderings via a fixed-point walk
that attaches any record whose parent is already present.
#### Scenario: ISessionManager conformance harness passes
- **WHEN** `runISessionManagerConformance(() => new SessionStore(deps))` is invoked
- **THEN** every scenario in the harness reports `passed: true`
#### Scenario: invalid options reject with INVALID_SESSION_STORE_OPTIONS
- **WHEN** `new SessionStore(deps, { storageKey: "" })`
- **THEN** the constructor throws `GameServerError(GAME_SERVER.INVALID_SESSION_STORE_OPTIONS)`
#### Scenario: persist + restore round-trip preserves the tree
- **WHEN** a populated `SessionStore` is persisted, then cleared, then restored
- **THEN** the restored tree has the same node count and parent/child relationships
#### Scenario: restore handles permuted record orderings
- **WHEN** a persisted payload lists children before their parents
- **THEN** the fixed-point walk attaches every record and the final tree shape is identical to the pre-persist state
#### Scenario: restore logs orphan records and skips them
- **WHEN** a payload references a parent that doesn't exist in the same payload
- **THEN** the orphan id is logged via `logger.warn` and the rest of the records still come online
**Verified by:** `src/engine/session-store/session-store.test.ts::"ISessionManager conformance harness passes"` AND `src/engine/session-store/session-store.test.ts::"rejects invalid options with INVALID_SESSION_STORE_OPTIONS"` AND `src/engine/session-store/session-store.test.ts::"persist+restore round-trips with the in-memory default"` AND `src/engine/session-store/session-store.test.ts::"restore handles permuted record ordering via fixed-point walk"` AND `src/engine/session-store/session-store.test.ts::"restore logs orphan records as warnings and skips them"`
---
### Requirement: SessionLifecycle per-session FSM
The package SHALL provide `SessionLifecycle` implementing a per-session
finite-state machine with the closed three-state vocabulary
`lobby | active | ended`. The transition table SHALL be:
`lobby → active`, `lobby → ended`, `active → ended`. Every other pair
SHALL be rejected with `INVALID_SESSION_TRANSITION`. Transitions out of
`ended` SHALL throw `SESSION_ALREADY_ENDED`. Transitions on unknown ids
SHALL throw `SESSION_NOT_FOUND`. Every transition SHALL emit a
`session:transitioned` event and record both
`game_server.session.lifecycle_total` (counter) and
`game_server.session.dwell_ms` (histogram) OTel instruments.
#### Scenario: register places the session in lobby
- **WHEN** `register(id)` is called
- **THEN** the new entry is in state `lobby` with `enteredStateAt = clock()`
#### Scenario: transition validates against the allow-table
- **WHEN** `transition("s1", "active")` is called on a `lobby` entry
- **THEN** the FSM advances to `active` and emits `session:transitioned`
#### Scenario: backwards transition rejects with INVALID_SESSION_TRANSITION
- **WHEN** `transition("s1", "lobby")` is called on an `active` entry
- **THEN** `GameServerError(GAME_SERVER.INVALID_SESSION_TRANSITION)` is thrown
#### Scenario: transition out of ended rejects with SESSION_ALREADY_ENDED
- **WHEN** `transition("s1", "active")` is called on an `ended` entry
- **THEN** `GameServerError(GAME_SERVER.SESSION_ALREADY_ENDED)` is thrown
#### Scenario: unknown id rejects with SESSION_NOT_FOUND
- **WHEN** `transition("nope", "active")` is called
- **THEN** `GameServerError(GAME_SERVER.SESSION_NOT_FOUND)` is thrown
#### Scenario: dwell histogram receives a sample per transition
- **WHEN** the FSM transitions out of `lobby`
- **THEN** `game_server.session.dwell_ms` records one sample tagged `state: "lobby"`
#### Scenario: forget drops the entry but preserves audit history
- **WHEN** `forget(id)` is called after one or more transitions
- **THEN** `getState(id)` returns `null` while `getHistoryFor(id)` still returns the transitions
**Verified by:** `src/engine/session-lifecycle/session-lifecycle.test.ts::"register creates an entry in lobby state"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"lobby → active transition succeeds and emits event"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"active → lobby (backwards) is rejected with INVALID_SESSION_TRANSITION"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"transition out of ended is rejected with SESSION_ALREADY_ENDED"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"transition on unknown id is rejected with SESSION_NOT_FOUND"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"emits session.dwell_ms histogram sample for the state being left"` AND `src/engine/session-lifecycle/session-lifecycle.test.ts::"history preserves transitions across forget()"`
---
### Requirement: PlayerManager implements IPlayerManager
The package SHALL provide `PlayerManager` implementing Wave A's
`IPlayerManager` interface (11 methods). Construction SHALL Zod-validate
options and reject with `INVALID_PLAYER_MANAGER_OPTIONS`.
`startHeartbeatMonitor` SHALL Zod-validate the interval and reject
non-positive integers with `INVALID_HEARTBEAT_INTERVAL`. The heartbeat
monitor SHALL schedule via the injected `TimersLike`, NOT `setInterval`.
`checkTimeouts` SHALL transition connected players past
`timeoutThresholdMs` to `timeout` and evict disconnected entries past
`reconnectionGraceMs`. A `connect` against an existing disconnected entry
within the grace window SHALL walk the reconnection path
(preserving `connectedAt`).
#### Scenario: IPlayerManager conformance harness passes
- **WHEN** `runIPlayerManagerConformance(() => new PlayerManager(deps))` is invoked
- **THEN** every scenario in the harness reports `passed: true`
#### Scenario: invalid options reject with INVALID_PLAYER_MANAGER_OPTIONS
- **WHEN** `new PlayerManager(deps, { timeoutThresholdMs: 0 })`
- **THEN** the constructor throws `GameServerError(GAME_SERVER.INVALID_PLAYER_MANAGER_OPTIONS)`
#### Scenario: startHeartbeatMonitor rejects non-positive intervals
- **WHEN** `startHeartbeatMonitor(0)` or `startHeartbeatMonitor(1.5)` is called
- **THEN** the call throws `GameServerError(GAME_SERVER.INVALID_HEARTBEAT_INTERVAL)`
#### Scenario: heartbeat monitor schedules via TimersLike
- **WHEN** `startHeartbeatMonitor(50)` is called
- **THEN** `TimersLike.setTimer` is invoked with delay 50; firing a due timer triggers `checkTimeouts` and the monitor self-reschedules
#### Scenario: stopHeartbeatMonitor cancels the next scheduled tick
- **WHEN** `stopHeartbeatMonitor()` is called after `startHeartbeatMonitor`
- **THEN** the pending timer is cleared and the captured callback, if fired post-stop, does not re-schedule itself
#### Scenario: connect walks the reconnection path for disconnected entries
- **WHEN** `connect("p1", "s1", { region: "eu" })` is called after `disconnect("p1", "x")`
- **THEN** `connectedAt` is preserved from the original entry, metadata is shallow-merged, and `player:reconnected` is emitted
#### Scenario: checkTimeouts evicts disconnected entries past grace window
- **WHEN** `checkTimeouts` is called with `now - conn.disconnectedAt > reconnectionGraceMs`
- **THEN** the connection is deleted; a subsequent `getConnection` returns `null`
**Verified by:** `src/engine/player-manager/player-manager.test.ts::"IPlayerManager conformance harness passes"` AND `src/engine/player-manager/player-manager.test.ts::"rejects invalid options with INVALID_PLAYER_MANAGER_OPTIONS"` AND `src/engine/player-manager/player-manager.test.ts::"startHeartbeatMonitor rejects non-positive intervals"` AND `src/engine/player-manager/player-manager.test.ts::"startHeartbeatMonitor schedules ticks via TimersLike"` AND `src/engine/player-manager/player-manager.test.ts::"heartbeat tick stops re-scheduling if monitor was stopped mid-tick"` AND `src/engine/player-manager/player-manager.test.ts::"connect on a disconnected player walks the reconnection path"` AND `src/engine/player-manager/player-manager.test.ts::"checkTimeouts evicts disconnected entries past grace window"`
---
### Requirement: Subpath exports
The package SHALL expose each new feature directory via additive
`exports` map entries so consumers can tree-shake.
#### Scenario: each feature dir is independently importable
- **WHEN** a consumer imports from `@suaveplan/game-server/engine/session-lifecycle`, `@suaveplan/game-server/engine/session-node`, `@suaveplan/game-server/engine/session-store`, `@suaveplan/game-server/engine/session-tree`, or `@suaveplan/game-server/engine/player-manager`
- **THEN** the import resolves to the corresponding feature barrel
**Verified by:** `packages/gaming/games/game-server/package.json::"exports./engine/session-lifecycle"` AND `packages/gaming/games/game-server/package.json::"exports./engine/session-node"` AND `packages/gaming/games/game-server/package.json::"exports./engine/session-store"` AND `packages/gaming/games/game-server/package.json::"exports./engine/session-tree"` AND `packages/gaming/games/game-server/package.json::"exports./engine/player-manager"`
<!-- markdownlint-enable MD024 -->