@open-rgs/simulator 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,14 @@
1
1
  # @open-rgs/simulator
2
2
 
3
3
  Per-mode RTP + hit-rate simulator and report generator for open-rgs
4
- games. Library, no CLI in v1.
4
+ games. Usable as a library or via the `open-rgs-sim` CLI.
5
+
6
+ ## Runtime
7
+
8
+ **Bun is required** (`engines.bun >= 1.0.0`). This package publishes raw
9
+ TypeScript (no `dist/`) and its `bin` is a `.ts` file with a
10
+ `#!/usr/bin/env bun` shebang, so run the CLI with **`bunx`** — not
11
+ `npm install -g` on a Node-only machine. See ADR-001 for why.
5
12
 
6
13
  ## Install
7
14
 
@@ -9,6 +16,19 @@ games. Library, no CLI in v1.
9
16
  bun add -d @open-rgs/simulator
10
17
  ```
11
18
 
19
+ ## CLI
20
+
21
+ ```bash
22
+ bunx open-rgs-sim <manifest-module> [--spins N] [--seed N] [--bet N] \
23
+ [--out DIR] [--format md|html|json|all] [--skip-internal] [--quiet]
24
+ ```
25
+
26
+ `<manifest-module>` is a path to a module that exports a `GameManifest`
27
+ (from `defineGame`) — as `default`, `manifest`, or `buildManifest`; a
28
+ function export is called with `{ seed }` so it can seed the math RNG.
29
+ Reports are written to `--out` (default `./reports`) in the chosen
30
+ `--format` (default `all`).
31
+
12
32
  ## Use
13
33
 
14
34
  Write a `simulate.ts` next to your game's `index.ts`:
@@ -84,6 +104,11 @@ import { mulberry32 } from "@open-rgs/simulator/rng";
84
104
  const math = await loadLuaMath("./maths/spin.lua", { rng: mulberry32(42) });
85
105
  ```
86
106
 
107
+ > ⚠️ **Simulation/dev only.** `mulberry32` is a 32-bit, fully-predictable
108
+ > PRNG — never route it into a production `loadLuaMath({ rng })`. It is
109
+ > tagged so `loadLuaMath` throws if it sees it under `NODE_ENV=production`.
110
+ > Production outcome determination requires a certified CSPRNG (Spec 03).
111
+
87
112
  The same `mulberry32` is exported from both `@open-rgs/simulator` and
88
113
  its `/rng` subpath, so you can import it into the simulator script *or*
89
114
  into a separate math-loading harness without dragging the whole sim in.
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@open-rgs/simulator",
3
- "version": "0.2.1",
4
- "description": "Per-mode RTP + hit-rate simulator and report generator for open-rgs games. Library; no CLI in v1.",
3
+ "version": "1.0.0",
4
+ "description": "Per-mode RTP + hit-rate simulator and report generator for open-rgs games. Library + `open-rgs-sim` CLI (run via bunx).",
5
5
  "license": "MIT",
6
+ "engines": {
7
+ "bun": ">=1.0.0"
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "https://github.com/open-rgs/open-rgs.git",
@@ -39,7 +42,7 @@
39
42
  "test": "bun test"
40
43
  },
41
44
  "dependencies": {
42
- "@open-rgs/contract": "0.3.1",
45
+ "@open-rgs/contract": "1.0.0",
43
46
  "handlebars": "^4.7.9"
44
47
  }
45
48
  }
package/src/report.ts CHANGED
@@ -49,6 +49,13 @@ export interface SimulationReport {
49
49
  measured: number; // total_win / total_bet
50
50
  declared: number; // from manifest
51
51
  delta: number; // measured - declared
52
+ /** Standard error of the measured RTP (stdDev of per-spin return / √n). */
53
+ standardError: number;
54
+ /** 95% confidence interval [low, high] for the measured RTP. */
55
+ ci95: [number, number];
56
+ /** Certification verdict: declared within CI95 → pass; within CI99 → warn;
57
+ * outside → fail (measured RTP significantly differs from declared). */
58
+ verdict: "pass" | "warn" | "fail";
52
59
  };
53
60
  /** Fraction of spins with multiplier > 0. */
54
61
  hitRate: number;
