Files
2026-05-27 23:29:53 +01:00

85 KiB
Raw Permalink Blame History

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 venue containing a table is destroyed
  • THEN the harness reports destroySession removes the node and its descendants as 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, and setState against 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 returns false

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:started throws
  • THEN safeEmit returns false (no successful listener), re-emits the error as error payload 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 maxListenersPerKey listeners are registered on one key
  • THEN on(...) throws GameServerError(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_total counter are recorded; the state installs from applyMove

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 validateMove returns { valid: false }
  • THEN processMove throws GameServerError(INVALID_MOVE) with the reason in error.context; rejections_total increments

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 addPlayer is called twice with the same id, or removePlayer is 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 AbstractGameLogic and GameEventEmitter run
  • 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-emitter or 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 with mode: "tick-based", tickRate: 30
  • THEN the loop schedules a recurring timer via TimersLike.setTimer with delay 1000 / 30 ms and invokes registered fixed-update callbacks each tick

Scenario: pause halts ticking, resume restarts without retroactive deltas

  • WHEN pause() then later resume() is called
  • THEN the resume call refreshes lastTickTime from the injected Clock and the next tick's deltaTime is bounded by maxDeltaTime

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: 0 or a negative maxDeltaTime
  • 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(), or stop() is called before start()
  • 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:error event 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_ms histogram receives one sample tagged with the current mode

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, onInstall was invoked once with the engine handle, and the game_server.plugin.lifecycle_total counter increments with op: "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"), or get("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 onEnable and then onDisable each fire once and isEnabled(name) returns the matching state

Scenario: plugin lifecycle errors wrap to PLUGIN_LIFECYCLE_FAILED

  • WHEN a plugin's onInstall throws
  • THEN install rejects with GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED) whose cause is the original error and context includes the plugin name and hook name

Scenario: dependency topological install order

  • WHEN installAll([pluginA depends on B, pluginB]) is called
  • THEN pluginB.onInstall fires before pluginA.onInstall

Scenario: missing dependency rejects

  • WHEN installAll([pluginA depends on Z]) is called with no pluginZ
  • THEN the call throws GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED) with the missing dependency name in context

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) with cycle in context

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.container under 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() returns true, and game_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 before start()
  • THEN the call throws GameServerError(GAME_SERVER.ENGINE_NOT_STARTED)

Scenario: dispose clears container, stops loop, blocks future calls

  • WHEN dispose() is called then start() is called
  • THEN the second start() throws GameServerError(GAME_SERVER.ENGINE_DISPOSED)

