@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 +26 -1
- package/package.json +6 -3
- package/src/report.ts +9 -0
- package/src/rng.ts +26 -8
- package/src/simulate.ts +49 -5
- package/src/stats.ts +23 -6
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.
|
|
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.
|
|
4
|
-
"description": "Per-mode RTP + hit-rate simulator and report generator for open-rgs games. Library
|
|
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.
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
28
|
+
let sum = 0;
|
|
29
|
+
let c = 0;
|
|
16
30
|
for (const x of xs) {
|
|
17
31
|
const d = x - mu;
|
|
18
|
-
|
|
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(
|
|
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
|