Initial Commit
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# game-server-engine-core — GameEngine + GameLoop + PluginManager
|
||||
|
||||
**Package**: `@suaveplan/game-server`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature (Wave B-1 migration slice)
|
||||
|
||||
- **Design**: see this file (no separate design.md required for a thin orchestrator slice)
|
||||
- **Spec delta**: ./specs/game-server/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Wave A landed the foundations (`errors`, `types`, `engine/di-container`). The
|
||||
broader package still has no engine top-level — there is no `GameEngine` to
|
||||
orchestrate services, no `GameLoop` to drive deterministic ticks, and no
|
||||
`PluginManager` to wire third-party extensions. Without these the package is
|
||||
unusable for actual game work.
|
||||
|
||||
This slice (Wave B-1) ships the three engine primitives in dependency order
|
||||
so Wave B-2 (sessions/player-manager) and later state/network/matchmaking
|
||||
slices have a stable top-level to bind to. The migration is verbatim in
|
||||
intent (preserve upstream lifecycle + statistics surface) but recast at
|
||||
genesis bar — injectable `Clock`/`TimersLike`/`IdGenerator`, typed
|
||||
`GameServerError` codes, Zod-validated boundaries, OTel counters and
|
||||
histograms with real `MeterProvider` assertions, kebab-case `<feature>/`
|
||||
layout, 100/100/100/100 coverage.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `GameEngine` (in `./engine/game-engine`) — top-level orchestrator wiring DI-injected services with `start`/`stop`/`tick`/`dispose` lifecycle.
|
||||
- ADDED: `GameLoop` (in `./engine/game-loop`) — deterministic fixed-step loop with `Clock`/`TimersLike` injection, `tick-based`/`event-driven`/`hybrid` modes, pause/resume, `tick()` manual driver.
|
||||
- ADDED: `PluginManager` (in `./engine/plugin-manager`) — typed `EnginePlugin` registry with `install`/`uninstall`/`enable`/`disable`/`get` + lifecycle-hook execution wrapped in `PLUGIN_LIFECYCLE_FAILED`.
|
||||
- ADDED: `GAME_SERVER.*` codes — `ENGINE_NOT_STARTED`, `ENGINE_ALREADY_STARTED`, `ENGINE_DISPOSED`, `INVALID_ENGINE_OPTIONS`, `INVALID_GAME_LOOP_OPTIONS`, `GAME_LOOP_NOT_RUNNING`, `GAME_LOOP_ALREADY_RUNNING`, `PLUGIN_NOT_FOUND`, `DUPLICATE_PLUGIN`, `PLUGIN_LIFECYCLE_FAILED`.
|
||||
- ADDED: subpath exports `./engine/game-engine`, `./engine/game-loop`, `./engine/plugin-manager`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `SessionManager`, `PlayerManager`, `SessionTree` (Wave B-2 — orchestrator consumes them as opaque structural service interfaces only).
|
||||
- `StateManager`, `NetworkAdapter`, `MatchmakingEngine`, `QueueManager` (later waves).
|
||||
- Game-logic strategy concretes (`AbstractGameLogic`) — the engine accepts an opaque `IGameLogicLike` slot per upstream contract.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from upstream files on `/mnt/s/web/suaveplan`:
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| GameEngine orchestrator | `packages/universal/game-server/src/engine/GameEngine.ts` L60–L497 | Recast — drops session/state/network constructor wiring (Wave B-2+ scope); preserves DI-service registration + start/stop/dispose lifecycle. |
|
||||
| GameLoop deterministic ticks | `packages/universal/game-server/src/engine/GameLoop.ts` L36–L358 | Recast — `Date.now()` replaced by injectable `Clock`; `setInterval/clearInterval` replaced by `TimersLike`. |
|
||||
| PluginManager lifecycle | `packages/universal/game-server/src/engine/PluginManager.ts` L27–L355 | Recast — plain `Error` → `GameServerError`; dependency-aware topo sort preserved. |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| OTel counters + histograms | Genesis bar — upstream had no OTel; this slice adds `game_server.engine.lifecycle_total`, `game_server.loop.tick_duration_ms`, `game_server.plugin.lifecycle_total` to match the package quality bar (mirrors `@suaveplan/ecs`). |
|
||||
| Zod-validated option boundaries | Genesis bar — every public option boundary parses through Zod before reaching state. |
|
||||
| Typed `GameServerError` everywhere | Genesis bar — upstream threw plain `Error` for lifecycle violations. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/engine/game-engine/game-engine.test.ts` — covers GameEngine lifecycle, DI wiring, plugin handoff, OTel emission.
|
||||
- `src/engine/game-loop/game-loop.test.ts` — covers loop modes, deterministic ticking via injected Clock/TimersLike, pause/resume, error propagation.
|
||||
- `src/engine/plugin-manager/plugin-manager.test.ts` — covers install/uninstall/enable/disable, duplicate detection, dependency topo sort, hook execution.
|
||||
- `src/errors/game-server-error.test.ts` — extended to assert every new code.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/game-server` to enumerate. The
|
||||
package has no shipped dependents on genesis at the time of this change
|
||||
(verified: `bunx turbo ls --filter=...@suaveplan/game-server` returns only
|
||||
the package itself).
|
||||
|
||||
- No dependents — additive surface only.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. Existing `./errors`, `./types`, `./engine/di-container`
|
||||
surfaces are unchanged. The `GAME_SERVER_CODES` registry is **extended**;
|
||||
the existing 4 codes are preserved.
|
||||
@@ -0,0 +1,184 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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 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 -->
|
||||
48
openspec/archive/2026-05-24-game-server-engine-core/tasks.md
Normal file
48
openspec/archive/2026-05-24-game-server-engine-core/tasks.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Tasks — game-server-engine-core (Wave B-1)
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 `git status` clean in worktree, `git worktree list` accounted for.
|
||||
- [x] 0.2 Worktree at `.claude/worktrees/agent-acdee3cac67aeeafb` — isolated branch.
|
||||
- [x] 0.3 Manifest count matches package set (unchanged — no new packages added).
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Upstream files read: `GameEngine.ts`, `GameLoop.ts`, `PluginManager.ts`.
|
||||
- [x] 1.2 Read existing `packages/gaming/games/game-server/src/` foundations.
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` §13 and §14.
|
||||
- [x] 1.4 Drafted `proposal.md` from template.
|
||||
- [x] 1.5 Filled `## Source mapping` table.
|
||||
- [x] 1.6 Drafted `specs/game-server/spec.md` from template.
|
||||
- [x] 1.7 Every requirement carries `**Verified by:**`.
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Extend `GAME_SERVER_CODES` registry with engine + loop + plugin codes.
|
||||
- [x] 2.2 Implement `src/engine/game-loop/game-loop.ts` with Clock+TimersLike injection, modes, pause/resume.
|
||||
- [x] 2.3 Implement `src/engine/plugin-manager/plugin-manager.ts` with install/uninstall/enable/disable + topo sort.
|
||||
- [x] 2.4 Implement `src/engine/game-engine/game-engine.ts` orchestrator wiring DIContainer + GameLoop + PluginManager.
|
||||
- [x] 2.5 Add subpath exports for the three feature dirs to `package.json`.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `game-loop.test.ts` — covers every scenario in the spec (21 tests).
|
||||
- [x] 3.2 `plugin-manager.test.ts` — covers every scenario in the spec (12 tests).
|
||||
- [x] 3.3 `game-engine.test.ts` — covers every scenario in the spec (15 tests).
|
||||
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }`.
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced + API + impl notes).
|
||||
- [x] 4.2 README updated — `## Modules` lists the new `.md` files.
|
||||
|
||||
## 5. Pre-archive verification
|
||||
|
||||
- [x] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/game-server` — green (82 tests).
|
||||
- [x] 5.2 `bunx turbo test:coverage --filter=@suaveplan/game-server` — 100/100/100/100 across full package.
|
||||
- [x] 5.3 No banned patterns (`TODO`/`FIXME`/`biome-ignore`/runner imports).
|
||||
|
||||
## 6. Archive (orchestrator-owned)
|
||||
|
||||
- [ ] 6.1 Orchestrator folds `specs/game-server/spec.md` into `openspec/specs/game-server/spec.md` post-merge.
|
||||
- [ ] 6.2 Orchestrator moves change folder to `openspec/archive/<yyyy-mm-dd>-game-server-engine-core/`.
|
||||
Reference in New Issue
Block a user