first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -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.