85 KiB
Capability Spec: game-server
Package: @suaveplan/game-server
Status: SHIPPED
Built across 7 OpenSpec changes archived under openspec/archive/2026-05-24-game-server-*/. This canonical spec consolidates the ADDED requirements from each.
From change: game-server-core
Requirement: IGameLogic strategy contract
The package SHALL export an IGameLogic<TState, TMove> interface declaring
validateMove, processMove, getGameState, isGameOver, getGameResult,
initializeGame, and dispose, fully typed without any, plus a runtime
conformance harness runIGameLogicConformance(factory, fixtures) that exercises
every method and returns a ConformanceCheckResult[].
Scenario: a compliant implementation passes every harness scenario
- WHEN a caller passes a factory returning a complete implementation (correct return shapes, validation truthy + falsy branches, terminal state)
- THEN every entry in the returned list has
passed === true
Verified by: src/core/interfaces/i-game-logic/i-game-logic.test.ts::"passes every scenario for a compliant implementation" AND "reports failure when an impl violates the contract"
Requirement: IPlayerManager lifecycle contract
The package SHALL export an IPlayerManager interface with the eleven methods
connect, disconnect, getConnection, getSessionPlayers,
getAllConnections, heartbeat, checkTimeouts, updateMetadata,
getPlayer, startHeartbeatMonitor, stopHeartbeatMonitor, plus a conformance
harness runIPlayerManagerConformance(factory) that exercises each.
Scenario: a compliant implementation passes every harness scenario
- WHEN the conformance harness runs against a Map-backed reference impl
- THEN every entry in the result list has
passed === true
Verified by: src/core/interfaces/i-player-manager/i-player-manager.test.ts::"passes every scenario for a compliant implementation" AND "reports session/all/getPlayer failures for an empty-returning impl"
Requirement: ISessionManager hierarchical contract
The package SHALL export an ISessionManager interface supporting unlimited
parent/child nesting with O(1) lookup by id, cascading destroy, partial-
metadata update, and async persist / restore, plus an async conformance
harness runISessionManagerConformance(factory).
Scenario: destroySession cascades to descendants
- WHEN a
venuecontaining atableis destroyed - THEN the harness reports
destroySession removes the node and its descendantsas passed; an implementation that fails to cascade reports the scenario as failed
Verified by: src/core/interfaces/i-session-manager/i-session-manager.test.ts::"passes every scenario for a compliant implementation" AND "reports failure when destroySession does not cascade to children"
Requirement: IStateManager observable-state contract
The package SHALL export an IStateManager<TState extends object> interface
declaring getState, update, updateMany, setState, subscribe, reset,
getHistory, and clearHistory, plus a conformance harness
runIStateManagerConformance(factory, fixtures).
Scenario: subscribers receive events for every mutation
- WHEN the harness calls
update,updateMany, andsetStateagainst a compliant manager - THEN the harness records at least three observed events; the unsubscribe handle stops further events
Verified by: src/core/interfaces/i-state-manager/i-state-manager.test.ts::"passes every scenario for a compliant implementation" AND "flags failure when unsubscribe does not stop events"
Requirement: GameEvents map and GAME_EVENT_KEYS registry
The package SHALL export a typed GameEvents map covering player, session,
game, move, and error event families, plus a frozen GAME_EVENT_KEYS array
enumerating every well-known key and an isGameEventKey(value) predicate.
Scenario: predicate narrows arbitrary strings
- WHEN
isGameEventKey("player:connected")is called - THEN it returns
true; for"not:a:real:event"it returnsfalse
Verified by: src/core/events/game-events/game-events.test.ts::"returns true for every registered key" AND "returns false for unknown keys"
Requirement: GameEventEmitter typed pub/sub
The package SHALL export a GameEventEmitter class supporting on/once/
off/emit, ergonomic helpers safeEmit / waitFor(timeoutMs?) / onN /
onMultiple, a configurable per-key listener cap (default 100), and removeAllListeners / listenerCount introspection.
Scenario: safeEmit routes listener errors to the error channel
- WHEN a listener subscribed to
game:startedthrows - THEN
safeEmitreturnsfalse(no successful listener), re-emits the error aserrorpayload on the"error"channel, and does not throw
Verified by: src/core/events/game-event-emitter/game-event-emitter.test.ts::"safeEmit routes listener errors to the error channel" AND "safeEmit wraps non-Error throws into Error"
Scenario: listener cap is enforced
- WHEN more than
maxListenersPerKeylisteners are registered on one key - THEN
on(...)throwsGameServerError(LISTENER_LIMIT_EXCEEDED)
Verified by: src/core/events/game-event-emitter/game-event-emitter.test.ts::"throws GameServerError(LISTENER_LIMIT_EXCEEDED) past the cap"
Scenario: waitFor rejects on timeout
- WHEN
waitFor("game:started", 5)is called and no emission occurs - THEN the returned promise rejects with
GameServerError(EVENT_WAIT_TIMEOUT)
Verified by: src/core/events/game-event-emitter/game-event-emitter.test.ts::"waitFor rejects with GameServerError on timeout"
Requirement: AbstractGameLogic Template-Method base
The package SHALL export an AbstractGameLogic<TState, TMove> abstract class
implementing IGameLogic, accepting { initialState, clock?, idGenerator?, meterName? }, orchestrating processMove as validate → before hook → apply
→ install → after hook, managing a player roster with typed-error duplicate /
missing handling, and emitting OTel instruments
game_server.logic.tick_duration_ms (Histogram),
game_server.logic.moves_total (Counter), and
game_server.logic.rejections_total (Counter).
Scenario: processMove on a valid move records duration + total
- WHEN a subclass processes a valid move with an injected fake clock
- THEN the histogram and
moves_totalcounter are recorded; the state installs fromapplyMove
Verified by: src/core/abstract-game-logic/abstract-game-logic.test.ts::"processMove records OTel instruments" AND "processMove validates, applies, mutates state, runs hooks"
Scenario: processMove on an invalid move throws and increments rejections
- WHEN
validateMovereturns{ valid: false } - THEN
processMovethrowsGameServerError(INVALID_MOVE)with the reason inerror.context;rejections_totalincrements
Verified by: src/core/abstract-game-logic/abstract-game-logic.test.ts::"processMove throws GameServerError(INVALID_MOVE) on rejection"
Scenario: roster mutations throw typed errors
- WHEN
addPlayeris called twice with the same id, orremovePlayeris called on an unknown id - THEN
GameServerError(PLAYER_ALREADY_EXISTS)/(PLAYER_NOT_FOUND)is thrown respectively
Verified by: src/core/abstract-game-logic/abstract-game-logic.test.ts::"addPlayer throws GameServerError(PLAYER_ALREADY_EXISTS) on duplicate" AND "removePlayer throws GameServerError(PLAYER_NOT_FOUND) for unknown id"
Requirement: error-code registry extended for Wave A
The package SHALL extend GAME_SERVER_CODES with INVALID_MOVE,
PLAYER_ALREADY_EXISTS, PLAYER_NOT_FOUND, LISTENER_LIMIT_EXCEEDED,
INVALID_LISTENER_COUNT, and EVENT_WAIT_TIMEOUT. Every new code MUST be
thrown by at least one production path inside src/.
Scenario: every new code is reachable from production
- WHEN the unit suites for
AbstractGameLogicandGameEventEmitterrun - THEN each of the six new codes is observed thrown by at least one test scenario
Verified by: src/errors/game-server-error.test.ts::"registers all expected codes under GAME_SERVER namespace" AND src/core/abstract-game-logic/abstract-game-logic.test.ts::"processMove throws GameServerError(INVALID_MOVE) on rejection" AND src/core/events/game-event-emitter/game-event-emitter.test.ts::"onN throws GameServerError(INVALID_LISTENER_COUNT) for non-positive counts"
Requirement: subpath exports per feature module
The package SHALL declare a package.json exports subpath for every Wave A
feature module so consumers can tree-shake at module-level granularity.
Scenario: each new feature dir has its own export
- WHEN a consumer imports
@suaveplan/game-server/core/events/game-event-emitteror any other Wave A subpath - THEN the import resolves to the feature module's compiled
index.js
Verified by: bunx turbo build --filter=@suaveplan/game-server (vite-plugin-dts emits matching .d.ts per subpath)
From change: game-server-engine-core
Requirement: GameLoop deterministic ticking
The package SHALL provide GameLoop driving a deterministic fixed-step
loop with tick-based, event-driven, and hybrid modes. All wall-clock
reads SHALL route through an injected Clock; all interval scheduling
SHALL route through an injected TimersLike. The loop SHALL NOT call
Date.now() or setTimeout/setInterval directly.
Scenario: tick-based mode schedules at the configured tick rate
- WHEN
start()is called withmode: "tick-based", tickRate: 30 - THEN the loop schedules a recurring timer via
TimersLike.setTimerwith delay1000 / 30ms and invokes registered fixed-update callbacks each tick
Scenario: pause halts ticking, resume restarts without retroactive deltas
- WHEN
pause()then laterresume()is called - THEN the resume call refreshes
lastTickTimefrom the injectedClockand the next tick'sdeltaTimeis bounded bymaxDeltaTime
Scenario: manual tick() invokes all callbacks in registration order
- WHEN
tick()is called with two registered fixed-update callbacks and one update callback - THEN both fixed-update callbacks fire (in insertion order) then the update callback fires once
Scenario: invalid options reject with INVALID_GAME_LOOP_OPTIONS
- WHEN the loop is constructed with
tickRate: 0or a negativemaxDeltaTime - THEN a
GameServerError(GAME_SERVER.INVALID_GAME_LOOP_OPTIONS)is thrown
Scenario: starting a running loop rejects with GAME_LOOP_ALREADY_RUNNING
- WHEN
start()is called twice in a row - THEN the second call throws
GameServerError(GAME_SERVER.GAME_LOOP_ALREADY_RUNNING)
Scenario: pause/resume/stop on idle loop rejects with GAME_LOOP_NOT_RUNNING
- WHEN
pause(),resume(), orstop()is called beforestart() - THEN each call throws
GameServerError(GAME_SERVER.GAME_LOOP_NOT_RUNNING)
Scenario: callback errors propagate via injected EventBus
- WHEN a registered update callback throws inside
tick() - THEN the loop emits a
loop:errorevent with the original error and continues processing remaining callbacks
Scenario: OTel histogram records each tick duration
- WHEN
tick()completes - THEN the
game_server.loop.tick_duration_mshistogram receives one sample tagged with the currentmode
Verified by: src/engine/game-loop/game-loop.test.ts::"schedules tick-based ticks via injected TimersLike at the configured rate" AND src/engine/game-loop/game-loop.test.ts::"pause halts ticking and resume refreshes lastTickTime" AND src/engine/game-loop/game-loop.test.ts::"manual tick invokes fixed-update then update callbacks in order" AND src/engine/game-loop/game-loop.test.ts::"rejects construction with invalid options as INVALID_GAME_LOOP_OPTIONS" AND src/engine/game-loop/game-loop.test.ts::"throws GAME_LOOP_ALREADY_RUNNING on second start" AND src/engine/game-loop/game-loop.test.ts::"throws GAME_LOOP_NOT_RUNNING when paused/resumed/stopped before start" AND src/engine/game-loop/game-loop.test.ts::"callback errors emit loop:error and do not stop the loop" AND src/engine/game-loop/game-loop.test.ts::"records a tick_duration_ms histogram sample per tick"
Requirement: PluginManager lifecycle registry
The package SHALL provide PluginManager accepting a typed EnginePlugin
contract. Install SHALL reject duplicates with DUPLICATE_PLUGIN, lookup
SHALL reject unknown names with PLUGIN_NOT_FOUND, and every plugin
lifecycle hook (onInstall, onUninstall, onEnable, onDisable) that
throws SHALL be wrapped in PLUGIN_LIFECYCLE_FAILED carrying the plugin
name as context.
Scenario: install registers and emits OTel counter
- WHEN
install(plugin)is called with a fresh plugin - THEN
getPlugin(name)returns the plugin,onInstallwas invoked once with the engine handle, and thegame_server.plugin.lifecycle_totalcounter increments withop: "install"
Scenario: duplicate install rejects
- WHEN
install(plugin)is called twice with the same plugin name - THEN the second call throws
GameServerError(GAME_SERVER.DUPLICATE_PLUGIN)
Scenario: unknown plugin name on uninstall/enable/disable/get rejects
- WHEN
uninstall("nope"),enable("nope"),disable("nope"), orget("nope")is called - THEN the call throws
GameServerError(GAME_SERVER.PLUGIN_NOT_FOUND)
Scenario: enable then disable toggles state and fires hooks
- WHEN an installed plugin is enabled then disabled
- THEN
onEnableand thenonDisableeach fire once andisEnabled(name)returns the matching state
Scenario: plugin lifecycle errors wrap to PLUGIN_LIFECYCLE_FAILED
- WHEN a plugin's
onInstallthrows - THEN
installrejects withGameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)whosecauseis the original error andcontextincludes the plugin name and hook name
Scenario: dependency topological install order
- WHEN
installAll([pluginA depends on B, pluginB])is called - THEN
pluginB.onInstallfires beforepluginA.onInstall
Scenario: missing dependency rejects
- WHEN
installAll([pluginA depends on Z])is called with nopluginZ - THEN the call throws
GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)with the missing dependency name incontext
Scenario: circular dependency rejects
- WHEN
installAll([A depends on B, B depends on A])is called - THEN the call throws
GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)withcycleincontext
Verified by: src/engine/plugin-manager/plugin-manager.test.ts::"install registers the plugin and fires onInstall + OTel counter" AND src/engine/plugin-manager/plugin-manager.test.ts::"second install with the same name rejects with DUPLICATE_PLUGIN" AND src/engine/plugin-manager/plugin-manager.test.ts::"unknown plugin name rejects with PLUGIN_NOT_FOUND on uninstall/enable/disable/get" AND src/engine/plugin-manager/plugin-manager.test.ts::"enable then disable toggles state and fires both hooks" AND src/engine/plugin-manager/plugin-manager.test.ts::"hook errors wrap into PLUGIN_LIFECYCLE_FAILED with cause and context" AND src/engine/plugin-manager/plugin-manager.test.ts::"installAll honours topological dependency order" AND src/engine/plugin-manager/plugin-manager.test.ts::"installAll rejects when a declared dependency is missing" AND src/engine/plugin-manager/plugin-manager.test.ts::"installAll rejects on circular dependency"
Requirement: GameEngine top-level orchestrator
The package SHALL provide GameEngine wiring a DIContainer, a
GameLoop, and a PluginManager plus opaque service slots
(logger, events, sessionManager, playerManager, stateManager,
gameLogic). Construction SHALL Zod-validate options and reject with
INVALID_ENGINE_OPTIONS. The engine SHALL refuse tick()/stop() before
start() (ENGINE_NOT_STARTED), refuse double start()
(ENGINE_ALREADY_STARTED), and refuse any method after dispose()
(ENGINE_DISPOSED).
Scenario: constructor registers core services in DIContainer
- WHEN the engine is constructed with a logger, events, gameLoop, pluginManager, and IdGenerator
- THEN each is resolvable from
engine.containerunder its canonical identifier
Scenario: start emits OTel + transitions running state
- WHEN
start()is called on a fresh engine - THEN the underlying
GameLoop.start()is invoked,isRunning()returnstrue, andgame_server.engine.lifecycle_total{op: "start"}increments
Scenario: double start rejects
- WHEN
start()is called a second time - THEN the call throws
GameServerError(GAME_SERVER.ENGINE_ALREADY_STARTED)
Scenario: tick without start rejects
- WHEN
tick()is called beforestart() - THEN the call throws
GameServerError(GAME_SERVER.ENGINE_NOT_STARTED)
Scenario: dispose clears container, stops loop, blocks future calls
- WHEN
dispose()is called thenstart()is called - THEN the second
start()throwsGameServerError(GAME_SERVER.ENGINE_DISPOSED)
Scenario: registerPlugin delegates to PluginManager
- WHEN
registerPlugin(plugin)is called - THEN the underlying
PluginManager.install(plugin)runs andgetPlugin(name)returns it
Scenario: invalid options rejected at construction
- WHEN the engine is constructed with
playerTimeout: -1 - THEN the constructor throws
GameServerError(GAME_SERVER.INVALID_ENGINE_OPTIONS)
Scenario: getStats reports lifecycle + child stats
- WHEN
getStats()is called on a running engine - THEN the result includes
{ running: true, disposed: false, loop: <GameLoop.getStats()>, plugins: <PluginManager.getStats()> }
Verified by: src/engine/game-engine/game-engine.test.ts::"constructor registers logger/events/loop/plugins in the container" AND src/engine/game-engine/game-engine.test.ts::"start invokes loop.start, transitions running, increments OTel" AND src/engine/game-engine/game-engine.test.ts::"second start throws ENGINE_ALREADY_STARTED" AND src/engine/game-engine/game-engine.test.ts::"tick before start throws ENGINE_NOT_STARTED" AND src/engine/game-engine/game-engine.test.ts::"dispose blocks future start with ENGINE_DISPOSED" AND src/engine/game-engine/game-engine.test.ts::"registerPlugin delegates to the underlying PluginManager" AND src/engine/game-engine/game-engine.test.ts::"invalid options throw INVALID_ENGINE_OPTIONS" AND src/engine/game-engine/game-engine.test.ts::"getStats reports running/disposed/loop/plugins"
Requirement: Subpath exports
The package SHALL expose the three feature directories 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/game-engine,@suaveplan/game-server/engine/game-loop, and@suaveplan/game-server/engine/plugin-manager - THEN the import resolves to the corresponding feature barrel without pulling sibling code
Verified by: packages/gaming/games/game-server/package.json::"exports./engine/game-engine" AND packages/gaming/games/game-server/package.json::"exports./engine/game-loop" AND packages/gaming/games/game-server/package.json::"exports./engine/plugin-manager"
From change: game-server-engine-sessions
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"
From change: game-server-state
Requirement: Observable state container
The package SHALL expose a StateManager<TState extends object> class
that holds a single typed state value, returns deep-frozen snapshots
through getState/get, and emits a StateChangeEvent on every
mutation. Listener faults SHALL be isolated and counted but MUST NOT
propagate or skip remaining listeners.
Scenario: getState returns deep-frozen snapshot
- WHEN a caller invokes
getState() - THEN the returned value is
Object.isFrozen-true at every level reachable through plain object/array traversal.
Scenario: listener errors do not interrupt other listeners
- WHEN one subscribed listener throws and another is registered
- THEN the non-throwing listener is still invoked exactly once per
mutation and the
listener_error_totalOTel counter increments.
Verified by:
src/state/state-manager/state-manager.test.ts::"getState returns deep-frozen current state"
AND
src/state/state-manager/state-manager.test.ts::"listener errors are isolated and counted"
AND
src/state/state-manager/state-manager.test.ts::"event carries previous and current snapshots and id+timestamp".
Requirement: Snapshot restore with typed validation
StateManager.restore(snapshot, validator?) SHALL run the optional
predicate before cloning. A failing predicate SHALL throw
GameServerError(GAME_SERVER.INVALID_SNAPSHOT) and leave the manager
state unchanged. A clone failure SHALL be wrapped as
GameServerError(GAME_SERVER.STATE_RESTORE_FAILED).
Scenario: validator rejects malformed snapshot
- WHEN
restoreis invoked with a snapshot the validator rejects - THEN a
GameServerErrorwith codeGAME_SERVER.INVALID_SNAPSHOTis thrown andgetState()returns the previous state unchanged.
Scenario: clone failure wrapped as STATE_RESTORE_FAILED
- WHEN the snapshot has a throwing getter that triggers during clone
- THEN a
GameServerErrorwith codeGAME_SERVER.STATE_RESTORE_FAILEDis thrown.
Verified by:
src/state/state-manager/state-manager.test.ts::"restore throws INVALID_SNAPSHOT when validator fails"
AND
src/state/state-manager/state-manager.test.ts::"restore wraps clone failures as STATE_RESTORE_FAILED".
Requirement: Structural diff and patch utility
The package SHALL expose a StateDiffer class with diff(a, b),
patch(s, d), isEmpty(d), and mergeDeltas(deltas) methods that
produce and consume a discriminated DiffOperation grammar (add,
remove, replace). For any plain-JSON pair (a, b), patch(a, diff(a, b))
SHALL be deep-equal to b.
Scenario: round-trip on nested object + array state
- WHEN
patch(a, diff(a, b))is computed for any pair of plain JSON-serialisable valuesaandb - THEN the result is deep-equal to
b.
Scenario: typed errors on diff/patch failure
- WHEN an internal traversal throws
- THEN the error surfaces as
GameServerErrorwith codeGAME_SERVER.STATE_DIFF_FAILEDorGAME_SERVER.STATE_PATCH_FAILEDrespectively.
Verified by:
src/state/state-differ/state-differ.test.ts::"round-trips: patch(a, diff(a, b)) deep-equals b"
AND
src/state/state-differ/state-differ.test.ts::"wraps internal failures as STATE_DIFF_FAILED"
AND
src/state/state-differ/state-differ.test.ts::"wraps patch failures as STATE_PATCH_FAILED".
Requirement: Versioned, Zod-validated delta compressor
The package SHALL expose a DeltaCompressor class that produces a
CompressedDelta packet { version, timestamp, data: Uint8Array, stats }
from any pair of plain-JSON snapshots, and decompresses such a packet
back to a snapshot. The wire payload SHALL be Zod-validated on receipt.
Version mismatches and parse failures SHALL surface as
GameServerError(GAME_SERVER.STATE_DECOMPRESS_FAILED).
Scenario: round-trip via compress + decompress
- WHEN a delta produced by
compress(a, b)is applied withdecompress(a, packet) - THEN the result equals
b.
Scenario: version mismatch raises typed error
- WHEN
decompressreceives a packet whoseversiondiffers fromCOMPRESSED_DELTA_VERSION - THEN a
GameServerErrorwith codeGAME_SERVER.STATE_DECOMPRESS_FAILEDis thrown.
Scenario: schema-invalid payload raises typed error
- WHEN
decompressreceives a packet whose data parses to a value that fails the operation schema - THEN a
GameServerErrorwith codeGAME_SERVER.STATE_DECOMPRESS_FAILEDis thrown.
Verified by:
src/state/delta-compressor/delta-compressor.test.ts::"round-trips compress + decompress"
AND
src/state/delta-compressor/delta-compressor.test.ts::"throws STATE_DECOMPRESS_FAILED on version mismatch"
AND
src/state/delta-compressor/delta-compressor.test.ts::"throws STATE_DECOMPRESS_FAILED on schema-invalid payload".
Requirement: Transport-agnostic state synchroniser
The package SHALL expose a StateSync<TState> class that orchestrates
delta and full-snapshot synchronisation between a StateManager and an
injectable StateSyncTransport. The first call to sync() SHALL send
a full snapshot. Subsequent calls SHALL send a delta unless the delta
payload exceeds the configurable maxDeltaSize, in which case a full
snapshot SHALL be sent instead. Transport send failures SHALL surface
as GameServerError(GAME_SERVER.STATE_SYNC_TRANSPORT_FAILED).
Scenario: initial sync sends full snapshot, subsequent sends delta
- WHEN
sync()is called twice on a freshly-constructedStateSyncwith an interveningStateManager.update - THEN the first transport message has
kind === "full"and the second haskind === "delta".
Scenario: large delta forces full-snapshot fallback
- WHEN the computed delta exceeds
maxDeltaSize - THEN a full snapshot is sent instead.
Scenario: transport failure wrapped as STATE_SYNC_TRANSPORT_FAILED
- WHEN
transport.sendrejects or throws - THEN a
GameServerErrorwith codeGAME_SERVER.STATE_SYNC_TRANSPORT_FAILEDpropagates to the caller.
Verified by:
src/state/state-sync/state-sync.test.ts::"first sync() sends a full snapshot"
AND
src/state/state-sync/state-sync.test.ts::"subsequent sync() sends a delta"
AND
src/state/state-sync/state-sync.test.ts::"falls back to full snapshot when delta exceeds maxDeltaSize"
AND
src/state/state-sync/state-sync.test.ts::"wraps transport send errors as STATE_SYNC_TRANSPORT_FAILED".
From change: game-server-network
Requirement: Unified network adapter contract
The package SHALL expose a NetworkAdapter class that wraps a structural
Transport interface and provides single-initialisation, multi-subscriber
fan-out, Zod-validated outbound + inbound frames, and OpenTelemetry
counters for messages sent / received / errors.
Scenario: single-initialisation guard
- WHEN
initialize(...)is called twice without an interveningdispose()/disconnect() - THEN the second call throws
GameServerErrorwith codeGAME_SERVER.TRANSPORT_ALREADY_INITIALIZED.
Scenario: send before initialise
- WHEN
send(...)is called beforeinitialize(...) - THEN it rejects with code
GAME_SERVER.TRANSPORT_NOT_INITIALIZED.
Scenario: send while transport disconnected
- WHEN the underlying transport never fires
onConnectandsendis invoked - THEN it rejects with code
GAME_SERVER.TRANSPORT_NOT_CONNECTED.
Scenario: outbound message validation
- WHEN a frame with empty
typeis passed tosend - THEN it rejects with code
GAME_SERVER.INVALID_MESSAGE_FORMAT.
Scenario: fan-out and handler isolation
- WHEN multiple subscribers are attached via
onMessageand one of them throws - THEN every other subscriber still receives the frame and the failure is logged through the injected logger.
Verified by:
src/network/network-adapter/network-adapter.test.ts::"initialises a transport and connects"
AND
src/network/network-adapter/network-adapter.test.ts::"rejects double-initialisation"
AND
src/network/network-adapter/network-adapter.test.ts::"rejects send before initialize"
AND
src/network/network-adapter/network-adapter.test.ts::"rejects send when not connected"
AND
src/network/network-adapter/network-adapter.test.ts::"rejects malformed outgoing message"
AND
src/network/network-adapter/network-adapter.test.ts::"fans inbound messages to every subscriber"
AND
src/network/network-adapter/network-adapter.test.ts::"isolates handler errors and still delivers to others"
AND
src/network/network-adapter/network-adapter.test.ts::"drops malformed inbound frames"
AND
src/network/network-adapter/network-adapter.test.ts::"exposes stats counts".
Requirement: Reconnection with exponential backoff
The package SHALL expose a ReconnectionManager that schedules
reconnection attempts through an injectable TimersLike, delegates
backoff calculation to @suaveplan/retry's createExponentialBackoff,
and reports outcomes through OTel counter + histogram.
Scenario: success on first attempt
- WHEN the supplied
reconnectcallback resolves on the first try - THEN the manager resets to
{ attempts: 0, isReconnecting: false }and stops scheduling.
Scenario: exponential retry until success
- WHEN
reconnectfails twice and then succeeds - THEN the manager invokes the callback exactly three times, doubling the envelope between attempts.
Scenario: exhaustion
- WHEN
maxAttemptsisnandreconnectalways rejects - THEN the manager emits
network:reconnect-exhaustedafternfailures and stops scheduling further attempts.
Scenario: double-start guard
- WHEN
start()is invoked while a reconnection is already in progress - THEN it throws
GameServerErrorwith codeGAME_SERVER.RECONNECT_IN_PROGRESS.
Scenario: use after dispose
- WHEN
start()orforceReconnect()is invoked afterdispose() - THEN it throws
GameServerErrorwith codeGAME_SERVER.RECONNECT_EXHAUSTED.
Verified by:
src/network/reconnection-manager/reconnection-manager.test.ts::"succeeds on first attempt"
AND
src/network/reconnection-manager/reconnection-manager.test.ts::"retries with exponential backoff on failure"
AND
src/network/reconnection-manager/reconnection-manager.test.ts::"exhausts after maxAttempts and emits exhausted event"
AND
src/network/reconnection-manager/reconnection-manager.test.ts::"rejects start when already reconnecting"
AND
src/network/reconnection-manager/reconnection-manager.test.ts::"start throws after dispose"
AND
src/network/reconnection-manager/reconnection-manager.test.ts::"forceReconnect throws after dispose".
Requirement: WebSocket transport wrapper
The package SHALL expose a WebSocketTransport class implementing the
unified Transport contract by wrapping a structural UnderlyingWebSocket
client. JSON framing SHALL be applied to outgoing messages and inbound
parse failures SHALL surface through onError rather than throwing.
Scenario: connect forwards protocols
- WHEN
connect(url, { protocols: ["v1"] })is invoked - THEN the underlying client receives the URL and protocols.
Scenario: send rejects when not connected
- WHEN
send(...)is invoked beforeconnect(...) - THEN it rejects with code
GAME_SERVER.TRANSPORT_NOT_CONNECTED.
Scenario: parse error surfaces through onError
- WHEN the underlying client delivers a non-JSON frame
- THEN the registered
onErrorhandler receives a typedGameServerErrorwith codeGAME_SERVER.INVALID_MESSAGE_FORMAT.
Verified by:
src/network/websocket-transport/websocket-transport.test.ts::"connects with protocols"
AND
src/network/websocket-transport/websocket-transport.test.ts::"send serialises message as JSON"
AND
src/network/websocket-transport/websocket-transport.test.ts::"send rejects when disconnected"
AND
src/network/websocket-transport/websocket-transport.test.ts::"reports parse errors through onError"
AND
src/network/websocket-transport/websocket-transport.test.ts::"propagates connect/disconnect callbacks".
Requirement: WebRTC transport wrapper
The package SHALL expose a WebRTCTransport class implementing the
unified Transport contract by wrapping a structural UnderlyingWebRTC
client. The wrapper SHALL provide pass-through signalling helpers
(addIceCandidate, applyAnswer) and SHALL throw a typed error when
the underlying client does not implement them.
Scenario: connect with options bag
- WHEN
connect(url, { iceServers: [...] })is invoked - THEN the options bag is forwarded verbatim to the underlying
client's
open(...).
Scenario: signalling pass-through
- WHEN
addIceCandidate(c)andapplyAnswer(a)are invoked on a client that implements them - THEN the candidate / answer is delivered verbatim to the client.
Scenario: signalling unsupported
- WHEN the underlying client omits
addIceCandidate/applyAnswer - THEN the wrapper throws
GameServerErrorwith codeGAME_SERVER.TRANSPORT_NOT_INITIALIZED.
Verified by:
src/network/webrtc-transport/webrtc-transport.test.ts::"connects with options bag"
AND
src/network/webrtc-transport/webrtc-transport.test.ts::"send serialises message as JSON"
AND
src/network/webrtc-transport/webrtc-transport.test.ts::"addIceCandidate forwards to underlying"
AND
src/network/webrtc-transport/webrtc-transport.test.ts::"applyAnswer forwards to underlying"
AND
src/network/webrtc-transport/webrtc-transport.test.ts::"addIceCandidate throws when unsupported"
AND
src/network/webrtc-transport/webrtc-transport.test.ts::"applyAnswer throws when unsupported".
Requirement: Extended error code registry
The package SHALL register six additional GAME_SERVER.* codes
introduced by the network slice and SHALL expose them on the
GAME_SERVER_CODES constant alongside the existing core codes.
Scenario: registry shape
- WHEN the test suite inspects
GAME_SERVER_CODES - THEN it contains
TRANSPORT_ALREADY_INITIALIZED,TRANSPORT_NOT_INITIALIZED,TRANSPORT_NOT_CONNECTED,INVALID_MESSAGE_FORMAT,RECONNECT_EXHAUSTED,RECONNECT_IN_PROGRESS, each fully-qualified asGAME_SERVER.<NAME>.
Verified by:
src/errors/game-server-error.test.ts::"registers all expected codes under GAME_SERVER namespace".
From change: game-server-matchmaking
Requirement: QueueManager primary-priority queue
QueueManager SHALL maintain an in-memory priority queue keyed by an
arbitrary string (typically a game mode or queue type) and prioritise
entries by a documented mix of premium bonus, wait-time bonus, and an
optional caller-supplied base offset.
Scenario: addToQueue accepts a valid request
- WHEN a caller invokes
addToQueue("ranked", {...validRequest}) - THEN the request is inserted into the queue and the queue's size grows by one
- AND the
game_server.matchmaking.queue_enqueue_totalcounter is incremented
Scenario: addToQueue rejects when the queue is at capacity
- GIVEN a
QueueManagerconfigured withmaxQueueSize: 1 - AND one request has been accepted
- WHEN a second request is submitted
- THEN
addToQueuethrowsGameServerError(GAME_SERVER.MATCHMAKING_QUEUE_FULL) - AND the
game_server.matchmaking.queue_reject_total{reason=full}counter is incremented
Scenario: findMatch atomically pops the top N entries
- GIVEN three queued players
- WHEN the caller invokes
findMatch("ranked", 2) - THEN the top two entries (by priority) are returned in order
- AND the queue's size shrinks by two
- AND the wait-time histogram is sampled twice
Requirement: SkillMatcher Elo-style closest-rating selection
SkillMatcher SHALL select the candidate closest in rating to the
seeker, widening the acceptance band proportional to the seeker's
waiting time and respecting a configurable hard ceiling.
Scenario: closest opponent is returned
- GIVEN a seeker rated 1500 and candidates rated 1400, 1510, 1700
- AND the configured
ratingRangeis 500 - WHEN the caller invokes
findMatch(seeker, candidates) - THEN the matcher returns the 1510-rated candidate
Scenario: rejection on a non-finite rating
- WHEN the caller submits a
SkillEntrywhose rating isNaN - THEN
findMatchthrowsGameServerError(GAME_SERVER.INVALID_SKILL_SCORE)
Requirement: SpectatorManager per-session cap
SpectatorManager SHALL enforce a configurable per-session spectator
cap and surface typed-error rejections on cap violation or duplicate
spectator registration.
Scenario: cap rejection
- GIVEN a
SpectatorManagerconfigured withmaxSpectatorsPerSession: 1 - AND one spectator has been added to session "s1"
- WHEN a second spectator is added to the same session
- THEN
addSpectatorthrowsGameServerError(GAME_SERVER.SPECTATOR_LIMIT_REACHED)
Scenario: duplicate spectator rejection
- GIVEN a spectator "alice" already attached to session "s1"
- WHEN
addSpectator("s1", "alice", perms)is invoked again - THEN the call throws
GameServerError(GAME_SERVER.INVALID_SPECTATOR_REQUEST)
Requirement: WinnerStaysOnManager streak tracking
WinnerStaysOnManager SHALL track the current champion, their
consecutive win streak, and a FIFO challenger queue, and expose an
isChampionRotationDue flag once the streak meets maxWinStreak.
Scenario: throne transfer
- GIVEN "alice" recorded as winner against "bob"
- WHEN the manager records "bob" as winner against "alice"
- THEN the champion becomes "bob" with
winStreak === 1 - AND "alice" is enqueued at the back of the challenger list
Requirement: MatchmakingEngine façade
MatchmakingEngine SHALL expose createMatch(players, criteria, algorithm) and createMatchFromQueue(queueKey, criteria, algorithm)
that pick the algorithm, validate inputs via Zod, mint a stable
matchId via an injected IdGenerator, and notify any registered
onMatchFound listener.
Scenario: quick algorithm matches FIFO
- GIVEN three queued players and
MatchCriteriaof{ minPlayers: 2, maxPlayers: 4 } - WHEN
createMatch(players, criteria, "quick")is invoked - THEN the engine returns a
Matchcontaining all three players
Scenario: skill-based algorithm uses the largest viable group
- GIVEN four players with ratings 1000, 1100, 1200, 2500
- AND
MatchCriteriaof{ minPlayers: 2, maxPlayers: 4, skillRange: 250 } - WHEN
createMatch(players, criteria, "skill-based")is invoked - THEN the engine returns the three rated 1000/1100/1200
Scenario: unknown algorithm rejection
- WHEN the caller passes
algorithm: "nuke" - THEN
createMatchthrowsGameServerError(GAME_SERVER.UNKNOWN_MATCHING_ALGORITHM)
Scenario: createMatchFromQueue restores on shortfall
- GIVEN a
QueueManagerwith one queued player andMatchCriteriaof{ minPlayers: 2, maxPlayers: 4 } - WHEN
createMatchFromQueue("ranked", criteria)is invoked - THEN the call returns
null - AND the queue's size remains 1
From change: game-server-performance
Requirement: ObjectPool typed reuse
ObjectPool<T> SHALL recycle typed objects between a configurable
factory, reset, and optional dispose to dampen GC pressure, and
SHALL enforce a configurable maxSize cap by raising typed errors
when callers misuse the pool.
Scenario: pre-allocation populates the pool
- GIVEN an
ObjectPoolconfigured withinitialSize: 2andpreAllocate: true - WHEN the pool is constructed
- THEN
getStats().availablereports2
Scenario: acquire returns a recycled object
- GIVEN a pool with two pre-allocated objects
- WHEN the caller invokes
acquire() - THEN the returned object came from the pool
- AND
getStats().totalReusedreports1
Scenario: cap rejection
- GIVEN a pool with
maxSize: 1and one object outstanding - WHEN the caller invokes
acquire()again - THEN the call throws
GameServerError(GAME_SERVER.POOL_EXHAUSTED)
Scenario: foreign-object release rejection
- WHEN the caller releases an object not handed out by this pool
- THEN
releasethrowsGameServerError(GAME_SERVER.RELEASE_FOREIGN_OBJECT)
Scenario: dispose lockout
- GIVEN a disposed
ObjectPool - WHEN the caller invokes
acquire(),release(obj), orclear() - THEN each call throws
GameServerError(GAME_SERVER.POOL_DISPOSED)
Requirement: SessionPool plain-data session recycling
SessionPool SHALL pool plain-data PooledSession records and
expose a acquireSession(data) API that stamps the borrowed record
with the caller-supplied identity and metadata before returning it.
Scenario: acquireSession stamps the record
- WHEN the caller invokes
acquireSession({ id: "match-42", type: "game", metadata: {...} }) - THEN the returned
PooledSessioncarriesid === "match-42" - AND its
childrenarray is empty - AND its
metadata.createdAtmirrors the caller's input
Scenario: releaseSession recycles the record
- GIVEN a pool with
initialSize: 1, maxSize: 5 - WHEN the caller acquires, mutates, and releases a session
- AND the caller immediately re-acquires
- THEN the same underlying object is returned with a freshly
empty
childrenarray
Requirement: NetworkTierAdapter capability-driven tier selection
NetworkTierAdapter SHALL accept a non-empty, validated tier list at
construction time, track per-node capability assessments, and
recommend an optimal tier per node based on its composite score.
Scenario: empty tier list rejection
- WHEN the adapter is constructed with
tiers: [] - THEN the constructor throws
GameServerError(GAME_SERVER.INVALID_TIER_CONFIG)
Scenario: score-based optimal tier
- GIVEN an adapter with
host,sub-host,playertiers - WHEN
getOptimalTier({score: 92, ...})is invoked - THEN the returned tier carries
nodeType === "host" - AND
getOptimalTier({score: 60, ...})returnssub-host - AND
getOptimalTier({score: 20, ...})returnsplayer
Scenario: load-balancing recommendation sort order
- GIVEN three registered nodes with scores 90, 60, 20
- WHEN
getLoadBalancingRecommendations()is invoked - THEN the result is sorted by capability score descending
- AND the first entry's
recommendedLoadisfloor(90 / 10) === 9
Scenario: unknown-tier rejection
- WHEN
getTierForNodeType("nope")is invoked - THEN the call throws
GameServerError(GAME_SERVER.UNKNOWN_TIER)
From change: game-server-plugins
Requirement: BasePlugin SHALL implement the EnginePlugin contract
BasePlugin is an abstract base class that implements the four lifecycle
hooks of EnginePlugin and forwards each to a protected template-method
hook subclasses override. Subclasses declare name, version, and an
optional dependencies array.
Scenario: invokes onInstallHook on install
- WHEN
BasePlugin.onInstall(ctx)is awaited - THEN the subclass's
onInstallHook(ctx)runs first - AND the plugin state transitions to
"installed" - AND
snapshot().installedAtis set toclock()
Verified by: it("invokes onInstallHook and transitions to 'installed'")
Scenario: rejects any hook after destroy
- WHEN a plugin has reached
"destroyed"(afteronUninstall) - AND
onInstall/onEnable/onDisable/onUninstallis called again - THEN the call rejects with
GAME_SERVER.PLUGIN_BASE_DESTROYED
Verified by: it("rejects any hook after destroy")
Scenario: adopts context logger on install
- WHEN a
BasePluginis constructed without an explicitlogger - AND
onInstall(ctx)runs - THEN subsequent
this.loggercalls forward toctx.logger
Verified by: it("adopts the context logger on install when no override given")
Requirement: ABTestPlugin SHALL bucket players via injectable Rng
ABTestPlugin registers weighted experiments and assigns players to
variants by accumulating variant weights and comparing against
rng.nextFloat() * totalWeight. Assignment is idempotent per player.
Scenario: deterministic assignment with seeded Rng
- GIVEN a seeded
Rng - WHEN
assignPlayer(experimentId, playerId)is called twice for the sameplayerId - THEN both calls return the same
variantId
Verified by: it("assigns players to a variant deterministically and idempotently")
Scenario: weighted distribution honoured
- GIVEN an experiment with two equal-weight variants
- WHEN
rng.nextFloat()returns0.0, 0.4, 0.6, 0.9in sequence - THEN the first two assignments land in variant
aand the next two inb
Verified by: it("assigns according to weighting")
Scenario: missing experiment surfaces stable error code
- WHEN
assignPlayerorgetSummaryis called for an unknown id - THEN
GameServerErrorwith codeGAME_SERVER.AB_TEST_EXPERIMENT_NOT_FOUNDis thrown
Verified by: it("requireExperiment throws on missing")
Requirement: AnalyticsPlugin SHALL subscribe via GameEventEmitter
AnalyticsPlugin subscribes to session:created, session:destroyed,
game:started, game:ended, player:joined, player:left, and
move:processed on install and routes structured AnalyticsEvent payloads
to an injectable sink.
Scenario: dispatches to sink on subscribed events
- WHEN
game:startedandplayer:joinedare emitted - THEN the sink receives
session:startedandplayer:joinedanalytics events withtimestamp = clock()
Verified by: it("subscribes to game events on install and dispatches to sink")
Scenario: marks abandoned sessions
- WHEN a session is active for longer than
abandonAfterMs - THEN
getMetrics().sessionsAbandonedis incremented
Verified by: it("marks abandoned sessions")
Scenario: unsubscribes on uninstall
- WHEN the plugin is uninstalled
- THEN subsequent emitter events are not delivered to the sink
Verified by: it("unsubscribes on uninstall — emitter events stop reaching the plugin")
Requirement: FeatureFlagPlugin SHALL adapt FlagManager with per-player context
FeatureFlagPlugin wraps a @suaveplan/feature-flags FlagManager and
merges the per-call playerId into the evaluation context.
Scenario: per-player evaluation context
- WHEN
isEnabled("flag", "p1")is called - THEN the underlying
FlagManager.isEnabledis invoked with a context whoseuserId === "p1"
Verified by: it("evaluates a boolean flag via isEnabled") and
it("builds context with defaultContext merged")
Scenario: rolling evaluation log
- WHEN evaluations exceed
maxLogEntries - THEN the oldest entries are dropped FIFO
Verified by: it("records evaluations and trims to maxLogEntries")
Requirement: LoggingPlugin SHALL forward emitter events to a LoggerLike sink
LoggingPlugin subscribes to player, game, and session events (each
individually toggleable) and forwards them to a LoggerLike sink, also
retaining a bounded FIFO ring buffer.
Scenario: level mapping per event class
- WHEN
player:joined,move:rejected,move:processed, andgame:startedare emitted - THEN the buffer carries one
info, onewarn, onedebug, and oneinfoentry respectively
Verified by: it("forwards game events including move levels")
Scenario: ring buffer trims to maxEntries
- WHEN more than
maxEntriesevents are emitted - THEN the oldest entries are dropped
Verified by: it("trims buffer to maxEntries")
Requirement: PersistencePlugin SHALL persist via StorageLike with Zod-validated load
PersistencePlugin writes per-session save records as UTF-8 JSON via
StorageLike.set and verifies records on load against a Zod schema.
Scenario: round-trip save → load
- WHEN a session is tracked via
game:started+game:state:changed - AND
saveis awaited thenloadis awaited - THEN the loaded record reflects the accumulated state
Verified by: it("manual save / load / delete round-trip")
Scenario: corrupt record surfaces stable error code
- WHEN the stored bytes do not decode to valid JSON
- THEN
loadthrowsGAME_SERVER.PERSISTENCE_LOAD_FAILED
Verified by: it("load throws on invalid JSON") and
it("load throws on schema mismatch")
Scenario: auto-save errors do not propagate to the emitter
- GIVEN
saveOnGameEnd: true - WHEN the storage
setcall rejects - THEN the error is logged and swallowed; the emitter continues to deliver subsequent events
Verified by: it("auto-save swallows storage errors to keep emitter alive")
Requirement: RulesPlugin SHALL evaluate three named rule sets
RulesPlugin exposes evaluateMove, evaluateGameStart, and
evaluateGameEnd, each backed by an independent Rule[] slot. Empty
slots short-circuit and record a passing history entry.
Scenario: ad-hoc evaluate rejects non-object context
- WHEN
evaluate(rules, null)is awaited - THEN it rejects with
GAME_SERVER.INVALID_RULE_CONTEXT
Verified by: it("evaluate rejects non-object context")
Scenario: history is bounded by historyCap
- WHEN more than
historyCapevaluations are recorded - THEN the history retains only the most recent
historyCapentries
Verified by: it("history is bounded by historyCap")
Requirement: WorkflowPlugin SHALL orchestrate registered engines
WorkflowPlugin keys WorkflowEngine instances by id and exposes
execute (run-and-record), cancel (defer to engine), and a rolling
history with getHistory/clearHistory.
Scenario: duplicate registration rejected
- WHEN
registerWorkflowis called twice with the same id - THEN the second call throws
GAME_SERVER.DUPLICATE_WORKFLOW
Verified by: it("rejects duplicate registration")
Scenario: execute records failed status on rejected promise
- GIVEN an engine whose
runrejects - WHEN
execute(id, data)is awaited - THEN the history entry status is
"failed"anderrorcarries the message
Verified by: it("execute records error on rejected promise")
Scenario: cancel returns false when no execution is active
- WHEN
cancel(id)is called with no in-flight execution - THEN the call returns
false
Verified by: it("cancel returns true for active and false for absent")
From change: game-server-enhanced-anti-cheat-lag
Requirement: Anti-Cheat Validator
@suaveplan/game-server SHALL expose an AntiCheatValidator<TMove, TAction>
class for server-authoritative validation of player movement and actions.
Scenario: Movement under maximum speed is accepted
- WHEN the validator receives two successive
MovementDatarecords for a player whose Euclidean distance over the elapsed time is belowmovement.maxSpeed × movement.toleranceFactor - THEN
validateMovementSHALL return{ valid: true } - AND SHALL record the latest position as the player's reference for subsequent calls.
Scenario: Teleport above threshold is rejected as major
- WHEN the Euclidean distance exceeds
movement.maxTeleportDistance - THEN
validateMovementSHALL return aValidationResultwhoseviolation.type === "teleport"andviolation.severity === "major" - AND SHALL emit a
violation:detectedevent.
Scenario: Rate limit blocks bursts beyond maxActions
- WHEN more than
maxActionsactions of the matchingactionType(or"all") arrive inside a rollingwindowMs - THEN
validateActionSHALL return aValidationResultwhoseviolation.type === "rate_limit" - AND SHALL emit a
rate_limit:exceededevent.
Scenario: Auto-flag triggers at threshold
- WHEN the player accumulates ≥
autoFlagThresholdviolations in the trailing 60 seconds - THEN the validator SHALL flag the player automatically
- AND the most recent recorded violation SHALL have
autoFlagged: true.
Scenario: Invalid configuration is rejected
- WHEN caller-supplied
AntiCheatConfigfails Zod validation - THEN the constructor SHALL throw a
GameServerErrorwith codeGAME_SERVER.INVALID_ANTI_CHEAT_CONFIG.
Requirement: Interest Manager
@suaveplan/game-server SHALL expose an InterestManager<TData> class
that performs area-of-interest culling for broadcast scope reduction.
Scenario: Entity inside view distance is visible
- WHEN a player and an entity are within
viewDistance - THEN
getVisibleEntitiesSHALL include the entity in its result.
Scenario: Visibility delta omits unchanged set
- WHEN
calculateVisibilityChangesis called twice with no intervening position change - THEN the second call SHALL return
null - AND SHALL NOT emit a
visibility:changedevent.
Scenario: Rectangular zone containment
- WHEN a 3D point's
(x, y, z)lies within±width/2,±height/2,±depth/2of a rectangular zone's centre - THEN
isInZoneSHALL returntrue.
Scenario: Custom zone delegates to provided predicate
- WHEN a
CustomZoneis registered withcontainsPoint - THEN
isInZoneSHALL invoke that predicate and return its result.
Scenario: Invalid configuration is rejected
- WHEN caller-supplied
InterestManagerConfigfails Zod validation - THEN the constructor SHALL throw
GameServerErrorwith codeGAME_SERVER.INVALID_INTEREST_CONFIG.
Requirement: Lag Compensator
@suaveplan/game-server SHALL expose a LagCompensator<TState> class for
server-side time-rewind of game state during hit validation.
Scenario: Bounded history pruning
- WHEN the number of recorded snapshots for an entity exceeds
maxHistorySize - THEN the oldest snapshots SHALL be evicted
- AND the count SHALL never grow beyond
maxHistorySize.
Scenario: Compensation is clamped
- WHEN the requested rewind exceeds
maxCompensationMs - THEN the returned
CompensatedState.compensationMsSHALL equalmaxCompensationMs - AND the returned
CompensatedState.clampedSHALL betrue.
Scenario: Interpolation between snapshots
- WHEN the rewind timestamp falls between two adjacent snapshots and
a
lerpfunction is supplied - THEN
getStateAtTimeSHALL invokelerpto produce the returned state - AND the returned
CompensatedState.interpolatedSHALL betrue.
Scenario: Hit validation requires both states
- WHEN the shooter or the target has no recorded state at the perceived time
- THEN
validateHitSHALL return{ valid: false }with areasondescribing the missing side.
Scenario: Invalid configuration is rejected
- WHEN caller-supplied
LagCompensatorConfigfails Zod validation - THEN the constructor SHALL throw
GameServerErrorwith codeGAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG.
From change: game-server-enhanced-content
Requirement: LeaderboardManager multi-board ranking
LeaderboardManager SHALL maintain independent leaderboards keyed by
string id, each configured with one of three ranking modes
(score / wins / elo) and an optional maxEntries cap.
Scenario: submitScore inserts and re-ranks
- GIVEN a leaderboard
"global"configured forscoremode - WHEN the caller invokes
submitScore("global", { playerId: "alice", displayName: "Alice", score: 100 }) - THEN Alice's entry has
rank === 1 - AND the
game_server.leaderboard.submit_totalcounter is incremented
Scenario: maxEntries evicts the lowest-ranked entry
- GIVEN a leaderboard with
maxEntries: 2and entries(a:10, b:20, c:30) - WHEN all three submissions complete
- THEN the leaderboard retains only two entries
- AND the lowest-ranked entry (
a) has been evicted
Scenario: submitScore rejects an unknown leaderboard
- WHEN the caller invokes
submitScore("missing", ...) - THEN
GameServerError(GAME_SERVER.LEADERBOARD_NOT_FOUND)is thrown
Scenario: Elo update follows the standard formula
- GIVEN a leaderboard configured for
elomode withinitialElo: 1200 - WHEN a player submits
{ isWin: true, opponentElo: 1200 } - THEN the player's Elo strictly increases above 1200
Requirement: GamePersistence StorageLike-backed saves
GamePersistence SHALL persist game state through any conforming
StorageLike implementation, encoding SaveRecord payloads as
UTF-8 JSON under the key
game-server:save:<gameId>:<id>.
Scenario: save writes a record and returns its id
- WHEN the caller invokes
save(state, { name: "quicksave" }) - THEN the result's
success === true - AND subsequent
load(id)returns the samestate - AND the
game_server.persistence.save_total{outcome=ok}counter is incremented
Scenario: load() of unknown id throws PERSISTENCE_NOT_FOUND
- WHEN the caller invokes
load("missing") - THEN
GameServerError(GAME_SERVER.PERSISTENCE_NOT_FOUND)is thrown
Scenario: createSnapshot rotates past maxSnapshots
- GIVEN a persistence configured with
maxSnapshots: 2 - WHEN three snapshots are created in sequence
- THEN only the most recent two are retained on the record
Scenario: corrupt storage throws PERSISTENCE_LOAD_FAILED
- GIVEN a
StorageLikereturning invalid JSON bytes - WHEN the caller invokes
load(id) - THEN
GameServerError(GAME_SERVER.PERSISTENCE_LOAD_FAILED)is thrown
Requirement: ReplayRecorder deterministic input + snapshot capture
ReplayRecorder SHALL record inputs with monotonic sequence numbers
and periodic state snapshots into an in-memory buffer that is emitted as
a ReplayData<TState, TInput> document on stop().
Scenario: start from non-idle is rejected
- GIVEN a recorder in
recordingstatus - WHEN the caller invokes
start(initialState) - THEN
GameServerError(GAME_SERVER.REPLAY_RECORDING_STATE_INVALID)is thrown
Scenario: recordInput respects buffer cap
- GIVEN a recorder configured with
maxInputBuffer: 1 - AND one input recorded
- WHEN the caller invokes
recordInput(input) - THEN
GameServerError(GAME_SERVER.REPLAY_RECORDING_STATE_INVALID)is thrown
Scenario: tick auto-snapshots at the configured interval
- GIVEN a recorder with
snapshotInterval: 2 - WHEN
tick(state)is invoked twice with a state - THEN the buffer contains at least one snapshot with
reason === "interval"
Scenario: pause/resume excludes paused interval from elapsed time
- GIVEN a recorder started at clock 1000
- AND paused at clock 2000 then resumed at clock 5000
- WHEN elapsed time is read
- THEN the elapsed time equals 1000 (the unpaused gameplay duration)
Requirement: ReplayPlayer tick-driven deterministic playback
ReplayPlayer SHALL load ReplayData<TState, TInput> documents and
advance playback exclusively in response to tick() calls, applying
the configured PlaybackSpeed multiplier to each tick.
Scenario: play before load throws REPLAY_NOT_LOADED
- WHEN the caller invokes
play()without a loaded replay - THEN
GameServerError(GAME_SERVER.REPLAY_NOT_LOADED)is thrown
Scenario: tick delivers replayed inputs to listeners
- GIVEN a player with a loaded replay containing two inputs
- AND an
onInputlistener subscribed - WHEN
tick()is invoked enough times to reach the second input's tick - THEN the listener receives both inputs in
sequenceorder
Scenario: setSpeed rejects values outside the canonical set
- WHEN the caller invokes
setSpeed(7) - THEN
GameServerError(GAME_SERVER.REPLAY_INVALID_SPEED)is thrown
Scenario: loop=true rewinds at end
- GIVEN a player configured with
loop: true - AND a replay of 3 ticks
- WHEN
tick()is invoked 5 times during playback - THEN status remains
"playing"and the cursor wraps to ≤ 3
Requirement: TournamentManager bracket generation + advancement
TournamentManager SHALL accept participants in the pending phase,
build a single-elimination or round-robin bracket on start(), and
advance winners through nextMatchId links as results are recorded.
Scenario: start requires at least 2 participants
- GIVEN a tournament with one registered participant
- WHEN the caller invokes
start() - THEN
GameServerError(GAME_SERVER.TOURNAMENT_INSUFFICIENT_PARTICIPANTS)is thrown
Scenario: single-elimination propagates first-round byes
- GIVEN a single-elimination tournament with 3 participants
- WHEN
start()is invoked - THEN the first round contains at least one match with
status === "bye" - AND the surviving participant is advanced into the next round
Scenario: round-robin completes after every match recorded
- GIVEN a round-robin tournament with 4 participants
- WHEN every scheduled match has a recorded result
- THEN the tournament status transitions to
"completed" - AND
getWinner()returns the standings leader
Scenario: recordResult rejects a non-participant winner
- GIVEN an in-progress match between
aandb - WHEN the caller invokes
recordResult(matchId, "z") - THEN
GameServerError(GAME_SERVER.TOURNAMENT_INVALID_RESULT)is thrown
Scenario: shuffleSeeds uses injected Rng deterministically
- GIVEN a tournament configured with
shuffleSeeds: trueand a deterministicRng - WHEN
start()is invoked - THEN the participants are re-ordered using the
Rng'snextIntInRangeand theirseedfields are renumbered 1..N
From change: game-server-enhanced-infra
Requirement: Game instance registry with TTL eviction and load-balancing
The package SHALL expose a GameInstanceRegistry<TCustom> class that registers live game-server instances, evicts them when their last heartbeat is older than instanceTTL milliseconds (as reported by an injected Clock), supports filter-based discovery, and selects one healthy instance per request using one of four load-balancing strategies (least-players, random, region-based, round-robin). All clock and randomness reads SHALL flow through injected Clock and Rng dependencies.
Scenario: register / discover / deregister round-trip
- WHEN the caller registers an instance, then calls
discover()with no filter - THEN the registered instance appears in the result, defaulted to
HEALTHY - WHEN the caller deregisters
- THEN the next
discover()returns an empty array
Scenario: heartbeat expiry triggers eviction
- WHEN the injected clock advances past
instanceTTL * 2since the last heartbeat - THEN the registry transitions the instance to
UNHEALTHYfirst, then deregisters it on the second sweep - AND the corresponding
GameInstanceEvent.HEALTH_CHANGEDandGameInstanceEvent.DEREGISTEREDevents fire
Scenario: least-players strategy picks lowest player count
- WHEN two healthy instances exist with
playerCount = 5andplayerCount = 2 - THEN
selectInstance(..., 'least-players')returns the second one withscore = 2
Scenario: region-based strategy honours preferred region
- WHEN the caller passes
filter.region = "eu-west"and at least one EU instance exists - THEN the selected instance has
metadata.region === "eu-west"
Scenario: round-robin rotates across calls
- WHEN three instances are available and
selectInstance(..., 'round-robin')is called three times - THEN each call returns a different instance, then the rotation repeats
Verified by: src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"registers and discovers an instance" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"evicts after TTL expiry" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"least-players selects lowest count" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"region-based prefers configured region" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"round-robin rotates across calls"
Requirement: BitPacker-backed binary state synchronisation
The package SHALL expose a BitPackerStateSync<TState, TSchema extends BitPackerSchema> class that packs TState snapshots to Uint8Array via @suaveplan/bitpacker, supports client-side prediction with a bounded history window, supports server reconciliation against a tick-tagged authoritative state, supports linear interpolation between two history snapshots, and aggregates wire-byte counters into a SyncStats view. All timestamps SHALL flow through an injected Clock. Pack/unpack failures SHALL throw GameServerError with BITPACK_ENCODE_FAILED / BITPACK_DECODE_FAILED.
Scenario: pack then unpack round-trip preserves state
- WHEN the caller packs a
TStatevalue and unpacks the resultingUint8Array - THEN the unpacked value equals the original
Scenario: prediction records a history frame
- WHEN
predict(state, input, simulate)is called - THEN the returned state equals
simulate(state, input.data) - AND
getStats().predictionsMadeincrements by one
Scenario: reconciliation re-applies pending inputs above server tick
- WHEN the client has predicted three frames at ticks 11, 12, 13 and the server delivers a state at tick 10
- AND the client's snapshot at tick 10 differs from the server state beyond the threshold
- THEN
reconcile(...)returnsneeded: truewithframesResimulated = 3and a state derived from re-applying the three pending inputs
Scenario: interpolation lerps between snapshots
- WHEN the caller passes two snapshots at timestamps 100 and 200 and a render time bracketing the targetTime
- THEN the returned state equals
lerp(before, after, t)for somet ∈ [0, 1]
Scenario: bitpack failure routes to typed error
- WHEN
BitPacker.packthrows - THEN
packStatere-throws aGameServerErrorcarrying codeGAME_SERVER.BITPACK_ENCODE_FAILED
Verified by: src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"packs and unpacks state" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"prediction records a history frame" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"reconciliation re-applies pending inputs above the server tick" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"interpolation lerps between two snapshots" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"throws BITPACK_ENCODE_FAILED when bitpacker throws"
Requirement: Vector-clock distributed action ordering
The package SHALL expose a VectorClockSync<TEventData> class that records game actions per session, tags each action with a vector-clock snapshot, detects concurrent actions, resolves conflicts via one of three strategies (last-write-wins, merge, custom), produces a causality chain in happens-before order, and exposes request/response sync round-trips between nodes. The internal vector clock SHALL be the createVectorClock primitive from @suaveplan/timing. Action IDs SHALL be minted through an injected IdGenerator; timestamps SHALL flow through an injected Clock.
Scenario: recordAction tags with current vector clock
- WHEN
recordAction(sessionId, "move", data, tick)is called - THEN the returned
GameAction.vectorClockcarries this node's counter at >= 1 - AND
getStats().totalActionsequals the count of recordings
Scenario: receiveAction detects concurrent conflict
- WHEN two actions are recorded by different nodes with vector clocks that are mutually concurrent (
compare === "concurrent") - THEN
receiveAction(remote)returns aConflictResolutionwithconflictDetected: true - AND the chosen winner under
last-write-winsis the one with the larger timestamp
Scenario: custom resolver overrides default strategy
- WHEN the registry is constructed with a
conflictResolver - THEN the resolver function is called with each (winner, candidate) pair and its return value becomes the resolved action
- AND
ConflictResolution.strategy === "custom"
Scenario: causality chain sorts by happens-before
- WHEN action A is recorded, then action B is recorded on the same node
- THEN
getCausalityChain(sessionId).actions[0]is A and[1]is B
Scenario: sync request returns missing actions
- WHEN node X has recorded actions [a1, a2] and node Y has recorded none
- AND Y issues
requestSync(X, sessionId)with its current (empty) vector clock - THEN X's
handleSyncRequest(request)returns a response carrying botha1anda2
Verified by: src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"recordAction tags with the current vector clock" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"receiveAction detects concurrent conflict and resolves last-write-wins" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"custom resolver overrides default strategy" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"causality chain sorts by happens-before" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"sync request returns missing actions"
From change: game-server-integrations
Requirement: EnhancedGameServer top-of-stack bootstrap
The @suaveplan/game-server package SHALL expose an EnhancedGameServer
class under the ./integrations/enhanced-server subpath export that
bootstraps the canonical engine + plugin stack via a single
bootstrap(options) entry point.
Scenario: Bootstrap installs all eight plugins
- WHEN
bootstrap()is invoked with valid options - THEN a
GameLoop,PluginManager, andGameEngineSHALL be constructed and wired - AND all eight built-in plugins (
ABTestPlugin,AnalyticsPlugin,FeatureFlagPlugin,LoggingPlugin,PersistencePlugin,RulesPlugin,WorkflowPlugin, and one further reserved slot satisfied byBasePluginderivatives) SHALL be installed in dependency order viaPluginManager.installAll - AND the
GameEngineSHALL be returned ready tostart().
Scenario: Double bootstrap rejected
- WHEN
bootstrap()is invoked twice on the sameEnhancedGameServer - THEN the second call SHALL throw
GameServerErrorwith codeGAME_SERVER.ALREADY_BOOTSTRAPPED.
Scenario: Plugin install failure rolls back
- WHEN any plugin install fails during
bootstrap() - THEN the failure SHALL be wrapped as
GameServerErrorwith codeGAME_SERVER.BOOTSTRAP_FAILED - AND the underlying
causeSHALL be the original error.
Scenario: Lifecycle deferral
- WHEN the caller invokes
start()/tick()/stop()/dispose() - THEN the
EnhancedGameServerSHALL delegate to the underlyingGameEngineso existing engine lifecycle codes propagate unchanged.
Scenario: Injected dependencies are honoured
- WHEN
bootstrap(options)is invoked - THEN every plugin SHALL receive the injected
ClockandLoggerLikeso deterministic tests observe stable timestamps and captured logs.