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

15 KiB

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"