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().installedAtis set toclock()
Verified by: it("invokes onInstallHook and transitions to 'installed'")
Scenario: rejects any hook after destroy
- WHEN a plugin has reached
"destroyed"(afteronUninstall) - AND
onInstall/onEnable/onDisable/onUninstallis 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
BasePluginis constructed without an explicitlogger - AND
onInstall(ctx)runs - THEN subsequent
this.loggercalls forward toctx.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 sameplayerId - 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()returns0.0, 0.4, 0.6, 0.9in sequence - THEN the first two assignments land in variant
aand the next two inb
Verified by: it("assigns according to weighting")
Scenario: missing experiment surfaces stable error code
- WHEN
assignPlayerorgetSummaryis called for an unknown id - THEN
GameServerErrorwith codeGAME_SERVER.AB_TEST_EXPERIMENT_NOT_FOUNDis 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:startedandplayer:joinedare emitted - THEN the sink receives
session:startedandplayer:joinedanalytics events withtimestamp = 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().sessionsAbandonedis 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.isEnabledis invoked with a context whoseuserId === "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, andgame:startedare emitted - THEN the buffer carries one
info, onewarn, onedebug, and oneinfoentry respectively
Verified by: it("forwards game events including move levels")
Scenario: ring buffer trims to maxEntries
- WHEN more than
maxEntriesevents 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
saveis awaited thenloadis 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
loadthrowsGAME_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
setcall 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
historyCapevaluations are recorded - THEN the history retains only the most recent
historyCapentries
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
registerWorkflowis 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
runrejects - WHEN
execute(id, data)is awaited - THEN the history entry status is
"failed"anderrorcarries 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")