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
timestampsarg - THEN
createdAtandupdatedAtare both set from the injectedClock
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, andgetRootare called on a populated tree - THEN each helper returns the documented walk;
isAncestorOf/isDescendantOfexclude self (strict)
Scenario: toJSON serialises root with null parentId
- WHEN a root node is serialised
- THEN
parentIdisnull; 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:createdis emitted
Scenario: invalid SessionData rejects with INVALID_SESSION_DATA
- WHEN
createSessionis called withid: ""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
createSessionreuses 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
destroySessionis called on an ancestor with descendants - THEN
session:destroyedevents fire in deepest-first order
Scenario: attachRestored re-attaches a deserialised node without firing session:created
- WHEN
attachRestored(node, parentId)is called duringSessionStore.restore - THEN the node is inserted into the index, the
session:createdevent is NOT emitted, and a duplicate id throwsDUPLICATE_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
SessionStoreis 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.warnand 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
lobbywithenteredStateAt = clock()
Scenario: transition validates against the allow-table
- WHEN
transition("s1", "active")is called on alobbyentry - THEN the FSM advances to
activeand emitssession:transitioned
Scenario: backwards transition rejects with INVALID_SESSION_TRANSITION
- WHEN
transition("s1", "lobby")is called on anactiveentry - 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 anendedentry - 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_msrecords one sample taggedstate: "lobby"
Scenario: forget drops the entry but preserves audit history
- WHEN
forget(id)is called after one or more transitions - THEN
getState(id)returnsnullwhilegetHistoryFor(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)orstartHeartbeatMonitor(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.setTimeris invoked with delay 50; firing a due timer triggerscheckTimeoutsand the monitor self-reschedules
Scenario: stopHeartbeatMonitor cancels the next scheduled tick
- WHEN
stopHeartbeatMonitor()is called afterstartHeartbeatMonitor - 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 afterdisconnect("p1", "x") - THEN
connectedAtis preserved from the original entry, metadata is shallow-merged, andplayer:reconnectedis emitted
Scenario: checkTimeouts evicts disconnected entries past grace window
- WHEN
checkTimeoutsis called withnow - conn.disconnectedAt > reconnectionGraceMs - THEN the connection is deleted; a subsequent
getConnectionreturnsnull
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"