Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -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 -->

View 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/`.