@@ -110,7 +117,9 @@ export function mdReport(r: SimulationReport): string {
110
117
  lines.push("");
111
118
  lines.push(`> ${r.narrative}`);
112
119
  lines.push("");
120
+ const verdictIcon = r.rtp.verdict === "pass" ? "✓" : r.rtp.verdict === "warn" ? "⚠" : "✗";
113
121
  lines.push(`- **Measured RTP:** ${pct(r.rtp.measured)} (declared ${pct(r.rtp.declared)}, Δ ${sign(r.rtp.delta)})`);
122
+ lines.push(`- **RTP certification:** ${verdictIcon} ${r.rtp.verdict.toUpperCase()} — 95% CI [${pct(r.rtp.ci95[0])}, ${pct(r.rtp.ci95[1])}], SE ${pct(r.rtp.standardError)}`);
114
123
  lines.push(`- **Hit rate:** ${pct(r.hitRate)}`);
115
124
  lines.push(`- **Spins:** ${r.spins.toLocaleString()} · **Bet:** ${r.bet.unitsPerSpin}u/spin · **Time:** ${r.elapsedMs}ms`);
116
125
  lines.push(`- **Stake multiplier:** ${r.mode.stakeMultiplier}× · **Internal:** ${r.mode.internal ? "yes" : "no"}`);
package/src/rng.ts CHANGED
@@ -1,17 +1,35 @@
1
- // Tiny deterministic PRNGs. Re-exported as @open-rgs/simulator/rng so
2
- // integrators can use the same one to seed math at load time + the
3
- // simulator's own choices, getting reproducible reports end-to-end.
1
+ // Tiny deterministic PRNG for the SIMULATOR ONLY — reproducible RTP runs
2
+ // and the simulator's own strategy/tie-break choices.
3
+ //
4
+ // ⚠️ NOT for production. mulberry32 has 32-bit state (period 2^32 — a few
5
+ // hours of spins at this project's throughput targets, after which the
6
+ // stream repeats), is fully determined by its seed, and is trivially
7
+ // predictable from a handful of outputs. Routing it into a production
8
+ // `loadLuaMath({ rng })` would make real-money outcomes predictable.
9
+ // Production REQUIRES a certified CSPRNG (see Spec 03 / audit C5). To make
10
+ // that hard to get wrong, the returned function is tagged
11
+ // `__insecureSimulatorRng` and `loadLuaMath` refuses it under
12
+ // NODE_ENV=production.
4
13
 
5
- /** mulberry32 32-bit state, period 2^32, decent statistical quality
6
- * for non-cryptographic use. ~3 lines of code; you can paste it into
7
- * a CI script if you want to verify a recorded report later. */
8
- export function mulberry32(seed: number): () => number {
14
+ /** A seeded PRNG function, tagged as simulator-only so the math loader can
15
+ * reject it in production. */
16
+ export interface SeededRng {
17
+ (): number;
18
+ /** Marks this as a non-cryptographic simulator PRNG. loadLuaMath throws
19
+ * if it sees this in production (unless allowInsecureRng). */
20
+ readonly __insecureSimulatorRng?: true;
21
+ }
22
+
23
+ /** mulberry32 — 32-bit state, period 2^32. Reproducible and fast; fine for
24
+ * simulation, catastrophic for real-money outcome determination. */
25
+ export function mulberry32(seed: number): SeededRng {
9
26
  let s = seed >>> 0;
10
- return () => {
27
+ const next = (): number => {
11
28
  s = (s + 0x6d2b79f5) >>> 0;
12
29
  let t = s;
13
30
  t = Math.imul(t ^ (t >>> 15), t | 1);
14
31
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
15
32
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
16
33
  };
34
+ return Object.assign(next, { __insecureSimulatorRng: true as const });
17
35
  }
package/src/simulate.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import type {
14
14
  GameManifest, GameMode,
15
15
  SimpleMath, ComplexMath,
16
- AwaitingHint, PlayerAction, SpinContext,
16
+ AwaitingHint, PlayerAction, SpinContext, CarryState,
17
17
  MarkSnapshot, MarkCollector,
18
18
  PlatformAdapter,
19
19
  } from "@open-rgs/contract";
@@ -22,6 +22,17 @@ import { mean, stdDev, percentileSorted } from "./stats.js";
22
22
  import { computeDeviations, narrate, type TargetDeviation } from "./deviation.js";
23
23
  import type { SimulationReport, DistributionStats } from "./report.js";
24
24
 
25
+ /** Round to nearest integer, ties to even (banker's rounding) — the money
26
+ * boundary rule from ADR-002. Mirrors @open-rgs/core's `roundHalfEven`;
27
+ * duplicated here because the simulator deliberately has no core dep. */
28
+ function roundHalfEven(x: number): number {
29
+ const floor = Math.floor(x);
30
+ const frac = x - floor;
31
+ if (frac < 0.5) return floor;
32
+ if (frac > 0.5) return floor + 1;
33
+ return floor % 2 === 0 ? floor : floor + 1;
34
+ }
35
+
25
36
  export interface SimulateOptions {
26
37
  /** Spins to run per mode. Default 100_000. */
27
38
  spinsPerMode?: number;
@@ -92,6 +103,10 @@ async function simulateMode(
92
103
  const nextModeRoutes: Record<string, number> = {};
93
104
  let totalWin = 0;
94
105
  let totalSteps = 0;
106
+ // Cross-round carry threaded spin-to-spin, exactly as the orchestrator does
107
+ // it. Passing `undefined` every spin (the old behaviour) made any stateful
108
+ // game's measured RTP wrong. (H7)
109
+ let carry: CarryState | undefined;
95
110
 
96
111
  // Optional adapter integration — when set, each spin is settled via
97
112
  // the real adapter so wire-protocol bugs (validator mismatches, auth
@@ -120,14 +135,15 @@ async function simulateMode(
120
135
  if (mode.math.kind === "simple") {
121
136
  const m = mode.math as SimpleMath;
122
137
  const ctx: SpinContext = { mode: modeId };
123
- const outcome = await Promise.resolve(m.play(undefined, ctx));
138
+ const outcome = await Promise.resolve(m.play(carry, ctx));
124
139
  multiplier = outcome.multiplier;
125
140
  type = outcome.type;
126
141
  nextMode = outcome.nextMode;
142
+ carry = outcome.carry;
127
143
  } else {
128
144
  const m = mode.math as ComplexMath;
129
145
  const ctx: SpinContext = { mode: modeId };
130
- const open = await Promise.resolve(m.open(undefined, ctx));
146
+ const open = await Promise.resolve(m.open(carry, ctx));
131
147
  let state = open.state;
132
148
  let awaiting: AwaitingHint | undefined = open.awaiting;
133
149
  let steps = 0;
@@ -144,6 +160,7 @@ async function simulateMode(
144
160
  multiplier = close.multiplier;
145
161
  type = close.type;
146
162
  nextMode = close.nextMode;
163
+ carry = close.carry;
147
164
  }
148
165
 
149
166
  multipliers[i] = multiplier;
@@ -154,19 +171,25 @@ async function simulateMode(
154
171
  if (adapter && adapterSession) {
155
172
  const tStart = performance.now();
156
173
  adapterRpcsSent += 1;
174
+ // The adapter is a real wallet expecting integer minor units, so the
175
+ // settled win must be rounded exactly as core's orchestrator does
176
+ // (round half to even, ADR-002) — not the raw float `multiplier ×
177
+ // bet`. (The theoretical `totalWin` above stays exact on purpose: it
178
+ // measures RTP, not what a wallet would actually credit.)
179
+ const winMinor = roundHalfEven(multiplier * betPerSpin);
157
180
  try {
158
181
  await adapter.settleSimple({
159
182
  sessionId: adapterSession,
160
183
  bet: betPerSpin,
161
184
  betIndex: adapterBetIdx,
162
185
  priceMultiplier: betUnits,
163
- win: multiplier * betPerSpin,
186
+ win: winMinor,
164
187
  multiplier,
165
188
  type,
166
189
  // Synthesize a per-spin audit envelope; core's orchestrator
167
190
  // does the same when math carry is absent.
168
191
  roundState: JSON.stringify({
169
- type, multiplier, win: multiplier * betPerSpin, bet: betPerSpin, bet_index: adapterBetIdx,
192
+ type, multiplier, win: winMinor, bet: betPerSpin, bet_index: adapterBetIdx,
170
193
  }),
171
194
  ...(mode.math.version ? { mathVersion: mode.math.version } : {}),
172
195
  });
@@ -195,6 +218,24 @@ async function simulateMode(
195
218
  const measuredRtp = totalBet === 0 ? 0 : totalWin / totalBet;
196
219
  const declaredRtp = mode.declaredRtp ?? mode.math.rtp;
197
220
 
221
+ // RTP certification verdict. The measured RTP is the mean per-spin return;
222
+ // its standard error is stdDev(per-spin multiplier)/√n. We can then say
223
+ // whether the declared RTP is statistically consistent with what we
224
+ // measured: within the 95% CI → pass; within 99% → warn; outside → fail.
225
+ const standardError = spins > 0 ? muStd / Math.sqrt(spins) : 0;
226
+ const ci95: [number, number] = [measuredRtp - 1.96 * standardError, measuredRtp + 1.96 * standardError];
227
+ const rtpDelta = Math.abs(declaredRtp - measuredRtp);
228
+ let rtpVerdict: "pass" | "warn" | "fail";
229
+ if (standardError === 0) {
230
+ rtpVerdict = rtpDelta < 1e-9 ? "pass" : "fail";
231
+ } else if (rtpDelta <= 1.96 * standardError) {
232
+ rtpVerdict = "pass";
233
+ } else if (rtpDelta <= 2.576 * standardError) {
234
+ rtpVerdict = "warn";
235
+ } else {
236
+ rtpVerdict = "fail";
237
+ }
238
+
198
239
  let hits = 0;
199
240
  for (const m of multipliers) if (m > 0) hits += 1;
200
241
  const hitRate = spins === 0 ? 0 : hits / spins;
@@ -303,6 +344,9 @@ async function simulateMode(
303
344
  measured: measuredRtp,
304
345
  declared: declaredRtp,
305
346
  delta: measuredRtp - declaredRtp,
347
+ standardError,
348
+ ci95,
349
+ verdict: rtpVerdict,
306
350
  },
307
351
  hitRate,
308
352
  multiplier: multiplierStats,
package/src/stats.ts CHANGED
@@ -1,10 +1,23 @@
1
1
  // Streaming-friendly stat helpers for big simulator runs.
2
2
 
3
+ /** Kahan (compensated) summation — bounds the rounding error that naive
4
+ * left-to-right addition accumulates over the 10^8+ samples a real RTP
5
+ * certification run produces. (L2) */
6
+ function kahanSum(xs: readonly number[]): number {
7
+ let sum = 0;
8
+ let c = 0; // running compensation for lost low-order bits
9
+ for (const x of xs) {
10
+ const y = x - c;
11
+ const t = sum + y;
12
+ c = (t - sum) - y;
13
+ sum = t;
14
+ }
15
+ return sum;
16
+ }
17
+
3
18
  export function mean(xs: readonly number[]): number {
4
19
  if (xs.length === 0) return 0;
5
- let s = 0;
6
- for (const x of xs) s += x;
7
- return s / xs.length;
20
+ return kahanSum(xs) / xs.length;
8
21
  }
9
22
 
10
23
  /** Population stddev (divides by N, not N-1). Simulator samples are full
@@ -12,12 +25,16 @@ export function mean(xs: readonly number[]): number {
12
25
  export function stdDev(xs: readonly number[], m?: number): number {
13
26
  if (xs.length === 0) return 0;
14
27
  const mu = m ?? mean(xs);
15
- let s = 0;
28
+ let sum = 0;
29
+ let c = 0;
16
30
  for (const x of xs) {
17
31
  const d = x - mu;
18
- s += d * d;
32
+ const y = d * d - c;
33
+ const t = sum + y;
34
+ c = (t - sum) - y;
35
+ sum = t;
19
36
  }
20
- return Math.sqrt(s / xs.length);
37
+ return Math.sqrt(sum / xs.length);
21
38
  }
22
39
 
23
40
  /** Percentile by nearest-rank on a *sorted* array. Pass an already-sorted