Initial Commit
This commit is contained in:
105
openspec/archive/2026-05-24-game-server-core/proposal.md
Normal file
105
openspec/archive/2026-05-24-game-server-core/proposal.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Wave A — `@suaveplan/game-server` core foundation
|
||||
|
||||
**Package**: `@suaveplan/game-server`
|
||||
**Status**: PROPOSED
|
||||
**Type**: extends existing genesis package (0.1.0)
|
||||
**Design**: ./design.md (inline below)
|
||||
**Spec delta**: ./specs/game-server/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Wave A ships the core foundation of `@suaveplan/game-server` that every
|
||||
later wave (engine, session, state, matchmaking, transport) depends on:
|
||||
the four core interfaces (`IGameLogic`, `IPlayerManager`,
|
||||
`ISessionManager`, `IStateManager`) with runtime conformance harnesses,
|
||||
the `GameEvents` typed payload map plus its `GAME_EVENT_KEYS` registry,
|
||||
the `GameEventEmitter` typed pub/sub with ergonomic helpers
|
||||
(`safeEmit`, `waitFor`, `onN`, `onMultiple`), and the
|
||||
`AbstractGameLogic` Template-Method base class for concrete games. The
|
||||
package already shipped `DIContainer`, the `GameServerError` registry,
|
||||
and the Zod-validated core types in the prior change
|
||||
`enterprise-game-server-core`; Wave A is an additive extension that
|
||||
fills the interfaces / events / abstract-base layer behind a single
|
||||
0.1.0 publish.
|
||||
|
||||
Without this Wave the downstream waves (B: session manager + player
|
||||
manager, C: state manager + replay, D: matchmaking + transport plugins)
|
||||
have no contract to implement against. Shipping it as one isolated wave
|
||||
keeps the surface reviewable, the coverage gate enforceable on the
|
||||
whole package, and the dependent slices unblocked.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `core/types/core.types.ts` — `ConnectionStatus`,
|
||||
`PlayerConnection`, `SessionNode`, `SessionData`, `StateChangeEvent`,
|
||||
`GameEvents` map.
|
||||
- ADDED: `core/interfaces/i-game-logic/` — `IGameLogic<TState, TMove>`
|
||||
interface and `runIGameLogicConformance` harness.
|
||||
- ADDED: `core/interfaces/i-player-manager/` — `IPlayerManager` and
|
||||
`runIPlayerManagerConformance`.
|
||||
- ADDED: `core/interfaces/i-session-manager/` — `ISessionManager` and
|
||||
`runISessionManagerConformance`.
|
||||
- ADDED: `core/interfaces/i-state-manager/` — `IStateManager<TState>`
|
||||
and `runIStateManagerConformance`.
|
||||
- ADDED: `core/events/game-events/` — `GAME_EVENT_KEYS` array +
|
||||
`isGameEventKey` predicate.
|
||||
- ADDED: `core/events/game-event-emitter/` — `GameEventEmitter` typed
|
||||
pub/sub with `safeEmit` / `waitFor` / `onN` / `onMultiple` / per-key
|
||||
listener cap.
|
||||
- ADDED: `core/abstract-game-logic/` — `AbstractGameLogic<TState, TMove>`
|
||||
Template-Method base class with injected `Clock` + `IdGenerator` and
|
||||
OTel instrumentation.
|
||||
- EXTENDED: `errors/game-server-error.ts` — six new codes
|
||||
(`INVALID_MOVE`, `PLAYER_ALREADY_EXISTS`, `PLAYER_NOT_FOUND`,
|
||||
`LISTENER_LIMIT_EXCEEDED`, `INVALID_LISTENER_COUNT`,
|
||||
`EVENT_WAIT_TIMEOUT`) raised by the new core surface.
|
||||
- EXTENDED: root `src/index.ts` — additive named re-exports for the new
|
||||
Wave A surface.
|
||||
- EXTENDED: `package.json` exports map — 8 new subpath entries, one per
|
||||
feature module.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`bunx turbo ls --filter=...@suaveplan/game-server` enumerates dependents
|
||||
of this package. As of Wave A landing, no genesis package consumes
|
||||
`@suaveplan/game-server` yet — this is a foundation wave for
|
||||
`@suaveplan/game-loop`, `@suaveplan/game-state-schemas`, and the
|
||||
`api-plugin-game-server` family that will land in Waves B–D.
|
||||
|
||||
- `@suaveplan/game-server` (self) — [x] tests run / [x] docs reviewed /
|
||||
[ ] no action needed.
|
||||
- No external dependents — verified by `bunx turbo ls
|
||||
--filter=...@suaveplan/game-server`.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `IGameLogic` interface | YES | YES | Genesis tightens `any` → generic `TState`/`TMove`. |
|
||||
| Inline `AbstractGameLogic` in `IGameLogic.ts` | YES | UP | Genesis splits to its own feature dir. |
|
||||
| `IPlayerManager` interface | YES | YES | Returns `ReadonlyArray<PlayerConnection>`. |
|
||||
| `ISessionManager` interface | YES | YES | Returns `ReadonlyArray<SessionNode>`. |
|
||||
| `IStateManager` interface | YES | YES | `Readonly<TState>` instead of `DeepReadonly`. |
|
||||
| `GameEvents` map | YES | UP | Variadic tuples collapsed to single-payload objects. |
|
||||
| `GameEventEmitter` extending Node `EventEmitter` | YES | UP | Self-contained typed emitter; same surface +typed errors. |
|
||||
| Wildcard `[key: string]: any[]` index signature | YES | NO | Scoped out: closed map keeps lint/type safety; downstream needing wildcard analytics can subscribe to `GAME_EVENT_KEYS` directly. |
|
||||
| `AbstractGameLogic` Template Method | YES | UP | Adds injected `Clock` + `IdGenerator`, typed errors, OTel instruments. |
|
||||
| `cloneState` JSON round-trip | YES | YES | |
|
||||
| Hook methods (`beforeMoveProcessing`, etc.) | YES | YES | |
|
||||
| Player roster (`addPlayer` / `removePlayer`) | YES | UP | Throws `GameServerError` not plain `Error`. |
|
||||
| Conformance harnesses | NO | UP | Genesis-original — makes interfaces runtime-testable. |
|
||||
|
||||
Rationale for the single scope-out (`wildcard analytics:*`): the
|
||||
upstream signature `[key: symbol]: any[]` defeats type safety and is
|
||||
unused inside the `core/` slice; downstream analytics packages can
|
||||
extend `GameEvents` via module augmentation if needed.
|
||||
|
||||
## Migration
|
||||
|
||||
Wave A is additive — no existing public-surface item is renamed or
|
||||
removed. Consumers of the prior `0.1.0` surface (`DIContainer`,
|
||||
`GameServerError`, Zod parsers, types) keep working unchanged. The new
|
||||
error codes extend the registry; pattern-match consumers should not
|
||||
care because none of the new codes were thrown previously.
|
||||
@@ -0,0 +1,191 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: `game-server` (Wave A — core foundation)
|
||||
|
||||
**Package**: `@suaveplan/game-server`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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)
|
||||
55
openspec/archive/2026-05-24-game-server-core/tasks.md
Normal file
55
openspec/archive/2026-05-24-game-server-core/tasks.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Tasks — Wave A: `@suaveplan/game-server` core foundation
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work
|
||||
- [x] 0.2 Account for every `.claude/worktrees/agent-*` — operating inside `agent-a3cb7ed021fa6e13a` for this change
|
||||
- [x] 0.3 `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count — verified unchanged (no new packages added; this is an in-package extension)
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/packages/universal/game-server/src/core/` upstream sources
|
||||
- [x] 1.2 Read existing `openspec/changes/enterprise-game-server-core/proposal.md` to align with prior scope decisions
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md`
|
||||
- [x] 1.5 Source mapping documented in the Parity Checklist
|
||||
- [x] 1.6 Draft `specs/game-server/spec.md`
|
||||
- [x] 1.7 Every `### Requirement:` carries a `**Verified by:**` line
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/core/types/core.types.ts` — `ConnectionStatus`, `PlayerConnection`, `SessionNode`, `SessionData`, `StateChangeEvent`, `GameEvents`
|
||||
- [x] 2.2 `src/core/interfaces/i-game-logic/` — interface + `runIGameLogicConformance`
|
||||
- [x] 2.3 `src/core/interfaces/i-player-manager/` — interface + `runIPlayerManagerConformance`
|
||||
- [x] 2.4 `src/core/interfaces/i-session-manager/` — interface + `runISessionManagerConformance`
|
||||
- [x] 2.5 `src/core/interfaces/i-state-manager/` — interface + `runIStateManagerConformance`
|
||||
- [x] 2.6 `src/core/events/game-events/` — `GAME_EVENT_KEYS` + `isGameEventKey`
|
||||
- [x] 2.7 `src/core/events/game-event-emitter/` — `GameEventEmitter` with all helpers
|
||||
- [x] 2.8 `src/core/abstract-game-logic/` — `AbstractGameLogic` with Clock + IdGenerator + OTel
|
||||
- [x] 2.9 Extend `src/errors/game-server-error.ts` — 6 new codes
|
||||
- [x] 2.10 Extend root `src/index.ts` and `package.json` exports
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests for every implementation file (one `.test.ts` per `.ts`)
|
||||
- [x] 3.2 Conformance harness tests against compliant + non-compliant reference impls
|
||||
- [x] 3.3 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [x] 3.4 OTel assertion via `flushTestMetrics()` from the existing polyfill
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts`
|
||||
- [x] 4.2 Existing `README.md` covers the package (Wave A surface auto-discoverable via re-exports; no README rewrite required for the core extension)
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0 errors across the package
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/game-server` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/game-server` — 100/100/100/100
|
||||
- [ ] 5.4 Banned-pattern scan returns empty
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/game-server` — green
|
||||
|
||||
## 6. Archive (NOT performed in this worktree)
|
||||
|
||||
- [ ] 6.1 Fold delta into `openspec/specs/game-server/spec.md` (orchestrator step)
|
||||
- [ ] 6.2 Move folder to `openspec/archive/<yyyy-mm-dd>-game-server-core/` (orchestrator step)
|
||||
Reference in New Issue
Block a user