# ABTestPlugin _(no submodules)_ ## Purpose `ABTestPlugin` registers weighted experiments and assigns players to variants deterministically via an injected `Rng`. It records conversion events per (experiment, player) pair and exposes per-variant summary statistics so the game engine can drive data-informed balance decisions without taking a runtime dep on a separate experiments service. The plugin is intentionally self-contained: it owns the experiment catalog, the assignment map, and the conversion log in memory. Persistence and statistical tests (z-test, chi-squared) are out of scope — callers compose the plugin with their preferred storage and analysis tooling. ## Features - Weighted variant bucketing via `Rng.nextFloat()` (deterministic with a seeded RNG). - Idempotent assignment: re-calling `assignPlayer` returns the same variant. - Per-experiment conversion log with arbitrary `goalId` and numeric value. - Per-variant summary: assignments, conversions, total value, conversion rate. - Zod-validated experiment input (id, name, ≥1 variant with positive weight). - Lifecycle-safe: clears every experiment on `onUninstall`. ## Usage: Basic ```ts import { ABTestPlugin } from "@suaveplan/game-server/plugins/ab-test-plugin"; const rng = { nextFloat: () => Math.random(), nextUint32: () => 0 }; const plugin = new ABTestPlugin({ rng }); plugin.createExperiment({ id: "exp-1", name: "Drop rate test", variants: [ { id: "control", weight: 1 }, { id: "treatment", weight: 1 }, ], }); const a = plugin.assignPlayer("exp-1", "player-1"); ``` ## Usage: Advanced ```ts import { ABTestPlugin } from "@suaveplan/game-server/plugins/ab-test-plugin"; // Seeded RNG for deterministic test runs. let s = 1; const rng = { nextUint32: () => (s = (s * 9301 + 49297) % 233280), nextFloat: () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }, }; const plugin = new ABTestPlugin({ rng }); plugin.createExperiment({ id: "drop", name: "Drop rate test", variants: [ { id: "low", weight: 3 }, { id: "high", weight: 1 }, ], }); plugin.assignPlayer("drop", "p1"); plugin.recordConversion("drop", "p1", "first-kill", 1); const summary = plugin.getSummary("drop"); ``` ## API Reference - `class ABTestPlugin extends BasePlugin` - `constructor(options: ABTestPluginOptions)` - `createExperiment(input: ExperimentInput): void` - `hasExperiment(id: string): boolean` - `assignPlayer(experimentId: string, playerId: string): Assignment` - `getAssignment(experimentId: string, playerId: string): Assignment | undefined` - `recordConversion(experimentId: string, playerId: string, goalId: string, value?: number): Conversion` - `getSummary(experimentId: string): readonly VariantSummary[]` - `listExperiments(): readonly string[]` ## Implementation Notes - Bucketing accumulates variant weights and compares against `nextFloat() * totalWeight`. The implementation falls back to the last variant if the accumulator never exceeds the threshold (a defensive guard against floating-point ulp drift when weights are tiny). - Recording a conversion for an unassigned player throws `GAME_SERVER.AB_TEST_INVALID_VARIANT` so callers can route the error to their analytics dead-letter queue rather than corrupting the report. - The summary recomputes from raw records on each call. The data sets are small (tens of variants, thousands of players); pre-aggregation would add latency on a rare path. Callers needing high-frequency dashboards should snapshot the summary at a checkpoint instead of polling. - The plugin uses `BasePlugin.assertLive` on every public method so a destroyed plugin cannot accidentally serve stale data.