Initial Commit
This commit is contained in:
93
openspec/archive/2026-05-24-game-server-plugins/proposal.md
Normal file
93
openspec/archive/2026-05-24-game-server-plugins/proposal.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Proposal — game-server plugins (Wave C-1)
|
||||
|
||||
## Why
|
||||
|
||||
The `PluginManager` shipped in Wave B exposes the `EnginePlugin` lifecycle
|
||||
contract but ships no concrete plugins. Wave C-1 fills that gap with seven
|
||||
production-grade plugins covering A/B testing, analytics, feature flags,
|
||||
logging, persistence, rules, and workflows. All seven plugins extend a new
|
||||
shared `BasePlugin` abstract base that implements the `EnginePlugin`
|
||||
lifecycle template-method style.
|
||||
|
||||
The plugins are universal-tier (zero browser / React deps). External
|
||||
integrations are routed through structural-type contracts from
|
||||
`@suaveplan/types` (`LoggerLike`, `StorageLike`, `Clock`, `IdGenerator`,
|
||||
`Rng`) so the package keeps its lean dep footprint.
|
||||
|
||||
## What changes
|
||||
|
||||
### ADDED `BasePlugin`
|
||||
|
||||
Abstract base for game-server plugins. Implements the `EnginePlugin`
|
||||
contract (`onInstall` / `onUninstall` / `onEnable` / `onDisable`) and
|
||||
forwards to `protected` template-method hooks. Tracks lifecycle state
|
||||
(`created → installed → enabled → disabled → destroyed`) and rejects any
|
||||
hook after destruction.
|
||||
|
||||
### ADDED concrete plugins
|
||||
|
||||
- `ABTestPlugin` — weighted, deterministic variant assignment via injectable
|
||||
`Rng`; conversion log; per-variant summary stats.
|
||||
- `AnalyticsPlugin` — subscribes to `GameEventEmitter`, accumulates
|
||||
per-session counters, dispatches structured `AnalyticsEvent` to an
|
||||
injectable sink.
|
||||
- `FeatureFlagPlugin` — thin adapter over `@suaveplan/feature-flags`
|
||||
`FlagManager`; per-player evaluation context; rolling evaluation log.
|
||||
- `LoggingPlugin` — subscribes to `GameEventEmitter`, forwards events to a
|
||||
`LoggerLike` sink, retains a bounded ring buffer for inspection.
|
||||
- `PersistencePlugin` — tracks per-session state from the emitter and
|
||||
persists via injectable `StorageLike`; explicit `save`/`load`/`delete`.
|
||||
- `RulesPlugin` — wraps `@suaveplan/rules` `RuleEngine`; three rule slots
|
||||
(move / start / end); rolling evaluation history.
|
||||
- `WorkflowPlugin` — id-keyed `WorkflowEngine` registry; `execute` records
|
||||
status + duration + error; `cancel` defers to engine.
|
||||
|
||||
### ADDED error codes (extends `GAME_SERVER.*` registry)
|
||||
|
||||
- `PLUGIN_BASE_DESTROYED`
|
||||
- `INVALID_PLUGIN_OPTIONS`
|
||||
- `AB_TEST_INVALID_VARIANT`
|
||||
- `AB_TEST_EXPERIMENT_NOT_FOUND`
|
||||
- `PERSISTENCE_SAVE_FAILED`
|
||||
- `PERSISTENCE_LOAD_FAILED`
|
||||
- `WORKFLOW_NOT_FOUND`
|
||||
- `DUPLICATE_WORKFLOW`
|
||||
- `INVALID_RULE_CONTEXT`
|
||||
|
||||
### ADDED dependencies
|
||||
|
||||
- `@suaveplan/feature-flags` (`workspace:^`)
|
||||
- `@suaveplan/rules` (`workspace:^`)
|
||||
- `@suaveplan/workflow` (`workspace:^`)
|
||||
|
||||
### Subpath exports
|
||||
|
||||
Each plugin module gets its own subpath:
|
||||
- `./plugins/base-plugin`
|
||||
- `./plugins/ab-test-plugin`
|
||||
- `./plugins/analytics-plugin`
|
||||
- `./plugins/feature-flag-plugin`
|
||||
- `./plugins/logging-plugin`
|
||||
- `./plugins/persistence-plugin`
|
||||
- `./plugins/rules-plugin`
|
||||
- `./plugins/workflow-plugin`
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Requirement | Upstream | Notes |
|
||||
| --- | --- | --- |
|
||||
| BasePlugin lifecycle | `BasePlugin.ts` (1-50) | Adapted to genesis `EnginePlugin` contract; tracks state machine |
|
||||
| AnalyticsPlugin per-session counters | `AnalyticsPlugin.ts` (35-280) | Sink-based dispatch instead of OTel coupling |
|
||||
| ABTestPlugin variant bucketing | `ABTestPlugin.ts` (28-190) | Self-contained — no `@suaveplan/ab-testing` dep |
|
||||
| FeatureFlagPlugin evaluation | `FeatureFlagPlugin.ts` (30-160) | Wraps `@suaveplan/feature-flags` `FlagManager` |
|
||||
| LoggingPlugin event forwarding | `LoggingPlugin.ts` (35-430) | Bounded ring buffer; granular toggles |
|
||||
| PersistencePlugin save/load | `PersistencePlugin.ts` (30-380) | Zod-validated load; `StorageLike` injection |
|
||||
| RulesPlugin evaluation | `RulesPlugin.ts` (28-195) | Wraps `@suaveplan/rules` `RuleEngine` |
|
||||
| WorkflowPlugin orchestration | `WorkflowPlugin.ts` (35-210) | Id-keyed registry; history; cancel |
|
||||
|
||||
## Scope guard
|
||||
|
||||
This change does NOT touch `src/core/`, `src/engine/`, `src/state/`,
|
||||
`src/network/`, `src/matchmaking/`, `src/performance/`, `src/types/`, or
|
||||
`src/observability/`. `src/index.ts` is also untouched — orchestrator
|
||||
consolidation handles the unified barrel.
|
||||
@@ -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")`
|
||||
63
openspec/archive/2026-05-24-game-server-plugins/tasks.md
Normal file
63
openspec/archive/2026-05-24-game-server-plugins/tasks.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Tasks — game-server plugins (Wave C-1)
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree; only this worktree open
|
||||
- [x] 0.2 Account for sibling worktrees (none beyond this one)
|
||||
- [x] 0.3 Confirm `MANIFEST.md` package set matches `find packages -maxdepth 3 -name package.json`
|
||||
- [x] 0.4 §0a Sync — fast-forwarded worktree to current `genesis` HEAD (orchestrator-supplied SHA predates current merges; verified ancestry via `git log`)
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read upstream `BasePlugin.ts` + 7 concrete plugins under `/mnt/s/web/suaveplan/packages/universal/game-server/src/plugins/`
|
||||
- [x] 1.2 Read existing `openspec/specs/game-server/spec.md`
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` rules 7, 10, 11
|
||||
- [x] 1.4 Draft `proposal.md`
|
||||
- [x] 1.5 Source mapping table with upstream line ranges
|
||||
- [x] 1.6 Draft `specs/game-server/spec.md` with `## ADDED Requirements`
|
||||
- [x] 1.7 Every requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `BasePlugin` abstract class + lifecycle state machine
|
||||
- [x] 2.2 `ABTestPlugin` — weighted bucketing with seeded `Rng`
|
||||
- [x] 2.3 `AnalyticsPlugin` — emitter subscription + structured sink
|
||||
- [x] 2.4 `FeatureFlagPlugin` — `FlagManager` adapter + evaluation log
|
||||
- [x] 2.5 `LoggingPlugin` — emitter forwarding + ring buffer
|
||||
- [x] 2.6 `PersistencePlugin` — `StorageLike` save/load + Zod-validated decode
|
||||
- [x] 2.7 `RulesPlugin` — `RuleEngine` adapter + history
|
||||
- [x] 2.8 `WorkflowPlugin` — registry + execute/cancel + history
|
||||
- [x] 2.9 Error-code registry extended with nine plugin codes
|
||||
- [x] 2.10 `package.json` deps + subpath exports
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests per plugin (`*.test.ts`) covering every public method + lifecycle
|
||||
- [x] 3.2 100% statement/branch/function/line coverage on touched modules
|
||||
- [x] 3.3 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation file (≥200 words, basic + advanced, full API)
|
||||
|
||||
## 5. Pre-archive verification
|
||||
|
||||
- [ ] 5.1 `bun run check` — clean
|
||||
- [ ] 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 Build green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded — `WorkflowEngine<TData>` and `RuleContext<TData>` exposed type-safely
|
||||
- [x] 6.2 Zod schemas wired at construction-time boundaries (`AnalyticsPlugin`, `ABTestPlugin.createExperiment`, `LoggingPlugin`, `PersistencePlugin.load`, `RulesPlugin`)
|
||||
- [x] 6.3 Every new error code thrown by at least one production path
|
||||
- [x] 6.4 Lifecycle-state behaviours tested (assertLive guards after destroy)
|
||||
- [x] 6.5 OTel `recordPluginLifecycle` integration unchanged — covered by existing plugin-manager tests
|
||||
- [x] 6.6 Lifecycle dedup tested (BasePlugin re-install after destroy rejected)
|
||||
- [x] 6.7 No retroactive ticks
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold delta into `openspec/specs/game-server/spec.md` (orchestrator task)
|
||||
- [ ] 7.2 Archive folder rename (orchestrator task)
|
||||
Reference in New Issue
Block a user