# 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")`