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

218 lines
7.6 KiB
Markdown

# game-server — Wave C-1 plugin layer
## ADDED Requirements
### Requirement: BasePlugin SHALL implement the EnginePlugin contract
`BasePlugin` is an abstract base class that implements the four lifecycle
hooks of `EnginePlugin` and forwards each to a `protected` template-method
hook subclasses override. Subclasses declare `name`, `version`, and an
optional `dependencies` array.
#### Scenario: invokes onInstallHook on install
- WHEN `BasePlugin.onInstall(ctx)` is awaited
- THEN the subclass's `onInstallHook(ctx)` runs first
- AND the plugin state transitions to `"installed"`
- AND `snapshot().installedAt` is set to `clock()`
**Verified by:** `it("invokes onInstallHook and transitions to 'installed'")`
#### Scenario: rejects any hook after destroy
- WHEN a plugin has reached `"destroyed"` (after `onUninstall`)
- AND `onInstall`/`onEnable`/`onDisable`/`onUninstall` is called again
- THEN the call rejects with `GAME_SERVER.PLUGIN_BASE_DESTROYED`
**Verified by:** `it("rejects any hook after destroy")`
#### Scenario: adopts context logger on install
- WHEN a `BasePlugin` is constructed without an explicit `logger`
- AND `onInstall(ctx)` runs
- THEN subsequent `this.logger` calls forward to `ctx.logger`
**Verified by:** `it("adopts the context logger on install when no override given")`
### Requirement: ABTestPlugin SHALL bucket players via injectable Rng
`ABTestPlugin` registers weighted experiments and assigns players to
variants by accumulating variant weights and comparing against
`rng.nextFloat() * totalWeight`. Assignment is idempotent per player.
#### Scenario: deterministic assignment with seeded Rng
- GIVEN a seeded `Rng`
- WHEN `assignPlayer(experimentId, playerId)` is called twice for the same
`playerId`
- THEN both calls return the same `variantId`
**Verified by:** `it("assigns players to a variant deterministically and idempotently")`
#### Scenario: weighted distribution honoured
- GIVEN an experiment with two equal-weight variants
- WHEN `rng.nextFloat()` returns `0.0, 0.4, 0.6, 0.9` in sequence
- THEN the first two assignments land in variant `a` and the next two in `b`
**Verified by:** `it("assigns according to weighting")`
#### Scenario: missing experiment surfaces stable error code
- WHEN `assignPlayer` or `getSummary` is called for an unknown id
- THEN `GameServerError` with code
`GAME_SERVER.AB_TEST_EXPERIMENT_NOT_FOUND` is thrown
**Verified by:** `it("requireExperiment throws on missing")`
### Requirement: AnalyticsPlugin SHALL subscribe via GameEventEmitter
`AnalyticsPlugin` subscribes to `session:created`, `session:destroyed`,
`game:started`, `game:ended`, `player:joined`, `player:left`, and
`move:processed` on install and routes structured `AnalyticsEvent` payloads
to an injectable sink.
#### Scenario: dispatches to sink on subscribed events
- WHEN `game:started` and `player:joined` are emitted
- THEN the sink receives `session:started` and `player:joined` analytics
events with `timestamp = clock()`
**Verified by:** `it("subscribes to game events on install and dispatches to sink")`
#### Scenario: marks abandoned sessions
- WHEN a session is active for longer than `abandonAfterMs`
- THEN `getMetrics().sessionsAbandoned` is incremented
**Verified by:** `it("marks abandoned sessions")`
#### Scenario: unsubscribes on uninstall
- WHEN the plugin is uninstalled
- THEN subsequent emitter events are not delivered to the sink
**Verified by:** `it("unsubscribes on uninstall — emitter events stop reaching the plugin")`
### Requirement: FeatureFlagPlugin SHALL adapt FlagManager with per-player context
`FeatureFlagPlugin` wraps a `@suaveplan/feature-flags` `FlagManager` and
merges the per-call `playerId` into the evaluation context.
#### Scenario: per-player evaluation context
- WHEN `isEnabled("flag", "p1")` is called
- THEN the underlying `FlagManager.isEnabled` is invoked with a context
whose `userId === "p1"`
**Verified by:** `it("evaluates a boolean flag via isEnabled")` and
`it("builds context with defaultContext merged")`
#### Scenario: rolling evaluation log
- WHEN evaluations exceed `maxLogEntries`
- THEN the oldest entries are dropped FIFO
**Verified by:** `it("records evaluations and trims to maxLogEntries")`
### Requirement: LoggingPlugin SHALL forward emitter events to a LoggerLike sink
`LoggingPlugin` subscribes to player, game, and session events (each
individually toggleable) and forwards them to a `LoggerLike` sink, also
retaining a bounded FIFO ring buffer.
#### Scenario: level mapping per event class
- WHEN `player:joined`, `move:rejected`, `move:processed`, and
`game:started` are emitted
- THEN the buffer carries one `info`, one `warn`, one `debug`, and one
`info` entry respectively
**Verified by:** `it("forwards game events including move levels")`
#### Scenario: ring buffer trims to maxEntries
- WHEN more than `maxEntries` events are emitted
- THEN the oldest entries are dropped
**Verified by:** `it("trims buffer to maxEntries")`
### Requirement: PersistencePlugin SHALL persist via StorageLike with Zod-validated load
`PersistencePlugin` writes per-session save records as UTF-8 JSON via
`StorageLike.set` and verifies records on `load` against a Zod schema.
#### Scenario: round-trip save → load
- WHEN a session is tracked via `game:started` + `game:state:changed`
- AND `save` is awaited then `load` is awaited
- THEN the loaded record reflects the accumulated state
**Verified by:** `it("manual save / load / delete round-trip")`
#### Scenario: corrupt record surfaces stable error code
- WHEN the stored bytes do not decode to valid JSON
- THEN `load` throws `GAME_SERVER.PERSISTENCE_LOAD_FAILED`
**Verified by:** `it("load throws on invalid JSON")` and
`it("load throws on schema mismatch")`
#### Scenario: auto-save errors do not propagate to the emitter
- GIVEN `saveOnGameEnd: true`
- WHEN the storage `set` call rejects
- THEN the error is logged and swallowed; the emitter continues to deliver
subsequent events
**Verified by:** `it("auto-save swallows storage errors to keep emitter alive")`
### Requirement: RulesPlugin SHALL evaluate three named rule sets
`RulesPlugin` exposes `evaluateMove`, `evaluateGameStart`, and
`evaluateGameEnd`, each backed by an independent `Rule[]` slot. Empty
slots short-circuit and record a passing history entry.
#### Scenario: ad-hoc evaluate rejects non-object context
- WHEN `evaluate(rules, null)` is awaited
- THEN it rejects with `GAME_SERVER.INVALID_RULE_CONTEXT`
**Verified by:** `it("evaluate rejects non-object context")`
#### Scenario: history is bounded by historyCap
- WHEN more than `historyCap` evaluations are recorded
- THEN the history retains only the most recent `historyCap` entries
**Verified by:** `it("history is bounded by historyCap")`
### Requirement: WorkflowPlugin SHALL orchestrate registered engines
`WorkflowPlugin` keys `WorkflowEngine` instances by `id` and exposes
`execute` (run-and-record), `cancel` (defer to engine), and a rolling
history with `getHistory`/`clearHistory`.
#### Scenario: duplicate registration rejected
- WHEN `registerWorkflow` is called twice with the same id
- THEN the second call throws `GAME_SERVER.DUPLICATE_WORKFLOW`
**Verified by:** `it("rejects duplicate registration")`
#### Scenario: execute records failed status on rejected promise
- GIVEN an engine whose `run` rejects
- WHEN `execute(id, data)` is awaited
- THEN the history entry status is `"failed"` and `error` carries the
message
**Verified by:** `it("execute records error on rejected promise")`
#### Scenario: cancel returns false when no execution is active
- WHEN `cancel(id)` is called with no in-flight execution
- THEN the call returns `false`
**Verified by:** `it("cancel returns true for active and false for absent")`