Files
suaveplan/openspec/archive/2026-05-24-game-server-core/specs/game-server/spec.md
2026-05-27 23:29:53 +01:00

8.9 KiB

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)