10 KiB
Capability Spec Delta: game-server (game-server-engine-core)
Package: @suaveplan/game-server
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: GameLoop deterministic ticking
The package SHALL provide GameLoop driving a deterministic fixed-step
loop with tick-based, event-driven, and hybrid modes. All wall-clock
reads SHALL route through an injected Clock; all interval scheduling
SHALL route through an injected TimersLike. The loop SHALL NOT call
Date.now() or setTimeout/setInterval directly.
Scenario: tick-based mode schedules at the configured tick rate
- WHEN
start()is called withmode: "tick-based", tickRate: 30 - THEN the loop schedules a recurring timer via
TimersLike.setTimerwith delay1000 / 30ms and invokes registered fixed-update callbacks each tick
Scenario: pause halts ticking, resume restarts without retroactive deltas
- WHEN
pause()then laterresume()is called - THEN the resume call refreshes
lastTickTimefrom the injectedClockand the next tick'sdeltaTimeis bounded bymaxDeltaTime
Scenario: manual tick() invokes all callbacks in registration order
- WHEN
tick()is called with two registered fixed-update callbacks and one update callback - THEN both fixed-update callbacks fire (in insertion order) then the update callback fires once
Scenario: invalid options reject with INVALID_GAME_LOOP_OPTIONS
- WHEN the loop is constructed with
tickRate: 0or a negativemaxDeltaTime - THEN a
GameServerError(GAME_SERVER.INVALID_GAME_LOOP_OPTIONS)is thrown
Scenario: starting a running loop rejects with GAME_LOOP_ALREADY_RUNNING
- WHEN
start()is called twice in a row - THEN the second call throws
GameServerError(GAME_SERVER.GAME_LOOP_ALREADY_RUNNING)
Scenario: pause/resume/stop on idle loop rejects with GAME_LOOP_NOT_RUNNING
- WHEN
pause(),resume(), orstop()is called beforestart() - THEN each call throws
GameServerError(GAME_SERVER.GAME_LOOP_NOT_RUNNING)
Scenario: callback errors propagate via injected EventBus
- WHEN a registered update callback throws inside
tick() - THEN the loop emits a
loop:errorevent with the original error and continues processing remaining callbacks
Scenario: OTel histogram records each tick duration
- WHEN
tick()completes - THEN the
game_server.loop.tick_duration_mshistogram receives one sample tagged with the currentmode
Verified by: src/engine/game-loop/game-loop.test.ts::"schedules tick-based ticks via injected TimersLike at the configured rate" AND src/engine/game-loop/game-loop.test.ts::"pause halts ticking and resume refreshes lastTickTime" AND src/engine/game-loop/game-loop.test.ts::"manual tick invokes fixed-update then update callbacks in order" AND src/engine/game-loop/game-loop.test.ts::"rejects construction with invalid options as INVALID_GAME_LOOP_OPTIONS" AND src/engine/game-loop/game-loop.test.ts::"throws GAME_LOOP_ALREADY_RUNNING on second start" AND src/engine/game-loop/game-loop.test.ts::"throws GAME_LOOP_NOT_RUNNING when paused/resumed/stopped before start" AND src/engine/game-loop/game-loop.test.ts::"callback errors emit loop:error and do not stop the loop" AND src/engine/game-loop/game-loop.test.ts::"records a tick_duration_ms histogram sample per tick"
Requirement: PluginManager lifecycle registry
The package SHALL provide PluginManager accepting a typed EnginePlugin
contract. Install SHALL reject duplicates with DUPLICATE_PLUGIN, lookup
SHALL reject unknown names with PLUGIN_NOT_FOUND, and every plugin
lifecycle hook (onInstall, onUninstall, onEnable, onDisable) that
throws SHALL be wrapped in PLUGIN_LIFECYCLE_FAILED carrying the plugin
name as context.
Scenario: install registers and emits OTel counter
- WHEN
install(plugin)is called with a fresh plugin - THEN
getPlugin(name)returns the plugin,onInstallwas invoked once with the engine handle, and thegame_server.plugin.lifecycle_totalcounter increments withop: "install"
Scenario: duplicate install rejects
- WHEN
install(plugin)is called twice with the same plugin name - THEN the second call throws
GameServerError(GAME_SERVER.DUPLICATE_PLUGIN)
Scenario: unknown plugin name on uninstall/enable/disable/get rejects
- WHEN
uninstall("nope"),enable("nope"),disable("nope"), orget("nope")is called - THEN the call throws
GameServerError(GAME_SERVER.PLUGIN_NOT_FOUND)
Scenario: enable then disable toggles state and fires hooks
- WHEN an installed plugin is enabled then disabled
- THEN
onEnableand thenonDisableeach fire once andisEnabled(name)returns the matching state
Scenario: plugin lifecycle errors wrap to PLUGIN_LIFECYCLE_FAILED
- WHEN a plugin's
onInstallthrows - THEN
installrejects withGameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)whosecauseis the original error andcontextincludes the plugin name and hook name
Scenario: dependency topological install order
- WHEN
installAll([pluginA depends on B, pluginB])is called - THEN
pluginB.onInstallfires beforepluginA.onInstall
Scenario: missing dependency rejects
- WHEN
installAll([pluginA depends on Z])is called with nopluginZ - THEN the call throws
GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)with the missing dependency name incontext
Scenario: circular dependency rejects
- WHEN
installAll([A depends on B, B depends on A])is called - THEN the call throws
GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)withcycleincontext
Verified by: src/engine/plugin-manager/plugin-manager.test.ts::"install registers the plugin and fires onInstall + OTel counter" AND src/engine/plugin-manager/plugin-manager.test.ts::"second install with the same name rejects with DUPLICATE_PLUGIN" AND src/engine/plugin-manager/plugin-manager.test.ts::"unknown plugin name rejects with PLUGIN_NOT_FOUND on uninstall/enable/disable/get" AND src/engine/plugin-manager/plugin-manager.test.ts::"enable then disable toggles state and fires both hooks" AND src/engine/plugin-manager/plugin-manager.test.ts::"hook errors wrap into PLUGIN_LIFECYCLE_FAILED with cause and context" AND src/engine/plugin-manager/plugin-manager.test.ts::"installAll honours topological dependency order" AND src/engine/plugin-manager/plugin-manager.test.ts::"installAll rejects when a declared dependency is missing" AND src/engine/plugin-manager/plugin-manager.test.ts::"installAll rejects on circular dependency"
Requirement: GameEngine top-level orchestrator
The package SHALL provide GameEngine wiring a DIContainer, a
GameLoop, and a PluginManager plus opaque service slots
(logger, events, sessionManager, playerManager, stateManager,
gameLogic). Construction SHALL Zod-validate options and reject with
INVALID_ENGINE_OPTIONS. The engine SHALL refuse tick()/stop() before
start() (ENGINE_NOT_STARTED), refuse double start()
(ENGINE_ALREADY_STARTED), and refuse any method after dispose()
(ENGINE_DISPOSED).
Scenario: constructor registers core services in DIContainer
- WHEN the engine is constructed with a logger, events, gameLoop, pluginManager, and IdGenerator
- THEN each is resolvable from
engine.containerunder its canonical identifier
Scenario: start emits OTel + transitions running state
- WHEN
start()is called on a fresh engine - THEN the underlying
GameLoop.start()is invoked,isRunning()returnstrue, andgame_server.engine.lifecycle_total{op: "start"}increments
Scenario: double start rejects
- WHEN
start()is called a second time - THEN the call throws
GameServerError(GAME_SERVER.ENGINE_ALREADY_STARTED)
Scenario: tick without start rejects
- WHEN
tick()is called beforestart() - THEN the call throws
GameServerError(GAME_SERVER.ENGINE_NOT_STARTED)
Scenario: dispose clears container, stops loop, blocks future calls
- WHEN
dispose()is called thenstart()is called - THEN the second
start()throwsGameServerError(GAME_SERVER.ENGINE_DISPOSED)
Scenario: registerPlugin delegates to PluginManager
- WHEN
registerPlugin(plugin)is called - THEN the underlying
PluginManager.install(plugin)runs andgetPlugin(name)returns it
Scenario: invalid options rejected at construction
- WHEN the engine is constructed with
playerTimeout: -1 - THEN the constructor throws
GameServerError(GAME_SERVER.INVALID_ENGINE_OPTIONS)
Scenario: getStats reports lifecycle + child stats
- WHEN
getStats()is called on a running engine - THEN the result includes
{ running: true, disposed: false, loop: <GameLoop.getStats()>, plugins: <PluginManager.getStats()> }
Verified by: src/engine/game-engine/game-engine.test.ts::"constructor registers logger/events/loop/plugins in the container" AND src/engine/game-engine/game-engine.test.ts::"start invokes loop.start, transitions running, increments OTel" AND src/engine/game-engine/game-engine.test.ts::"second start throws ENGINE_ALREADY_STARTED" AND src/engine/game-engine/game-engine.test.ts::"tick before start throws ENGINE_NOT_STARTED" AND src/engine/game-engine/game-engine.test.ts::"dispose blocks future start with ENGINE_DISPOSED" AND src/engine/game-engine/game-engine.test.ts::"registerPlugin delegates to the underlying PluginManager" AND src/engine/game-engine/game-engine.test.ts::"invalid options throw INVALID_ENGINE_OPTIONS" AND src/engine/game-engine/game-engine.test.ts::"getStats reports running/disposed/loop/plugins"
Requirement: Subpath exports
The package SHALL expose the three feature directories via additive
exports map entries so consumers can tree-shake.
Scenario: each feature dir is independently importable
- WHEN a consumer imports from
@suaveplan/game-server/engine/game-engine,@suaveplan/game-server/engine/game-loop, and@suaveplan/game-server/engine/plugin-manager - THEN the import resolves to the corresponding feature barrel without pulling sibling code
Verified by: packages/gaming/games/game-server/package.json::"exports./engine/game-engine" AND packages/gaming/games/game-server/package.json::"exports./engine/game-loop" AND packages/gaming/games/game-server/package.json::"exports./engine/plugin-manager"