255 lines
15 KiB
Markdown
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 -->
|