Initial Commit
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# ABTestPlugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user