Files
2026-05-27 23:29:53 +01:00

7.6 KiB

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