Scenario: registerPlugin delegates to PluginManager

  • WHEN registerPlugin(plugin) is called
  • THEN the underlying PluginManager.install(plugin) runs and getPlugin(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 timestamps arg
  • THEN createdAt and updatedAt are both set from the injected Clock

Scenario: addChild is idempotent

  • WHEN addChild(c) is called twice with the same child
  • THEN the child appears exactly once in children

Scenario: tree-walking helpers respect depth and ancestry

  • WHEN getDepth, getDescendants (DFS pre-order), getAncestors, and getRoot are called on a populated tree
  • THEN each helper returns the documented walk; isAncestorOf / isDescendantOf exclude self (strict)

Scenario: toJSON serialises root with null parentId

  • WHEN a root node is serialised
  • THEN parentId is null; for a non-root the parent's id is preserved

Verified by: src/engine/session-node/session-node.test.ts::"constructs with clock-supplied createdAt/updatedAt when no timestamps passed" AND src/engine/session-node/session-node.test.ts::"preserves timestamps when explicitly passed (restore path)" AND src/engine/session-node/session-node.test.ts::"addChild is idempotent — re-adding the same child is a no-op" AND src/engine/session-node/session-node.test.ts::"getDepth returns 0 for root, distance otherwise" AND src/engine/session-node/session-node.test.ts::"getDescendants returns DFS pre-order array" AND src/engine/session-node/session-node.test.ts::"isAncestorOf / isDescendantOf are strict (exclude self)" AND src/engine/session-node/session-node.test.ts::"toJSON serialises a node with null parentId for a root"


Requirement: SessionTree hierarchical graph

The package SHALL provide SessionTree owning a Map<id, SessionGraphNode> plus a Set<rootId> and exposing the mutation surface used by SessionStore. The createSession boundary SHALL parse SessionData through Zod and reject failures with INVALID_SESSION_DATA. Duplicate ids SHALL throw DUPLICATE_SESSION_ID; unknown parents SHALL throw PARENT_SESSION_NOT_FOUND. destroySession SHALL cascade bottom-up through every descendant.

Scenario: createSession registers a root and emits session:created

  • WHEN createSession(null, data) is called with valid data
  • THEN the node is registered as a root and session:created is emitted

Scenario: invalid SessionData rejects with INVALID_SESSION_DATA

  • WHEN createSession is called with id: "" or any schema-failing input
  • THEN a GameServerError(GAME_SERVER.INVALID_SESSION_DATA) is thrown

Scenario: duplicate id rejects with DUPLICATE_SESSION_ID

  • WHEN a second createSession reuses an existing id
  • THEN GameServerError(GAME_SERVER.DUPLICATE_SESSION_ID) is thrown

Scenario: unknown parent rejects with PARENT_SESSION_NOT_FOUND

  • WHEN createSession(parentId, ...) references a missing id
  • THEN GameServerError(GAME_SERVER.PARENT_SESSION_NOT_FOUND) is thrown

Scenario: destroySession cascades bottom-up

  • WHEN destroySession is called on an ancestor with descendants
  • THEN session:destroyed events fire in deepest-first order

Scenario: attachRestored re-attaches a deserialised node without firing session:created

  • WHEN attachRestored(node, parentId) is called during SessionStore.restore
  • THEN the node is inserted into the index, the session:created event is NOT emitted, and a duplicate id throws DUPLICATE_SESSION_ID

Scenario: validateIntegrity catches corrupted invariants

  • WHEN the bidirectional parent/children invariant is forced into an inconsistent state
  • THEN validateIntegrity() returns {valid: false, errors: [...]} with at least one diagnostic

Verified by: src/engine/session-tree/session-tree.test.ts::"createSession registers a root and emits session:created" AND src/engine/session-tree/session-tree.test.ts::"createSession rejects invalid SessionData with INVALID_SESSION_DATA" AND src/engine/session-tree/session-tree.test.ts::"createSession rejects duplicate ids with DUPLICATE_SESSION_ID" AND src/engine/session-tree/session-tree.test.ts::"createSession rejects unknown parents with PARENT_SESSION_NOT_FOUND" AND src/engine/session-tree/session-tree.test.ts::"destroySession cascades through descendants bottom-up" AND src/engine/session-tree/session-tree.test.ts::"attachRestored attaches a pre-built node without firing session:created" AND src/engine/session-tree/session-tree.test.ts::"validateIntegrity flags a tampered parent pointer"


Requirement: SessionStore implements ISessionManager

The package SHALL provide SessionStore implementing Wave A's ISessionManager interface (createSession, getSession, destroySession, getSessionsByType, getChildren, getParent, updateMetadata, getAllSessions, persist, restore). The store SHALL accept an injectable SessionPersistence adapter and fall back to an in-memory default when none is supplied. Construction SHALL Zod-validate options and reject with INVALID_SESSION_STORE_OPTIONS. restore SHALL handle arbitrary record orderings via a fixed-point walk that attaches any record whose parent is already present.

Scenario: ISessionManager conformance harness passes

  • WHEN runISessionManagerConformance(() => new SessionStore(deps)) is invoked
  • THEN every scenario in the harness reports passed: true

Scenario: invalid options reject with INVALID_SESSION_STORE_OPTIONS

  • WHEN new SessionStore(deps, { storageKey: "" })
  • THEN the constructor throws GameServerError(GAME_SERVER.INVALID_SESSION_STORE_OPTIONS)

Scenario: persist + restore round-trip preserves the tree

  • WHEN a populated SessionStore is persisted, then cleared, then restored
  • THEN the restored tree has the same node count and parent/child relationships

Scenario: restore handles permuted record orderings

  • WHEN a persisted payload lists children before their parents
  • THEN the fixed-point walk attaches every record and the final tree shape is identical to the pre-persist state

Scenario: restore logs orphan records and skips them

  • WHEN a payload references a parent that doesn't exist in the same payload
  • THEN the orphan id is logged via logger.warn and the rest of the records still come online

Verified by: src/engine/session-store/session-store.test.ts::"ISessionManager conformance harness passes" AND src/engine/session-store/session-store.test.ts::"rejects invalid options with INVALID_SESSION_STORE_OPTIONS" AND src/engine/session-store/session-store.test.ts::"persist+restore round-trips with the in-memory default" AND src/engine/session-store/session-store.test.ts::"restore handles permuted record ordering via fixed-point walk" AND src/engine/session-store/session-store.test.ts::"restore logs orphan records as warnings and skips them"


Requirement: SessionLifecycle per-session FSM

The package SHALL provide SessionLifecycle implementing a per-session finite-state machine with the closed three-state vocabulary lobby | active | ended. The transition table SHALL be: lobby → active, lobby → ended, active → ended. Every other pair SHALL be rejected with INVALID_SESSION_TRANSITION. Transitions out of ended SHALL throw SESSION_ALREADY_ENDED. Transitions on unknown ids SHALL throw SESSION_NOT_FOUND. Every transition SHALL emit a session:transitioned event and record both game_server.session.lifecycle_total (counter) and game_server.session.dwell_ms (histogram) OTel instruments.

Scenario: register places the session in lobby

  • WHEN register(id) is called
  • THEN the new entry is in state lobby with enteredStateAt = clock()

Scenario: transition validates against the allow-table

  • WHEN transition("s1", "active") is called on a lobby entry
  • THEN the FSM advances to active and emits session:transitioned

Scenario: backwards transition rejects with INVALID_SESSION_TRANSITION

  • WHEN transition("s1", "lobby") is called on an active entry
  • THEN GameServerError(GAME_SERVER.INVALID_SESSION_TRANSITION) is thrown

Scenario: transition out of ended rejects with SESSION_ALREADY_ENDED

  • WHEN transition("s1", "active") is called on an ended entry
  • THEN GameServerError(GAME_SERVER.SESSION_ALREADY_ENDED) is thrown

Scenario: unknown id rejects with SESSION_NOT_FOUND

  • WHEN transition("nope", "active") is called
  • THEN GameServerError(GAME_SERVER.SESSION_NOT_FOUND) is thrown

Scenario: dwell histogram receives a sample per transition

  • WHEN the FSM transitions out of lobby
  • THEN game_server.session.dwell_ms records one sample tagged state: "lobby"

Scenario: forget drops the entry but preserves audit history

  • WHEN forget(id) is called after one or more transitions
  • THEN getState(id) returns null while getHistoryFor(id) still returns the transitions

Verified by: src/engine/session-lifecycle/session-lifecycle.test.ts::"register creates an entry in lobby state" AND src/engine/session-lifecycle/session-lifecycle.test.ts::"lobby → active transition succeeds and emits event" AND src/engine/session-lifecycle/session-lifecycle.test.ts::"active → lobby (backwards) is rejected with INVALID_SESSION_TRANSITION" AND src/engine/session-lifecycle/session-lifecycle.test.ts::"transition out of ended is rejected with SESSION_ALREADY_ENDED" AND src/engine/session-lifecycle/session-lifecycle.test.ts::"transition on unknown id is rejected with SESSION_NOT_FOUND" AND src/engine/session-lifecycle/session-lifecycle.test.ts::"emits session.dwell_ms histogram sample for the state being left" AND src/engine/session-lifecycle/session-lifecycle.test.ts::"history preserves transitions across forget()"


Requirement: PlayerManager implements IPlayerManager

The package SHALL provide PlayerManager implementing Wave A's IPlayerManager interface (11 methods). Construction SHALL Zod-validate options and reject with INVALID_PLAYER_MANAGER_OPTIONS. startHeartbeatMonitor SHALL Zod-validate the interval and reject non-positive integers with INVALID_HEARTBEAT_INTERVAL. The heartbeat monitor SHALL schedule via the injected TimersLike, NOT setInterval. checkTimeouts SHALL transition connected players past timeoutThresholdMs to timeout and evict disconnected entries past reconnectionGraceMs. A connect against an existing disconnected entry within the grace window SHALL walk the reconnection path (preserving connectedAt).

Scenario: IPlayerManager conformance harness passes

  • WHEN runIPlayerManagerConformance(() => new PlayerManager(deps)) is invoked
  • THEN every scenario in the harness reports passed: true

Scenario: invalid options reject with INVALID_PLAYER_MANAGER_OPTIONS

  • WHEN new PlayerManager(deps, { timeoutThresholdMs: 0 })
  • THEN the constructor throws GameServerError(GAME_SERVER.INVALID_PLAYER_MANAGER_OPTIONS)

Scenario: startHeartbeatMonitor rejects non-positive intervals

  • WHEN startHeartbeatMonitor(0) or startHeartbeatMonitor(1.5) is called
  • THEN the call throws GameServerError(GAME_SERVER.INVALID_HEARTBEAT_INTERVAL)

Scenario: heartbeat monitor schedules via TimersLike

  • WHEN startHeartbeatMonitor(50) is called
  • THEN TimersLike.setTimer is invoked with delay 50; firing a due timer triggers checkTimeouts and the monitor self-reschedules

Scenario: stopHeartbeatMonitor cancels the next scheduled tick

  • WHEN stopHeartbeatMonitor() is called after startHeartbeatMonitor
  • THEN the pending timer is cleared and the captured callback, if fired post-stop, does not re-schedule itself

Scenario: connect walks the reconnection path for disconnected entries

  • WHEN connect("p1", "s1", { region: "eu" }) is called after disconnect("p1", "x")
  • THEN connectedAt is preserved from the original entry, metadata is shallow-merged, and player:reconnected is emitted

Scenario: checkTimeouts evicts disconnected entries past grace window

  • WHEN checkTimeouts is called with now - conn.disconnectedAt > reconnectionGraceMs
  • THEN the connection is deleted; a subsequent getConnection returns null

Verified by: src/engine/player-manager/player-manager.test.ts::"IPlayerManager conformance harness passes" AND src/engine/player-manager/player-manager.test.ts::"rejects invalid options with INVALID_PLAYER_MANAGER_OPTIONS" AND src/engine/player-manager/player-manager.test.ts::"startHeartbeatMonitor rejects non-positive intervals" AND src/engine/player-manager/player-manager.test.ts::"startHeartbeatMonitor schedules ticks via TimersLike" AND src/engine/player-manager/player-manager.test.ts::"heartbeat tick stops re-scheduling if monitor was stopped mid-tick" AND src/engine/player-manager/player-manager.test.ts::"connect on a disconnected player walks the reconnection path" AND src/engine/player-manager/player-manager.test.ts::"checkTimeouts evicts disconnected entries past grace window"


Requirement: Subpath exports

The package SHALL expose each new feature directory via additive exports map entries so consumers can tree-shake.

Scenario: each feature dir is independently importable

  • WHEN a consumer imports from @suaveplan/game-server/engine/session-lifecycle, @suaveplan/game-server/engine/session-node, @suaveplan/game-server/engine/session-store, @suaveplan/game-server/engine/session-tree, or @suaveplan/game-server/engine/player-manager
  • THEN the import resolves to the corresponding feature barrel

Verified by: packages/gaming/games/game-server/package.json::"exports./engine/session-lifecycle" AND packages/gaming/games/game-server/package.json::"exports./engine/session-node" AND packages/gaming/games/game-server/package.json::"exports./engine/session-store" AND packages/gaming/games/game-server/package.json::"exports./engine/session-tree" AND packages/gaming/games/game-server/package.json::"exports./engine/player-manager"


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_total OTel 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 restore is invoked with a snapshot the validator rejects
  • THEN a GameServerError with code GAME_SERVER.INVALID_SNAPSHOT is thrown and getState() 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 GameServerError with code GAME_SERVER.STATE_RESTORE_FAILED is 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 values a and b
  • 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 GameServerError with code GAME_SERVER.STATE_DIFF_FAILED or GAME_SERVER.STATE_PATCH_FAILED respectively.

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 with decompress(a, packet)
  • THEN the result equals b.

Scenario: version mismatch raises typed error

  • WHEN decompress receives a packet whose version differs from COMPRESSED_DELTA_VERSION
  • THEN a GameServerError with code GAME_SERVER.STATE_DECOMPRESS_FAILED is thrown.

Scenario: schema-invalid payload raises typed error

  • WHEN decompress receives a packet whose data parses to a value that fails the operation schema
  • THEN a GameServerError with code GAME_SERVER.STATE_DECOMPRESS_FAILED is 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-constructed StateSync with an intervening StateManager.update
  • THEN the first transport message has kind === "full" and the second has kind === "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.send rejects or throws
  • THEN a GameServerError with code GAME_SERVER.STATE_SYNC_TRANSPORT_FAILED propagates 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 intervening dispose() / disconnect()
  • THEN the second call throws GameServerError with code GAME_SERVER.TRANSPORT_ALREADY_INITIALIZED.

Scenario: send before initialise

  • WHEN send(...) is called before initialize(...)
  • THEN it rejects with code GAME_SERVER.TRANSPORT_NOT_INITIALIZED.

Scenario: send while transport disconnected

  • WHEN the underlying transport never fires onConnect and send is invoked
  • THEN it rejects with code GAME_SERVER.TRANSPORT_NOT_CONNECTED.

Scenario: outbound message validation

  • WHEN a frame with empty type is passed to send
  • THEN it rejects with code GAME_SERVER.INVALID_MESSAGE_FORMAT.

Scenario: fan-out and handler isolation

  • WHEN multiple subscribers are attached via onMessage and 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 reconnect callback resolves on the first try
  • THEN the manager resets to { attempts: 0, isReconnecting: false } and stops scheduling.

Scenario: exponential retry until success

  • WHEN reconnect fails twice and then succeeds
  • THEN the manager invokes the callback exactly three times, doubling the envelope between attempts.

Scenario: exhaustion

  • WHEN maxAttempts is n and reconnect always rejects
  • THEN the manager emits network:reconnect-exhausted after n failures and stops scheduling further attempts.

Scenario: double-start guard

  • WHEN start() is invoked while a reconnection is already in progress
  • THEN it throws GameServerError with code GAME_SERVER.RECONNECT_IN_PROGRESS.

Scenario: use after dispose

  • WHEN start() or forceReconnect() is invoked after dispose()
  • THEN it throws GameServerError with code GAME_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 before connect(...)
  • 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 onError handler receives a typed GameServerError with code GAME_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) and applyAnswer(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 GameServerError with code GAME_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 as GAME_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_total counter is incremented

Scenario: addToQueue rejects when the queue is at capacity

  • GIVEN a QueueManager configured with maxQueueSize: 1
  • AND one request has been accepted
  • WHEN a second request is submitted
  • THEN addToQueue throws GameServerError(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 ratingRange is 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 SkillEntry whose rating is NaN
  • THEN findMatch throws GameServerError(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 SpectatorManager configured with maxSpectatorsPerSession: 1
  • AND one spectator has been added to session "s1"
  • WHEN a second spectator is added to the same session
  • THEN addSpectator throws GameServerError(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 MatchCriteria of { minPlayers: 2, maxPlayers: 4 }
  • WHEN createMatch(players, criteria, "quick") is invoked
  • THEN the engine returns a Match containing all three players

Scenario: skill-based algorithm uses the largest viable group

  • GIVEN four players with ratings 1000, 1100, 1200, 2500
  • AND MatchCriteria of { 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 createMatch throws GameServerError(GAME_SERVER.UNKNOWN_MATCHING_ALGORITHM)

Scenario: createMatchFromQueue restores on shortfall

  • GIVEN a QueueManager with one queued player and MatchCriteria of { 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 ObjectPool configured with initialSize: 2 and preAllocate: true
  • WHEN the pool is constructed
  • THEN getStats().available reports 2

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().totalReused reports 1

Scenario: cap rejection

  • GIVEN a pool with maxSize: 1 and 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 release throws GameServerError(GAME_SERVER.RELEASE_FOREIGN_OBJECT)

Scenario: dispose lockout

  • GIVEN a disposed ObjectPool
  • WHEN the caller invokes acquire(), release(obj), or clear()
  • 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 PooledSession carries id === "match-42"
  • AND its children array is empty
  • AND its metadata.createdAt mirrors 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 children array

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, player tiers
  • WHEN getOptimalTier({score: 92, ...}) is invoked
  • THEN the returned tier carries nodeType === "host"
  • AND getOptimalTier({score: 60, ...}) returns sub-host
  • AND getOptimalTier({score: 20, ...}) returns player

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 recommendedLoad is floor(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().installedAt is set to clock()

Verified by: it("invokes onInstallHook and transitions to 'installed'")

Scenario: rejects any hook after destroy

  • WHEN a plugin has reached "destroyed" (after onUninstall)
  • AND onInstall/onEnable/onDisable/onUninstall is 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 BasePlugin is constructed without an explicit logger
  • AND onInstall(ctx) runs
  • THEN subsequent this.logger calls forward to ctx.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 same playerId
  • 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() returns 0.0, 0.4, 0.6, 0.9 in sequence
  • THEN the first two assignments land in variant a and the next two in b

Verified by: it("assigns according to weighting")

Scenario: missing experiment surfaces stable error code

  • WHEN assignPlayer or getSummary is called for an unknown id
  • THEN GameServerError with code GAME_SERVER.AB_TEST_EXPERIMENT_NOT_FOUND is 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:started and player:joined are emitted
  • THEN the sink receives session:started and player:joined analytics events with timestamp = 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().sessionsAbandoned is 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.isEnabled is invoked with a context whose userId === "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, and game:started are emitted
  • THEN the buffer carries one info, one warn, one debug, and one info entry respectively

Verified by: it("forwards game events including move levels")

Scenario: ring buffer trims to maxEntries

  • WHEN more than maxEntries events 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 save is awaited then load is 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 load throws GAME_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 set call 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 historyCap evaluations are recorded
  • THEN the history retains only the most recent historyCap entries

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 registerWorkflow is 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 run rejects
  • WHEN execute(id, data) is awaited
  • THEN the history entry status is "failed" and error carries 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 MovementData records for a player whose Euclidean distance over the elapsed time is below movement.maxSpeed × movement.toleranceFactor
  • THEN validateMovement SHALL 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 validateMovement SHALL return a ValidationResult whose violation.type === "teleport" and violation.severity === "major"
  • AND SHALL emit a violation:detected event.

Scenario: Rate limit blocks bursts beyond maxActions

  • WHEN more than maxActions actions of the matching actionType (or "all") arrive inside a rolling windowMs
  • THEN validateAction SHALL return a ValidationResult whose violation.type === "rate_limit"
  • AND SHALL emit a rate_limit:exceeded event.

Scenario: Auto-flag triggers at threshold

  • WHEN the player accumulates ≥ autoFlagThreshold violations 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 AntiCheatConfig fails Zod validation
  • THEN the constructor SHALL throw a GameServerError with code GAME_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 getVisibleEntities SHALL include the entity in its result.

Scenario: Visibility delta omits unchanged set

  • WHEN calculateVisibilityChanges is called twice with no intervening position change
  • THEN the second call SHALL return null
  • AND SHALL NOT emit a visibility:changed event.

Scenario: Rectangular zone containment

  • WHEN a 3D point's (x, y, z) lies within ±width/2, ±height/2, ±depth/2 of a rectangular zone's centre
  • THEN isInZone SHALL return true.

Scenario: Custom zone delegates to provided predicate

  • WHEN a CustomZone is registered with containsPoint
  • THEN isInZone SHALL invoke that predicate and return its result.

Scenario: Invalid configuration is rejected

  • WHEN caller-supplied InterestManagerConfig fails Zod validation
  • THEN the constructor SHALL throw GameServerError with code GAME_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.compensationMs SHALL equal maxCompensationMs
  • AND the returned CompensatedState.clamped SHALL be true.

Scenario: Interpolation between snapshots

  • WHEN the rewind timestamp falls between two adjacent snapshots and a lerp function is supplied
  • THEN getStateAtTime SHALL invoke lerp to produce the returned state
  • AND the returned CompensatedState.interpolated SHALL be true.

Scenario: Hit validation requires both states

  • WHEN the shooter or the target has no recorded state at the perceived time
  • THEN validateHit SHALL return { valid: false } with a reason describing the missing side.

Scenario: Invalid configuration is rejected

  • WHEN caller-supplied LagCompensatorConfig fails Zod validation
  • THEN the constructor SHALL throw GameServerError with code GAME_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 for score mode
  • 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_total counter is incremented

Scenario: maxEntries evicts the lowest-ranked entry

  • GIVEN a leaderboard with maxEntries: 2 and 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 elo mode with initialElo: 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 same state
  • 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 StorageLike returning 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 recording status
  • 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 onInput listener subscribed
  • WHEN tick() is invoked enough times to reach the second input's tick
  • THEN the listener receives both inputs in sequence order

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 a and b
  • 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: true and a deterministic Rng
  • WHEN start() is invoked
  • THEN the participants are re-ordered using the Rng's nextIntInRange and their seed fields 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 * 2 since the last heartbeat
  • THEN the registry transitions the instance to UNHEALTHY first, then deregisters it on the second sweep
  • AND the corresponding GameInstanceEvent.HEALTH_CHANGED and GameInstanceEvent.DEREGISTERED events fire

Scenario: least-players strategy picks lowest player count

  • WHEN two healthy instances exist with playerCount = 5 and playerCount = 2
  • THEN selectInstance(..., 'least-players') returns the second one with score = 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 TState value and unpacks the resulting Uint8Array
  • 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().predictionsMade increments 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(...) returns needed: true with framesResimulated = 3 and 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 some t ∈ [0, 1]

Scenario: bitpack failure routes to typed error

  • WHEN BitPacker.pack throws
  • THEN packState re-throws a GameServerError carrying code GAME_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.vectorClock carries this node's counter at >= 1
  • AND getStats().totalActions equals 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 a ConflictResolution with conflictDetected: true
  • AND the chosen winner under last-write-wins is 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 both a1 and a2

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, and GameEngine SHALL be constructed and wired
  • AND all eight built-in plugins (ABTestPlugin, AnalyticsPlugin, FeatureFlagPlugin, LoggingPlugin, PersistencePlugin, RulesPlugin, WorkflowPlugin, and one further reserved slot satisfied by BasePlugin derivatives) SHALL be installed in dependency order via PluginManager.installAll
  • AND the GameEngine SHALL be returned ready to start().

Scenario: Double bootstrap rejected

  • WHEN bootstrap() is invoked twice on the same EnhancedGameServer
  • THEN the second call SHALL throw GameServerError with code GAME_SERVER.ALREADY_BOOTSTRAPPED.

Scenario: Plugin install failure rolls back

  • WHEN any plugin install fails during bootstrap()
  • THEN the failure SHALL be wrapped as GameServerError with code GAME_SERVER.BOOTSTRAP_FAILED
  • AND the underlying cause SHALL be the original error.

Scenario: Lifecycle deferral

  • WHEN the caller invokes start() / tick() / stop() / dispose()
  • THEN the EnhancedGameServer SHALL delegate to the underlying GameEngine so existing engine lifecycle codes propagate unchanged.

Scenario: Injected dependencies are honoured

  • WHEN bootstrap(options) is invoked
  • THEN every plugin SHALL receive the injected Clock and LoggerLike so deterministic tests observe stable timestamps and captured logs.