@open-rgs/simulator 0.0.0-snapshot-20260530050213
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 +139 -0
- package/package.json +48 -0
- package/src/cli.ts +144 -0
- package/src/deviation.ts +129 -0
- package/src/html.ts +136 -0
- package/src/index.ts +7 -0
- package/src/report.ts +271 -0
- package/src/rng.ts +35 -0
- package/src/simulate.ts +393 -0
- package/src/stats.ts +49 -0
- package/src/templates/default.hbs +435 -0
package/src/report.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// SimulationReport shape + markdown renderer. Keep this file readable
|
|
2
|
+
// — the markdown output is what most users will look at; the typed
|
|
3
|
+
// object is what an LLM eats.
|
|
4
|
+
|
|
5
|
+
import type { TargetDeviation } from "./deviation.js";
|
|
6
|
+
|
|
7
|
+
export interface DistributionStats {
|
|
8
|
+
min: number;
|
|
9
|
+
max: number;
|
|
10
|
+
mean: number;
|
|
11
|
+
stdDev: number;
|
|
12
|
+
p50: number;
|
|
13
|
+
p90: number;
|
|
14
|
+
p95: number;
|
|
15
|
+
p99: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SimulationReport {
|
|
19
|
+
game: {
|
|
20
|
+
id: string;
|
|
21
|
+
declaredRtp: number;
|
|
22
|
+
defaultMode: string;
|
|
23
|
+
};
|
|
24
|
+
mode: {
|
|
25
|
+
id: string;
|
|
26
|
+
label?: string;
|
|
27
|
+
stakeMultiplier: number;
|
|
28
|
+
internal: boolean;
|
|
29
|
+
};
|
|
30
|
+
math: {
|
|
31
|
+
name: string;
|
|
32
|
+
version: string;
|
|
33
|
+
declaredRtp: number;
|
|
34
|
+
kind: "simple" | "complex";
|
|
35
|
+
};
|
|
36
|
+
/** Number of spins simulated for this mode. */
|
|
37
|
+
spins: number;
|
|
38
|
+
bet: {
|
|
39
|
+
/** Units used per spin (post-stake-multiplier). */
|
|
40
|
+
unitsPerSpin: number;
|
|
41
|
+
totalUnits: number;
|
|
42
|
+
};
|
|
43
|
+
win: {
|
|
44
|
+
totalUnits: number;
|
|
45
|
+
/** Largest single-spin multiplier observed. */
|
|
46
|
+
maxMultiplier: number;
|
|
47
|
+
};
|
|
48
|
+
rtp: {
|
|
49
|
+
measured: number; // total_win / total_bet
|
|
50
|
+
declared: number; // from manifest
|
|
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";
|
|
59
|
+
};
|
|
60
|
+
/** Fraction of spins with multiplier > 0. */
|
|
61
|
+
hitRate: number;
|
|
62
|
+
/** Distribution stats on the per-spin multiplier. */
|
|
63
|
+
multiplier: DistributionStats;
|
|
64
|
+
/** Count of spins by outcome `type` returned from math. */
|
|
65
|
+
outcomeTypes: Record<string, number>;
|
|
66
|
+
/** Count of spins that emitted a `nextMode` route to each target. */
|
|
67
|
+
nextModeRoutes: Record<string, number>;
|
|
68
|
+
|
|
69
|
+
/** ── Author-annotated marks (only present if math used host.mark.*) ── */
|
|
70
|
+
|
|
71
|
+
/** Per-name total of host.mark.count() calls (across all spins).
|
|
72
|
+
* Convenient view: counters[name] / spins = fire rate. */
|
|
73
|
+
counters: Record<string, { total: number; perSpin: number }>;
|
|
74
|
+
/** Per-name distribution stats over host.mark.observe(name, value) samples. */
|
|
75
|
+
observations: Record<string, DistributionStats & { count: number }>;
|
|
76
|
+
/** Per-name share of spins on which host.mark.tag(name) was called. */
|
|
77
|
+
tagShares: Record<string, { spins: number; share: number }>;
|
|
78
|
+
/** Per-name share of total RTP that came from host.mark.contribute(name, m) buckets. */
|
|
79
|
+
rtpContributions: Record<string, { sumMultiplier: number; rtpShare: number }>;
|
|
80
|
+
/** Per-key deviation entries (empty if math has no `expected` block). */
|
|
81
|
+
deviations: TargetDeviation[];
|
|
82
|
+
/** Single-line machine-readable diagnosis. */
|
|
83
|
+
narrative: string;
|
|
84
|
+
|
|
85
|
+
/** Complex-round-only stats. */
|
|
86
|
+
complex?: {
|
|
87
|
+
averageStepsPerRound: number;
|
|
88
|
+
};
|
|
89
|
+
/** Platform-adapter call stats. Only present when the simulator
|
|
90
|
+
* was run with a real (or test) PlatformAdapter in options. Counts
|
|
91
|
+
* each spin's settleSimple/closeComplex round trip. */
|
|
92
|
+
adapter?: {
|
|
93
|
+
rpcsSent: number;
|
|
94
|
+
rpcsOk: number;
|
|
95
|
+
rpcsFailed: number;
|
|
96
|
+
/** Wall-clock time spent inside adapter calls. */
|
|
97
|
+
rpcMsTotal: number;
|
|
98
|
+
/** Per-error-message tally. Useful for spotting one class of
|
|
99
|
+
* failure dominating the rest (e.g. RoundState validator). */
|
|
100
|
+
failuresByMessage: Record<string, number>;
|
|
101
|
+
};
|
|
102
|
+
/** Wall-clock time spent simulating this mode. */
|
|
103
|
+
elapsedMs: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Render one SimulationReport as a tidy markdown block. */
|
|
107
|
+
export function mdReport(r: SimulationReport): string {
|
|
108
|
+
const pct = (n: number) => (n * 100).toFixed(2) + "%";
|
|
109
|
+
const sign = (n: number) => (n >= 0 ? "+" : "") + (n * 100).toFixed(2) + "%";
|
|
110
|
+
const num = (n: number, d = 4) => n.toFixed(d);
|
|
111
|
+
|
|
112
|
+
const lines: string[] = [];
|
|
113
|
+
lines.push(`# Simulation — ${r.game.id} / ${r.mode.id}`);
|
|
114
|
+
lines.push("");
|
|
115
|
+
if (r.mode.label) lines.push(`*${r.mode.label}* · math ${r.math.name}@${r.math.version} (${r.math.kind})`);
|
|
116
|
+
else lines.push(`math ${r.math.name}@${r.math.version} (${r.math.kind})`);
|
|
117
|
+
lines.push("");
|
|
118
|
+
lines.push(`> ${r.narrative}`);
|
|
119
|
+
lines.push("");
|
|
120
|
+
const verdictIcon = r.rtp.verdict === "pass" ? "✓" : r.rtp.verdict === "warn" ? "⚠" : "✗";
|
|
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)}`);
|
|
123
|
+
lines.push(`- **Hit rate:** ${pct(r.hitRate)}`);
|
|
124
|
+
lines.push(`- **Spins:** ${r.spins.toLocaleString()} · **Bet:** ${r.bet.unitsPerSpin}u/spin · **Time:** ${r.elapsedMs}ms`);
|
|
125
|
+
lines.push(`- **Stake multiplier:** ${r.mode.stakeMultiplier}× · **Internal:** ${r.mode.internal ? "yes" : "no"}`);
|
|
126
|
+
lines.push("");
|
|
127
|
+
|
|
128
|
+
if (r.deviations.length > 0) {
|
|
129
|
+
lines.push("## Targets vs measured");
|
|
130
|
+
lines.push("");
|
|
131
|
+
lines.push("| metric | target | measured | Δ | tolerance | status |");
|
|
132
|
+
lines.push("|-----------------------------------|---------|----------|----------|-----------|--------|");
|
|
133
|
+
for (const d of r.deviations) {
|
|
134
|
+
lines.push(`| ${d.key.padEnd(33)} | ${num(d.target)} | ${num(d.measured)} | ${sign(d.delta / Math.max(Math.abs(d.target), 1e-9))} | ±${num(d.tolerance)} | ${d.status} |`);
|
|
135
|
+
}
|
|
136
|
+
lines.push("");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
lines.push("## Multiplier distribution");
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push("| stat | value |");
|
|
142
|
+
lines.push("|--------|---------|");
|
|
143
|
+
lines.push(`| min | ${num(r.multiplier.min)} |`);
|
|
144
|
+
lines.push(`| mean | ${num(r.multiplier.mean)} |`);
|
|
145
|
+
lines.push(`| stddev | ${num(r.multiplier.stdDev)} |`);
|
|
146
|
+
lines.push(`| p50 | ${num(r.multiplier.p50)} |`);
|
|
147
|
+
lines.push(`| p90 | ${num(r.multiplier.p90)} |`);
|
|
148
|
+
lines.push(`| p95 | ${num(r.multiplier.p95)} |`);
|
|
149
|
+
lines.push(`| p99 | ${num(r.multiplier.p99)} |`);
|
|
150
|
+
lines.push(`| max | ${num(r.multiplier.max)} |`);
|
|
151
|
+
lines.push("");
|
|
152
|
+
|
|
153
|
+
const typeEntries = Object.entries(r.outcomeTypes).sort((a, b) => b[1] - a[1]);
|
|
154
|
+
if (typeEntries.length > 0) {
|
|
155
|
+
lines.push("## Outcome types");
|
|
156
|
+
lines.push("");
|
|
157
|
+
lines.push("| type | count | share |");
|
|
158
|
+
lines.push("|-----------------|--------------|---------|");
|
|
159
|
+
for (const [t, n] of typeEntries) {
|
|
160
|
+
lines.push(`| ${t.padEnd(15)} | ${n.toLocaleString().padStart(12)} | ${pct(n / r.spins)} |`);
|
|
161
|
+
}
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const routeEntries = Object.entries(r.nextModeRoutes).sort((a, b) => b[1] - a[1]);
|
|
166
|
+
if (routeEntries.length > 0) {
|
|
167
|
+
lines.push("## Next-mode routes");
|
|
168
|
+
lines.push("");
|
|
169
|
+
lines.push("| target | count | share |");
|
|
170
|
+
lines.push("|-----------------|--------------|---------|");
|
|
171
|
+
for (const [t, n] of routeEntries) {
|
|
172
|
+
lines.push(`| ${t.padEnd(15)} | ${n.toLocaleString().padStart(12)} | ${pct(n / r.spins)} |`);
|
|
173
|
+
}
|
|
174
|
+
lines.push("");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const counterEntries = Object.entries(r.counters).sort((a, b) => b[1].total - a[1].total);
|
|
178
|
+
if (counterEntries.length > 0) {
|
|
179
|
+
lines.push("## Counters (host.mark.count)");
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push("| name | total | per spin |");
|
|
182
|
+
lines.push("|-------------------------------|--------------|------------|");
|
|
183
|
+
for (const [name, c] of counterEntries) {
|
|
184
|
+
lines.push(`| ${name.padEnd(29)} | ${c.total.toLocaleString().padStart(12)} | ${c.perSpin.toFixed(5).padStart(10)} |`);
|
|
185
|
+
}
|
|
186
|
+
lines.push("");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const obsEntries = Object.entries(r.observations);
|
|
190
|
+
if (obsEntries.length > 0) {
|
|
191
|
+
lines.push("## Observations (host.mark.observe)");
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push("| name | count | mean | stddev | p50 | p90 | p99 | max |");
|
|
194
|
+
lines.push("|-----------------------|---------|---------|---------|--------|--------|--------|---------|");
|
|
195
|
+
for (const [name, o] of obsEntries) {
|
|
196
|
+
lines.push(`| ${name.padEnd(21)} | ${o.count.toString().padStart(7)} | ${num(o.mean).padStart(7)} | ${num(o.stdDev).padStart(7)} | ${num(o.p50).padStart(6)} | ${num(o.p90).padStart(6)} | ${num(o.p99).padStart(6)} | ${num(o.max).padStart(7)} |`);
|
|
197
|
+
}
|
|
198
|
+
lines.push("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const tagEntries = Object.entries(r.tagShares).sort((a, b) => b[1].spins - a[1].spins);
|
|
202
|
+
if (tagEntries.length > 0) {
|
|
203
|
+
lines.push("## Tag shares (host.mark.tag)");
|
|
204
|
+
lines.push("");
|
|
205
|
+
lines.push("| tag | spins | share |");
|
|
206
|
+
lines.push("|-----------------------|--------------|---------|");
|
|
207
|
+
for (const [name, t] of tagEntries) {
|
|
208
|
+
lines.push(`| ${name.padEnd(21)} | ${t.spins.toLocaleString().padStart(12)} | ${pct(t.share)} |`);
|
|
209
|
+
}
|
|
210
|
+
lines.push("");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const ctbEntries = Object.entries(r.rtpContributions).sort((a, b) => b[1].rtpShare - a[1].rtpShare);
|
|
214
|
+
if (ctbEntries.length > 0) {
|
|
215
|
+
lines.push("## RTP contributions (host.mark.contribute)");
|
|
216
|
+
lines.push("");
|
|
217
|
+
lines.push("| bucket | sum multiplier | RTP share |");
|
|
218
|
+
lines.push("|-----------------------|----------------|-----------|");
|
|
219
|
+
for (const [name, c] of ctbEntries) {
|
|
220
|
+
lines.push(`| ${name.padEnd(21)} | ${num(c.sumMultiplier, 2).padStart(14)} | ${pct(c.rtpShare).padStart(9)} |`);
|
|
221
|
+
}
|
|
222
|
+
lines.push("");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (r.complex) {
|
|
226
|
+
lines.push("## Complex-round stats");
|
|
227
|
+
lines.push("");
|
|
228
|
+
lines.push(`- Average steps per round: **${r.complex.averageStepsPerRound.toFixed(2)}**`);
|
|
229
|
+
lines.push("");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Render all reports as one markdown document with a top-level summary. */
|
|
236
|
+
export function mdReportSet(reports: readonly SimulationReport[]): string {
|
|
237
|
+
if (reports.length === 0) return "_No modes simulated._";
|
|
238
|
+
|
|
239
|
+
const game = reports[0]!.game;
|
|
240
|
+
const pct = (n: number) => (n * 100).toFixed(2) + "%";
|
|
241
|
+
|
|
242
|
+
const lines: string[] = [];
|
|
243
|
+
lines.push(`# Simulation report — ${game.id}`);
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(`Declared game RTP: **${pct(game.declaredRtp)}**`);
|
|
246
|
+
lines.push("");
|
|
247
|
+
|
|
248
|
+
lines.push("## Summary");
|
|
249
|
+
lines.push("");
|
|
250
|
+
lines.push("| mode | spins | measured RTP | declared | Δ | hit rate | targets |");
|
|
251
|
+
lines.push("|-----------------|------------|--------------|----------|-------------|----------|---------------|");
|
|
252
|
+
for (const r of reports) {
|
|
253
|
+
const delta = (r.rtp.delta >= 0 ? "+" : "") + (r.rtp.delta * 100).toFixed(2) + "%";
|
|
254
|
+
const fails = r.deviations.filter(d => d.status === "fail").length;
|
|
255
|
+
const warns = r.deviations.filter(d => d.status === "warn").length;
|
|
256
|
+
const oks = r.deviations.filter(d => d.status === "ok").length;
|
|
257
|
+
const tgts = r.deviations.length === 0 ? "—" : `${oks} ok · ${warns} warn · ${fails} fail`;
|
|
258
|
+
lines.push(
|
|
259
|
+
`| ${r.mode.id.padEnd(15)} | ${r.spins.toLocaleString().padStart(10)} | ${pct(r.rtp.measured).padStart(12)} | ${pct(r.rtp.declared).padStart(8)} | ${delta.padStart(11)} | ${pct(r.hitRate).padStart(8)} | ${tgts.padEnd(13)} |`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
lines.push("");
|
|
263
|
+
|
|
264
|
+
for (const r of reports) {
|
|
265
|
+
lines.push("---");
|
|
266
|
+
lines.push("");
|
|
267
|
+
lines.push(mdReport(r));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
package/src/rng.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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.
|
|
13
|
+
|
|
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 {
|
|
26
|
+
let s = seed >>> 0;
|
|
27
|
+
const next = (): number => {
|
|
28
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
29
|
+
let t = s;
|
|
30
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
31
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
32
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
33
|
+
};
|
|
34
|
+
return Object.assign(next, { __insecureSimulatorRng: true as const });
|
|
35
|
+
}
|
package/src/simulate.ts
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Per-mode RTP simulator. Replays the math thousands of times, drives
|
|
2
|
+
// host.mark.* lifecycle, and produces a SimulationReport for each mode
|
|
3
|
+
// (with deviation entries when the math declares an `expected` block).
|
|
4
|
+
//
|
|
5
|
+
// Determinism note: the math's RNG is wired at loadLuaMath time, not
|
|
6
|
+
// here. To get reproducible reports across runs, seed the math:
|
|
7
|
+
//
|
|
8
|
+
// import { mulberry32 } from "@open-rgs/simulator/rng";
|
|
9
|
+
// const math = await loadLuaMath("./maths/spin.lua", { rng: mulberry32(42) });
|
|
10
|
+
//
|
|
11
|
+
// The simulator's `seed` option only seeds the complex-round step strategy.
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
GameManifest, GameMode,
|
|
15
|
+
SimpleMath, ComplexMath,
|
|
16
|
+
AwaitingHint, PlayerAction, SpinContext, CarryState,
|
|
17
|
+
MarkSnapshot, MarkCollector,
|
|
18
|
+
PlatformAdapter,
|
|
19
|
+
} from "@open-rgs/contract";
|
|
20
|
+
import { mulberry32 } from "./rng.js";
|
|
21
|
+
import { mean, stdDev, percentileSorted } from "./stats.js";
|
|
22
|
+
import { computeDeviations, narrate, type TargetDeviation } from "./deviation.js";
|
|
23
|
+
import type { SimulationReport, DistributionStats } from "./report.js";
|
|
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
|
+
|
|
36
|
+
export interface SimulateOptions {
|
|
37
|
+
/** Spins to run per mode. Default 100_000. */
|
|
38
|
+
spinsPerMode?: number;
|
|
39
|
+
/** Units bet per spin BEFORE the mode's stakeMultiplier. Default 1. */
|
|
40
|
+
betUnits?: number;
|
|
41
|
+
/** Include `internal: true` modes (those only reachable via nextMode).
|
|
42
|
+
* Defaults to true — you usually want the internal-mode RTP measured
|
|
43
|
+
* independently for math review. */
|
|
44
|
+
includeInternal?: boolean;
|
|
45
|
+
/** Complex-round step strategy. Default "first".
|
|
46
|
+
* - "first": always pick awaiting.options[0]
|
|
47
|
+
* - "random": pick from awaiting.options uniformly (seeded — see seed) */
|
|
48
|
+
complexStrategy?: "first" | "random";
|
|
49
|
+
/** Seed for the simulator's *own* PRNG (drives "random" strategy and
|
|
50
|
+
* any tie-breaking). Does NOT seed the math — see top-of-file note. */
|
|
51
|
+
seed?: number;
|
|
52
|
+
/** Safety cap on steps per complex round to avoid infinite loops in
|
|
53
|
+
* buggy maths. Default 1000. */
|
|
54
|
+
maxStepsPerRound?: number;
|
|
55
|
+
/** OPTIONAL platform adapter. When set, each generated spin is
|
|
56
|
+
* settled via `adapter.settleSimple(...)` (or close/openComplex
|
|
57
|
+
* for complex maths) so adapter-shape mismatches surface during
|
|
58
|
+
* the pre-deploy sim run instead of in production.
|
|
59
|
+
*
|
|
60
|
+
* Caller MUST connect the adapter and openSession beforehand,
|
|
61
|
+
* pass the sessionId, and disconnect after simulate() returns.
|
|
62
|
+
* Spin loop runs at math speed (≈ microseconds per spin), so
|
|
63
|
+
* using a real adapter implies real wallet movements at the
|
|
64
|
+
* upstream — only point this at a sandbox account. */
|
|
65
|
+
adapter?: PlatformAdapter;
|
|
66
|
+
/** Session id to thread through adapter calls. Required when
|
|
67
|
+
* `adapter` is set. */
|
|
68
|
+
adapterSessionId?: string;
|
|
69
|
+
/** Bet index used in adapter calls. Default 0. */
|
|
70
|
+
adapterBetIndex?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Simulate every mode in the manifest. Returns one report per mode in
|
|
74
|
+
* the manifest's declared order (filtered by includeInternal). */
|
|
75
|
+
export async function simulate(
|
|
76
|
+
manifest: GameManifest,
|
|
77
|
+
opts: SimulateOptions = {},
|
|
78
|
+
): Promise<SimulationReport[]> {
|
|
79
|
+
const reports: SimulationReport[] = [];
|
|
80
|
+
for (const [modeId, mode] of Object.entries(manifest.modes)) {
|
|
81
|
+
if (mode.internal && opts.includeInternal === false) continue;
|
|
82
|
+
reports.push(await simulateMode(manifest, modeId, mode, opts));
|
|
83
|
+
}
|
|
84
|
+
return reports;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function simulateMode(
|
|
88
|
+
manifest: GameManifest,
|
|
89
|
+
modeId: string,
|
|
90
|
+
mode: GameMode,
|
|
91
|
+
opts: SimulateOptions,
|
|
92
|
+
): Promise<SimulationReport> {
|
|
93
|
+
const spins = opts.spinsPerMode ?? 100_000;
|
|
94
|
+
const betUnits = opts.betUnits ?? 1;
|
|
95
|
+
const betPerSpin = betUnits * mode.stakeMultiplier;
|
|
96
|
+
const stratRng = mulberry32(opts.seed ?? 0);
|
|
97
|
+
const maxSteps = opts.maxStepsPerRound ?? 1000;
|
|
98
|
+
const complexStrategy = opts.complexStrategy ?? "first";
|
|
99
|
+
const marks: MarkCollector | undefined = mode.math.marks;
|
|
100
|
+
|
|
101
|
+
const multipliers: number[] = new Array<number>(spins);
|
|
102
|
+
const outcomeTypes: Record<string, number> = {};
|
|
103
|
+
const nextModeRoutes: Record<string, number> = {};
|
|
104
|
+
let totalWin = 0;
|
|
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;
|
|
110
|
+
|
|
111
|
+
// Optional adapter integration — when set, each spin is settled via
|
|
112
|
+
// the real adapter so wire-protocol bugs (validator mismatches, auth
|
|
113
|
+
// drift, envelope shape errors) surface during sim instead of prod.
|
|
114
|
+
const adapter = opts.adapter;
|
|
115
|
+
const adapterSession = opts.adapterSessionId;
|
|
116
|
+
const adapterBetIdx = opts.adapterBetIndex ?? 0;
|
|
117
|
+
if (adapter && !adapterSession) {
|
|
118
|
+
throw new Error("simulate({ adapter }) requires adapterSessionId");
|
|
119
|
+
}
|
|
120
|
+
let adapterRpcsSent = 0;
|
|
121
|
+
let adapterRpcsOk = 0;
|
|
122
|
+
let adapterRpcsFailed = 0;
|
|
123
|
+
let adapterMsTotal = 0;
|
|
124
|
+
const adapterFailures: Record<string, number> = {};
|
|
125
|
+
|
|
126
|
+
const start = performance.now();
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < spins; i++) {
|
|
129
|
+
marks?.beginSpin();
|
|
130
|
+
|
|
131
|
+
let multiplier: number;
|
|
132
|
+
let type: string;
|
|
133
|
+
let nextMode: string | undefined;
|
|
134
|
+
|
|
135
|
+
if (mode.math.kind === "simple") {
|
|
136
|
+
const m = mode.math as SimpleMath;
|
|
137
|
+
const ctx: SpinContext = { mode: modeId };
|
|
138
|
+
const outcome = await Promise.resolve(m.play(carry, ctx));
|
|
139
|
+
multiplier = outcome.multiplier;
|
|
140
|
+
type = outcome.type;
|
|
141
|
+
nextMode = outcome.nextMode;
|
|
142
|
+
carry = outcome.carry;
|
|
143
|
+
} else {
|
|
144
|
+
const m = mode.math as ComplexMath;
|
|
145
|
+
const ctx: SpinContext = { mode: modeId };
|
|
146
|
+
const open = await Promise.resolve(m.open(carry, ctx));
|
|
147
|
+
let state = open.state;
|
|
148
|
+
let awaiting: AwaitingHint | undefined = open.awaiting;
|
|
149
|
+
let steps = 0;
|
|
150
|
+
while (steps < maxSteps && !(await Promise.resolve(m.isTerminal(state)))) {
|
|
151
|
+
if (!awaiting) break;
|
|
152
|
+
const action = pickAction(awaiting, complexStrategy, stratRng);
|
|
153
|
+
const step = await Promise.resolve(m.step(state, action));
|
|
154
|
+
state = step.state;
|
|
155
|
+
awaiting = step.awaiting;
|
|
156
|
+
steps += 1;
|
|
157
|
+
}
|
|
158
|
+
totalSteps += steps;
|
|
159
|
+
const close = await Promise.resolve(m.close(state));
|
|
160
|
+
multiplier = close.multiplier;
|
|
161
|
+
type = close.type;
|
|
162
|
+
nextMode = close.nextMode;
|
|
163
|
+
carry = close.carry;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
multipliers[i] = multiplier;
|
|
167
|
+
totalWin += multiplier * betPerSpin;
|
|
168
|
+
outcomeTypes[type] = (outcomeTypes[type] ?? 0) + 1;
|
|
169
|
+
if (nextMode) nextModeRoutes[nextMode] = (nextModeRoutes[nextMode] ?? 0) + 1;
|
|
170
|
+
|
|
171
|
+
if (adapter && adapterSession) {
|
|
172
|
+
const tStart = performance.now();
|
|
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);
|
|
180
|
+
try {
|
|
181
|
+
await adapter.settleSimple({
|
|
182
|
+
sessionId: adapterSession,
|
|
183
|
+
bet: betPerSpin,
|
|
184
|
+
betIndex: adapterBetIdx,
|
|
185
|
+
priceMultiplier: betUnits,
|
|
186
|
+
win: winMinor,
|
|
187
|
+
multiplier,
|
|
188
|
+
type,
|
|
189
|
+
// Synthesize a per-spin audit envelope; core's orchestrator
|
|
190
|
+
// does the same when math carry is absent.
|
|
191
|
+
roundState: JSON.stringify({
|
|
192
|
+
type, multiplier, win: winMinor, bet: betPerSpin, bet_index: adapterBetIdx,
|
|
193
|
+
}),
|
|
194
|
+
...(mode.math.version ? { mathVersion: mode.math.version } : {}),
|
|
195
|
+
});
|
|
196
|
+
adapterRpcsOk += 1;
|
|
197
|
+
} catch (e) {
|
|
198
|
+
adapterRpcsFailed += 1;
|
|
199
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
200
|
+
adapterFailures[msg] = (adapterFailures[msg] ?? 0) + 1;
|
|
201
|
+
}
|
|
202
|
+
adapterMsTotal += performance.now() - tStart;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
marks?.endSpin();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const elapsedMs = Math.round(performance.now() - start);
|
|
209
|
+
|
|
210
|
+
// Stats over the multiplier distribution.
|
|
211
|
+
const sorted = [...multipliers].sort((a, b) => a - b);
|
|
212
|
+
const muMean = mean(multipliers);
|
|
213
|
+
const muStd = stdDev(multipliers, muMean);
|
|
214
|
+
const muMin = sorted[0] ?? 0;
|
|
215
|
+
const muMax = sorted[sorted.length - 1] ?? 0;
|
|
216
|
+
|
|
217
|
+
const totalBet = spins * betPerSpin;
|
|
218
|
+
const measuredRtp = totalBet === 0 ? 0 : totalWin / totalBet;
|
|
219
|
+
const declaredRtp = mode.declaredRtp ?? mode.math.rtp;
|
|
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
|
+
|
|
239
|
+
let hits = 0;
|
|
240
|
+
for (const m of multipliers) if (m > 0) hits += 1;
|
|
241
|
+
const hitRate = spins === 0 ? 0 : hits / spins;
|
|
242
|
+
|
|
243
|
+
// ── Marks: compute counter/observation/tag/contribution sections ──
|
|
244
|
+
const snap: MarkSnapshot = marks?.snapshot() ?? {
|
|
245
|
+
counts: {}, observations: {}, tagSpins: {}, contributions: {}, spinsCompleted: 0,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const counters: SimulationReport["counters"] = {};
|
|
249
|
+
for (const [name, total] of Object.entries(snap.counts)) {
|
|
250
|
+
counters[name] = { total, perSpin: spins === 0 ? 0 : total / spins };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const observations: SimulationReport["observations"] = {};
|
|
254
|
+
for (const [name, values] of Object.entries(snap.observations)) {
|
|
255
|
+
if (values.length === 0) continue;
|
|
256
|
+
const s = [...values].sort((a, b) => a - b);
|
|
257
|
+
const m = mean(values);
|
|
258
|
+
observations[name] = {
|
|
259
|
+
count: values.length,
|
|
260
|
+
min: s[0]!,
|
|
261
|
+
max: s[s.length - 1]!,
|
|
262
|
+
mean: m,
|
|
263
|
+
stdDev: stdDev(values, m),
|
|
264
|
+
p50: percentileSorted(s, 50),
|
|
265
|
+
p90: percentileSorted(s, 90),
|
|
266
|
+
p95: percentileSorted(s, 95),
|
|
267
|
+
p99: percentileSorted(s, 99),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const tagShares: SimulationReport["tagShares"] = {};
|
|
272
|
+
for (const [name, n] of Object.entries(snap.tagSpins)) {
|
|
273
|
+
tagShares[name] = { spins: n, share: spins === 0 ? 0 : n / spins };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const rtpContributions: SimulationReport["rtpContributions"] = {};
|
|
277
|
+
const rates: Record<string, number> = {};
|
|
278
|
+
for (const [name, total] of Object.entries(snap.counts)) {
|
|
279
|
+
rates[name] = spins === 0 ? 0 : total / spins;
|
|
280
|
+
}
|
|
281
|
+
for (const [name, sumMultiplier] of Object.entries(snap.contributions)) {
|
|
282
|
+
const rtpShare = totalBet === 0 ? 0 : (sumMultiplier * betPerSpin) / totalBet;
|
|
283
|
+
rtpContributions[name] = { sumMultiplier, rtpShare };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const tagShareRates: Record<string, number> = {};
|
|
287
|
+
for (const [name, n] of Object.entries(snap.tagSpins)) {
|
|
288
|
+
tagShareRates[name] = spins === 0 ? 0 : n / spins;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const deviations: TargetDeviation[] = computeDeviations(mode.math.expected, {
|
|
292
|
+
hitRate,
|
|
293
|
+
rates,
|
|
294
|
+
rtpContributions: Object.fromEntries(Object.entries(rtpContributions).map(([k, v]) => [k, v.rtpShare])),
|
|
295
|
+
tagShares: tagShareRates,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const topContributions = Object.entries(rtpContributions)
|
|
299
|
+
.map(([name, c]) => ({ name, rtpShare: c.rtpShare }))
|
|
300
|
+
.sort((a, b) => b.rtpShare - a.rtpShare);
|
|
301
|
+
|
|
302
|
+
const narrative = narrate(
|
|
303
|
+
manifest.id,
|
|
304
|
+
modeId,
|
|
305
|
+
{ measured: measuredRtp, declared: declaredRtp, delta: measuredRtp - declaredRtp },
|
|
306
|
+
hitRate,
|
|
307
|
+
deviations,
|
|
308
|
+
topContributions,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const multiplierStats: DistributionStats = {
|
|
312
|
+
min: muMin,
|
|
313
|
+
max: muMax,
|
|
314
|
+
mean: muMean,
|
|
315
|
+
stdDev: muStd,
|
|
316
|
+
p50: percentileSorted(sorted, 50),
|
|
317
|
+
p90: percentileSorted(sorted, 90),
|
|
318
|
+
p95: percentileSorted(sorted, 95),
|
|
319
|
+
p99: percentileSorted(sorted, 99),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const report: SimulationReport = {
|
|
323
|
+
game: {
|
|
324
|
+
id: manifest.id,
|
|
325
|
+
declaredRtp: manifest.declaredRtp,
|
|
326
|
+
defaultMode: manifest.defaultMode,
|
|
327
|
+
},
|
|
328
|
+
mode: {
|
|
329
|
+
id: modeId,
|
|
330
|
+
...(mode.label !== undefined ? { label: mode.label } : {}),
|
|
331
|
+
stakeMultiplier: mode.stakeMultiplier,
|
|
332
|
+
internal: mode.internal ?? false,
|
|
333
|
+
},
|
|
334
|
+
math: {
|
|
335
|
+
name: mode.math.name,
|
|
336
|
+
version: mode.math.version,
|
|
337
|
+
declaredRtp,
|
|
338
|
+
kind: mode.math.kind,
|
|
339
|
+
},
|
|
340
|
+
spins,
|
|
341
|
+
bet: { unitsPerSpin: betPerSpin, totalUnits: totalBet },
|
|
342
|
+
win: { totalUnits: totalWin, maxMultiplier: muMax },
|
|
343
|
+
rtp: {
|
|
344
|
+
measured: measuredRtp,
|
|
345
|
+
declared: declaredRtp,
|
|
346
|
+
delta: measuredRtp - declaredRtp,
|
|
347
|
+
standardError,
|
|
348
|
+
ci95,
|
|
349
|
+
verdict: rtpVerdict,
|
|
350
|
+
},
|
|
351
|
+
hitRate,
|
|
352
|
+
multiplier: multiplierStats,
|
|
353
|
+
outcomeTypes,
|
|
354
|
+
nextModeRoutes,
|
|
355
|
+
counters,
|
|
356
|
+
observations,
|
|
357
|
+
tagShares,
|
|
358
|
+
rtpContributions,
|
|
359
|
+
deviations,
|
|
360
|
+
narrative,
|
|
361
|
+
...(mode.math.kind === "complex"
|
|
362
|
+
? { complex: { averageStepsPerRound: spins === 0 ? 0 : totalSteps / spins } }
|
|
363
|
+
: {}),
|
|
364
|
+
...(adapter
|
|
365
|
+
? { adapter: {
|
|
366
|
+
rpcsSent: adapterRpcsSent,
|
|
367
|
+
rpcsOk: adapterRpcsOk,
|
|
368
|
+
rpcsFailed: adapterRpcsFailed,
|
|
369
|
+
rpcMsTotal: Math.round(adapterMsTotal),
|
|
370
|
+
failuresByMessage: adapterFailures,
|
|
371
|
+
} }
|
|
372
|
+
: {}),
|
|
373
|
+
elapsedMs,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
return report;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function pickAction(
|
|
380
|
+
awaiting: AwaitingHint,
|
|
381
|
+
strategy: "first" | "random",
|
|
382
|
+
rng: () => number,
|
|
383
|
+
): PlayerAction {
|
|
384
|
+
const options = awaiting.options;
|
|
385
|
+
if (!options || options.length === 0) {
|
|
386
|
+
return { type: awaiting.type };
|
|
387
|
+
}
|
|
388
|
+
if (strategy === "random") {
|
|
389
|
+
const idx = Math.floor(rng() * options.length);
|
|
390
|
+
return { type: awaiting.type, value: options[idx] };
|
|
391
|
+
}
|
|
392
|
+
return { type: awaiting.type, value: options[0] };
|
|
393
|
+
}
|