3.7 KiB
3.7 KiB
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
assignPlayerreturns the same variant. - Per-experiment conversion log with arbitrary
goalIdand 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
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
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 BasePluginconstructor(options: ABTestPluginOptions)createExperiment(input: ExperimentInput): voidhasExperiment(id: string): booleanassignPlayer(experimentId: string, playerId: string): AssignmentgetAssignment(experimentId: string, playerId: string): Assignment | undefinedrecordConversion(experimentId: string, playerId: string, goalId: string, value?: number): ConversiongetSummary(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_VARIANTso 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.assertLiveon every public method so a destroyed plugin cannot accidentally serve stale data.