Initial Commit
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# 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")`
|
||||
Reference in New Issue
Block a user