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