1906 lines
85 KiB
Markdown
1906 lines
85 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
||
|
||
# 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"`
|
||
|
||
<!-- markdownlint-enable MD024 -->
|
||
|
||
---
|
||
|
||
## 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"`
|
||
|
||
<!-- markdownlint-enable MD024 -->
|
||
|
||
---
|
||
|
||
## 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.
|