@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/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @open-rgs/simulator
2
+
3
+ Per-mode RTP + hit-rate simulator and report generator for open-rgs
4
+ games. Library, no CLI in v1.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ bun add -d @open-rgs/simulator
10
+ ```
11
+
12
+ ## Use
13
+
14
+ Write a `simulate.ts` next to your game's `index.ts`:
15
+
16
+ ```ts
17
+ import { simulate, mdReportSet, mulberry32 } from "@open-rgs/simulator";
18
+ import { loadLuaMath } from "@open-rgs/core";
19
+ import { defineGame } from "@open-rgs/contract";
20
+
21
+ // Seed the MATH's rng so spins are reproducible.
22
+ const math = await loadLuaMath("./maths/spin.lua", {
23
+ rng: mulberry32(42),
24
+ });
25
+
26
+ const manifest = defineGame({
27
+ id: "hello-spin",
28
+ declaredRtp: 0.95,
29
+ defaultMode: "default",
30
+ modes: { default: { math, stakeMultiplier: 1 } },
31
+ });
32
+
33
+ const reports = await simulate(manifest, { spinsPerMode: 100_000 });
34
+ console.log(mdReportSet(reports));
35
+ ```
36
+
37
+ Run it: `bun src/simulate.ts > report.md`.
38
+
39
+ ## What you get back
40
+
41
+ One [`SimulationReport`](src/report.ts) per mode:
42
+
43
+ ```
44
+ # Simulation — hello-spin / default
45
+
46
+ math hello-spin@0.2.0 (simple)
47
+
48
+ - Measured RTP: 94.87% (declared 95.00%, Δ -0.13%)
49
+ - Hit rate: 38.74%
50
+ - Spins: 100,000 · Bet: 1u/spin · Time: 412ms
51
+ - Stake multiplier: 1× · Internal: no
52
+
53
+ ## Multiplier distribution
54
+ | stat | value |
55
+ |--------|---------|
56
+ | min | 0.0000 |
57
+ | mean | 0.9487 |
58
+ | stddev | 4.2814 |
59
+ | p50 | 0.0000 |
60
+ | p90 | 1.5000 |
61
+ | ... | |
62
+
63
+ ## Outcome types
64
+ | type | count | share |
65
+ | ------- | -------- | ------- |
66
+ | loss | 61,260 | 61.26% |
67
+ | win | 37,824 | 37.82% |
68
+ | scatter | 916 | 0.92% |
69
+
70
+ ## Next-mode routes
71
+ | target | count | share |
72
+ | ---------- | ----- | ----- |
73
+ | free-spins | 916 | 0.92% |
74
+ ```
75
+
76
+ ## Reproducibility note
77
+
78
+ The simulator's own `seed` option only drives its **complex-round step
79
+ strategy** ("random" / "first"). To make the *math's* spins
80
+ reproducible, seed the math at `loadLuaMath` time:
81
+
82
+ ```ts
83
+ import { mulberry32 } from "@open-rgs/simulator/rng";
84
+ const math = await loadLuaMath("./maths/spin.lua", { rng: mulberry32(42) });
85
+ ```
86
+
87
+ The same `mulberry32` is exported from both `@open-rgs/simulator` and
88
+ its `/rng` subpath, so you can import it into the simulator script *or*
89
+ into a separate math-loading harness without dragging the whole sim in.
90
+
91
+ ## Complex-round strategies
92
+
93
+ For complex rounds (open / step / close with player actions) the
94
+ simulator picks actions via:
95
+
96
+ | strategy | behaviour |
97
+ |----------|------------------------------------------------------|
98
+ | `"first"`| Always `awaiting.options[0]`. Default. Deterministic. |
99
+ | `"random"` | Picks uniformly from `awaiting.options` using the simulator's seeded rng. |
100
+
101
+ Bespoke strategies (e.g., always-gamble, always-take) aren't first-
102
+ class yet — write your own loop using the orchestrator's
103
+ `OrchestratorAPI` if you need them.
104
+
105
+ ## Caveats
106
+
107
+ - `next_mode` and `carry` are recorded but **not followed**. Each
108
+ mode is simulated in isolation. Cross-mode session RTP needs a
109
+ different harness; this one measures per-mode math behaviour.
110
+ - Free-round campaigns aren't simulated either — those are platform-
111
+ side, and the simulator skips the platform adapter entirely.
112
+ - The whole reel-distribution is held in memory (`number[]` of length
113
+ `spinsPerMode`) so percentile and stddev can be computed. 100k spins
114
+ ≈ 800kB. For 10M-spin runs, refactor to streaming quantile sketches.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@open-rgs/simulator",
3
+ "version": "0.2.0",
4
+ "description": "Per-mode RTP + hit-rate simulator and report generator for open-rgs games. Library; no CLI in v1.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/open-rgs/open-rgs.git",
9
+ "directory": "packages/simulator"
10
+ },
11
+ "homepage": "https://github.com/open-rgs/open-rgs",
12
+ "type": "module",
13
+ "main": "src/index.ts",
14
+ "types": "src/index.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./rng": "./src/rng.ts"
18
+ },
19
+ "bin": {
20
+ "open-rgs-sim": "./src/cli.ts"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "provenance": true
25
+ },
26
+ "files": ["src", "README.md"],
27
+ "keywords": ["open-rgs", "rgs", "simulator", "rtp", "slot", "math"],
28
+ "scripts": {
29
+ "test": "bun test"
30
+ },
31
+ "dependencies": {
32
+ "@open-rgs/contract": "0.3.0",
33
+ "handlebars": "^4.7.8"
34
+ }
35
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env bun
2
+ // open-rgs-sim — CLI front-end for @open-rgs/simulator.
3
+ //
4
+ // Usage:
5
+ // bunx open-rgs-sim <manifest-module> [--spins N] [--seed N] [--out DIR]
6
+ // [--format md|html|json|all]
7
+ //
8
+ // <manifest-module> is a TS / JS file that default-exports either:
9
+ // • a GameManifest, OR
10
+ // • an async function returning a GameManifest
11
+ //
12
+ // Examples:
13
+ // bunx open-rgs-sim ./src/manifest.ts
14
+ // bunx open-rgs-sim ./src/manifest.ts --spins 250000 --seed 7
15
+ // bunx open-rgs-sim ./src/manifest.ts --out reports --format html
16
+
17
+ import { resolve, dirname, join, basename } from "node:path";
18
+ import { mkdir, writeFile } from "node:fs/promises";
19
+ import { simulate } from "./simulate.js";
20
+ import { mdReportSet } from "./report.js";
21
+ import { htmlReportSet } from "./html.js";
22
+ import type { GameManifest } from "@open-rgs/contract";
23
+
24
+ interface CliOpts {
25
+ manifestPath: string;
26
+ spins: number;
27
+ seed: number;
28
+ outDir: string;
29
+ format: "md" | "html" | "json" | "all";
30
+ betUnits: number;
31
+ includeInternal: boolean;
32
+ quiet: boolean;
33
+ }
34
+
35
+ function parseArgs(argv: readonly string[]): CliOpts {
36
+ const positional: string[] = [];
37
+ const flags: Record<string, string> = {};
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i]!;
40
+ if (a.startsWith("--")) {
41
+ const eq = a.indexOf("=");
42
+ if (eq >= 0) flags[a.slice(2, eq)] = a.slice(eq + 1);
43
+ else { flags[a.slice(2)] = argv[++i] ?? "true"; }
44
+ } else {
45
+ positional.push(a);
46
+ }
47
+ }
48
+ if (positional.length === 0) usageAndExit("missing <manifest-module> argument");
49
+ return {
50
+ manifestPath: positional[0]!,
51
+ spins: Number(flags["spins"] ?? "100000"),
52
+ seed: Number(flags["seed"] ?? "0"),
53
+ outDir: flags["out"] ?? "./reports",
54
+ format: ((flags["format"] ?? "all") as CliOpts["format"]),
55
+ betUnits: Number(flags["bet"] ?? "1"),
56
+ includeInternal: flags["skip-internal"] === "true" ? false : true,
57
+ quiet: flags["quiet"] === "true",
58
+ };
59
+ }
60
+
61
+ function usageAndExit(msg: string): never {
62
+ process.stderr.write(`open-rgs-sim: ${msg}\n\n`);
63
+ process.stderr.write(`Usage: bunx open-rgs-sim <manifest-module> [opts]\n`);
64
+ process.stderr.write(`Opts:\n`);
65
+ process.stderr.write(` --spins N spins per mode (default 100000)\n`);
66
+ process.stderr.write(` --seed N simulator PRNG seed (default 0)\n`);
67
+ process.stderr.write(` --bet N units per spin pre-stakeMultiplier (default 1)\n`);
68
+ process.stderr.write(` --out DIR output directory (default ./reports)\n`);
69
+ process.stderr.write(` --format md|html|json|all (default all)\n`);
70
+ process.stderr.write(` --skip-internal skip modes flagged { internal: true }\n`);
71
+ process.stderr.write(` --quiet suppress stdout progress lines\n`);
72
+ process.exit(2);
73
+ }
74
+
75
+ async function loadManifest(modulePath: string, seed: number): Promise<GameManifest> {
76
+ const abs = resolve(process.cwd(), modulePath);
77
+ const mod = await import(abs);
78
+ // Accept default export, named `manifest`, or named `buildManifest`.
79
+ // Functions are called with `{ seed }` so they can seed the math's RNG.
80
+ const candidate = mod.default ?? mod.manifest ?? mod.buildManifest;
81
+ if (candidate == null) {
82
+ usageAndExit(`module ${modulePath} does not export default / manifest / buildManifest`);
83
+ }
84
+ const resolved = typeof candidate === "function" ? await candidate({ seed }) : candidate;
85
+ if (!resolved || typeof resolved !== "object" || typeof resolved.id !== "string") {
86
+ usageAndExit(`module ${modulePath} did not produce a GameManifest`);
87
+ }
88
+ return resolved as GameManifest;
89
+ }
90
+
91
+ async function main(): Promise<void> {
92
+ const opts = parseArgs(process.argv.slice(2));
93
+ const manifest = await loadManifest(opts.manifestPath, opts.seed);
94
+
95
+ if (!opts.quiet) {
96
+ process.stderr.write(`open-rgs-sim · ${manifest.id} · ${opts.spins.toLocaleString()} spins/mode · seed ${opts.seed}\n`);
97
+ }
98
+
99
+ const reports = await simulate(manifest, {
100
+ spinsPerMode: opts.spins,
101
+ seed: opts.seed,
102
+ betUnits: opts.betUnits,
103
+ includeInternal: opts.includeInternal,
104
+ });
105
+
106
+ await mkdir(opts.outDir, { recursive: true });
107
+ const stem = `${manifest.id}-seed${opts.seed}-spins${opts.spins}`;
108
+ const wrote: string[] = [];
109
+
110
+ if (opts.format === "all" || opts.format === "md") {
111
+ const p = join(opts.outDir, `${stem}.md`);
112
+ await writeFile(p, mdReportSet(reports), "utf-8");
113
+ wrote.push(p);
114
+ }
115
+ if (opts.format === "all" || opts.format === "html") {
116
+ const p = join(opts.outDir, `${stem}.html`);
117
+ await writeFile(p, htmlReportSet(reports), "utf-8");
118
+ wrote.push(p);
119
+ }
120
+ if (opts.format === "all" || opts.format === "json") {
121
+ const p = join(opts.outDir, `${stem}.json`);
122
+ await writeFile(p, JSON.stringify({
123
+ schema: "open-rgs/simulator/report@1",
124
+ game: reports[0]?.game ?? null,
125
+ reports,
126
+ }, null, 2), "utf-8");
127
+ wrote.push(p);
128
+ }
129
+
130
+ if (!opts.quiet) {
131
+ for (const p of wrote) process.stderr.write(`→ ${p}\n`);
132
+ // Always print the one-line narrative(s) to stdout for grep/jq.
133
+ for (const r of reports) process.stdout.write(`${r.narrative}\n`);
134
+ }
135
+ }
136
+
137
+ main().catch(e => {
138
+ process.stderr.write(`open-rgs-sim: ${e instanceof Error ? e.message : String(e)}\n`);
139
+ if (e instanceof Error && e.stack) process.stderr.write(e.stack + "\n");
140
+ process.exit(1);
141
+ });
142
+
143
+ // Silence "unused" warnings for path helpers — they're imported for runtime use only.
144
+ void dirname; void basename;
@@ -0,0 +1,129 @@
1
+ // Target-vs-measured deviation. Each entry in the author's
2
+ // MathExpectations produces one TargetDeviation; the simulator embeds
3
+ // them in the report so consumers (and LLMs) can scan for what's off.
4
+
5
+ import type { MathExpectations, MathTarget } from "@open-rgs/contract";
6
+
7
+ export type DeviationStatus = "ok" | "warn" | "fail";
8
+
9
+ export interface TargetDeviation {
10
+ /** Dotted key, e.g. "hit_rate", "rate.scatter_trigger",
11
+ * "rtp_contribution.scatter", "tag_share.feature". */
12
+ key: string;
13
+ target: number;
14
+ measured: number;
15
+ /** measured - target. */
16
+ delta: number;
17
+ /** Absolute tolerance band (derived if not declared: 5% of |target|). */
18
+ tolerance: number;
19
+ status: DeviationStatus;
20
+ }
21
+
22
+ interface MeasuredSeries {
23
+ hitRate: number;
24
+ rates: Record<string, number>; // per-spin rate of named counter
25
+ rtpContributions: Record<string, number>; // RTP fraction of named bucket
26
+ tagShares: Record<string, number>; // per-spin share of tagged spins
27
+ }
28
+
29
+ /** Compute one TargetDeviation per declared target in `expected`. Quiet
30
+ * when `expected` is undefined — no opinions, no entries. */
31
+ export function computeDeviations(
32
+ expected: MathExpectations | undefined,
33
+ measured: MeasuredSeries,
34
+ ): TargetDeviation[] {
35
+ if (!expected) return [];
36
+ const out: TargetDeviation[] = [];
37
+
38
+ if (expected.hitRate) {
39
+ out.push(make("hit_rate", expected.hitRate, measured.hitRate));
40
+ }
41
+ for (const [name, t] of Object.entries(expected.rate ?? {})) {
42
+ out.push(make(`rate.${name}`, t, measured.rates[name] ?? 0));
43
+ }
44
+ for (const [name, t] of Object.entries(expected.rtpContribution ?? {})) {
45
+ out.push(make(`rtp_contribution.${name}`, t, measured.rtpContributions[name] ?? 0));
46
+ }
47
+ for (const [name, t] of Object.entries(expected.tagShare ?? {})) {
48
+ out.push(make(`tag_share.${name}`, t, measured.tagShares[name] ?? 0));
49
+ }
50
+
51
+ // Stable sort: status (fail → warn → ok), then key.
52
+ const order: Record<DeviationStatus, number> = { fail: 0, warn: 1, ok: 2 };
53
+ out.sort((a, b) => order[a.status] - order[b.status] || a.key.localeCompare(b.key));
54
+ return out;
55
+ }
56
+
57
+ function make(key: string, t: MathTarget, measured: number): TargetDeviation {
58
+ const tolerance = t.tolerance ?? Math.max(Math.abs(t.target) * 0.05, 1e-9);
59
+ const delta = measured - t.target;
60
+ const adelta = Math.abs(delta);
61
+ const status: DeviationStatus =
62
+ adelta <= tolerance ? "ok" :
63
+ adelta <= 2 * tolerance ? "warn" :
64
+ "fail";
65
+ return { key, target: t.target, measured, delta, tolerance, status };
66
+ }
67
+
68
+ /** Generate a short, machine-readable narrative summarising the report
69
+ * for AI consumption. Designed to be quotable and grep-able. */
70
+ export function narrate(
71
+ game: string,
72
+ mode: string,
73
+ rtp: { measured: number; declared: number; delta: number },
74
+ hitRate: number,
75
+ deviations: readonly TargetDeviation[],
76
+ topContributions: ReadonlyArray<{ name: string; rtpShare: number }>,
77
+ ): string {
78
+ const fails = deviations.filter(d => d.status === "fail");
79
+ const warns = deviations.filter(d => d.status === "warn");
80
+ const oks = deviations.filter(d => d.status === "ok");
81
+ const pct = (n: number) => (n * 100).toFixed(2) + "%";
82
+ const sgn = (n: number) => (n >= 0 ? "+" : "") + (n * 100).toFixed(2) + "%";
83
+
84
+ const parts: string[] = [];
85
+ parts.push(
86
+ `${game}/${mode}: RTP ${pct(rtp.measured)} (declared ${pct(rtp.declared)}, Δ ${sgn(rtp.delta)}); hit rate ${pct(hitRate)}.`,
87
+ );
88
+
89
+ if (deviations.length === 0) {
90
+ parts.push("No author-declared targets to compare.");
91
+ } else if (fails.length === 0 && warns.length === 0) {
92
+ parts.push(`All ${oks.length} declared targets within tolerance.`);
93
+ } else {
94
+ if (fails.length > 0) {
95
+ parts.push(
96
+ `${fails.length} target${fails.length === 1 ? "" : "s"} FAIL: ` +
97
+ fails.slice(0, 3).map(d => `${d.key}=${num(d.measured)} (target ${num(d.target)}, Δ ${signedNum(d.delta)})`).join("; ") +
98
+ (fails.length > 3 ? `; +${fails.length - 3} more` : "") + ".",
99
+ );
100
+ }
101
+ if (warns.length > 0) {
102
+ parts.push(
103
+ `${warns.length} target${warns.length === 1 ? "" : "s"} WARN: ` +
104
+ warns.slice(0, 3).map(d => `${d.key}=${num(d.measured)} (target ${num(d.target)})`).join("; ") +
105
+ (warns.length > 3 ? `; +${warns.length - 3} more` : "") + ".",
106
+ );
107
+ }
108
+ if (oks.length > 0) parts.push(`${oks.length} target${oks.length === 1 ? "" : "s"} ok.`);
109
+ }
110
+
111
+ if (topContributions.length > 0) {
112
+ parts.push(
113
+ `Top RTP contributors: ` +
114
+ topContributions.slice(0, 3).map(c => `${c.name} (${pct(c.rtpShare)})`).join(", ") + ".",
115
+ );
116
+ }
117
+
118
+ return parts.join(" ");
119
+ }
120
+
121
+ function num(n: number): string {
122
+ if (Math.abs(n) < 1e-6) return "0";
123
+ if (Math.abs(n) < 0.01) return n.toExponential(2);
124
+ return n.toFixed(4);
125
+ }
126
+ function signedNum(n: number): string {
127
+ const s = num(Math.abs(n));
128
+ return (n >= 0 ? "+" : "-") + s;
129
+ }
package/src/html.ts ADDED
@@ -0,0 +1,136 @@
1
+ // HTML report via Handlebars. Self-contained single-file output: inline
2
+ // CSS, inline JS, no external assets. Styled to match the open-rgs.dev
3
+ // site (warm-paper light, warm-charcoal dark, Charter serif).
4
+ //
5
+ // Use:
6
+ // import { htmlReportSet } from "@open-rgs/simulator";
7
+ // await Bun.write("report.html", htmlReportSet(reports));
8
+ //
9
+ // Custom template? Pass `template: string` (raw .hbs source) in options.
10
+
11
+ import Handlebars from "handlebars";
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import type { SimulationReport } from "./report.js";
16
+ import type { TargetDeviation, DeviationStatus } from "./deviation.js";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const defaultTemplateSrc = readFileSync(join(here, "templates", "default.hbs"), "utf-8");
20
+
21
+ let helpersRegistered = false;
22
+ function ensureHelpers(): void {
23
+ if (helpersRegistered) return;
24
+ helpersRegistered = true;
25
+
26
+ Handlebars.registerHelper("pct", (n: unknown) => formatPct(toNum(n)));
27
+ Handlebars.registerHelper("pctSigned", (n: unknown) => {
28
+ const v = toNum(n);
29
+ return (v >= 0 ? "+" : "") + (v * 100).toFixed(2) + "%";
30
+ });
31
+ Handlebars.registerHelper("fixed", (n: unknown, digits: unknown) => {
32
+ const d = typeof digits === "number" ? digits : 2;
33
+ return toNum(n).toFixed(d);
34
+ });
35
+ Handlebars.registerHelper("fixedSigned", (n: unknown, digits: unknown) => {
36
+ const d = typeof digits === "number" ? digits : 2;
37
+ const v = toNum(n);
38
+ return (v >= 0 ? "+" : "") + v.toFixed(d);
39
+ });
40
+ Handlebars.registerHelper("numLocale", (n: unknown) => toNum(n).toLocaleString());
41
+ Handlebars.registerHelper("eq", (a: unknown, b: unknown) => a === b);
42
+ Handlebars.registerHelper("gt", (a: unknown, b: unknown) => toNum(a) > toNum(b));
43
+ Handlebars.registerHelper("div", (a: unknown, b: unknown) => {
44
+ const d = toNum(b);
45
+ return d === 0 ? 0 : toNum(a) / d;
46
+ });
47
+
48
+ // {{#each (entries obj)}} → iterate { key, value } pairs.
49
+ Handlebars.registerHelper("entries", (obj: unknown) => {
50
+ if (!obj || typeof obj !== "object") return [];
51
+ return Object.entries(obj as Record<string, unknown>).map(([key, value]) => ({ key, value }));
52
+ });
53
+
54
+ Handlebars.registerHelper("anyEntries", (obj: unknown) => {
55
+ if (!obj || typeof obj !== "object") return false;
56
+ return Object.keys(obj as Record<string, unknown>).length > 0;
57
+ });
58
+
59
+ // Sorted entries for outcomeTypes / nextModeRoutes (record<string, number>): desc by value.
60
+ Handlebars.registerHelper("sortEntries", (obj: unknown) => {
61
+ if (!obj || typeof obj !== "object") return [];
62
+ return Object.entries(obj as Record<string, number>)
63
+ .map(([key, value]) => ({ key, value }))
64
+ .sort((a, b) => b.value - a.value);
65
+ });
66
+
67
+ // Counters: desc by .total
68
+ Handlebars.registerHelper("sortCounters", (obj: unknown) => {
69
+ if (!obj || typeof obj !== "object") return [];
70
+ return Object.entries(obj as Record<string, { total: number; perSpin: number }>)
71
+ .map(([key, value]) => ({ key, value }))
72
+ .sort((a, b) => b.value.total - a.value.total);
73
+ });
74
+
75
+ // Tag shares: desc by .spins
76
+ Handlebars.registerHelper("sortTags", (obj: unknown) => {
77
+ if (!obj || typeof obj !== "object") return [];
78
+ return Object.entries(obj as Record<string, { spins: number; share: number }>)
79
+ .map(([key, value]) => ({ key, value }))
80
+ .sort((a, b) => b.value.spins - a.value.spins);
81
+ });
82
+
83
+ // Contributions: desc by .rtpShare
84
+ Handlebars.registerHelper("sortContributions", (obj: unknown) => {
85
+ if (!obj || typeof obj !== "object") return [];
86
+ return Object.entries(obj as Record<string, { sumMultiplier: number; rtpShare: number }>)
87
+ .map(([key, value]) => ({ key, value }))
88
+ .sort((a, b) => b.value.rtpShare - a.value.rtpShare);
89
+ });
90
+
91
+ // Status badge: count by status (ok/warn/fail), sorted fail-first.
92
+ Handlebars.registerHelper("statusCounts", (deviations: unknown) => {
93
+ if (!Array.isArray(deviations)) return [];
94
+ const counts: Record<DeviationStatus, number> = { fail: 0, warn: 0, ok: 0 };
95
+ for (const d of deviations as TargetDeviation[]) {
96
+ counts[d.status] = (counts[d.status] ?? 0) + 1;
97
+ }
98
+ return (["fail", "warn", "ok"] as DeviationStatus[])
99
+ .filter(k => counts[k] > 0)
100
+ .map(k => ({ key: k, value: counts[k] }));
101
+ });
102
+ }
103
+
104
+ export interface HtmlReportOptions {
105
+ /** Override the default template. Pass raw .hbs source. */
106
+ template?: string;
107
+ /** Override the "generated at" timestamp. Default = new Date().toISOString(). */
108
+ generatedAt?: string;
109
+ /** Override the generator string. Default = "@open-rgs/simulator". */
110
+ generator?: string;
111
+ }
112
+
113
+ /** Render all reports as a single self-contained HTML document. */
114
+ export function htmlReportSet(
115
+ reports: readonly SimulationReport[],
116
+ opts: HtmlReportOptions = {},
117
+ ): string {
118
+ ensureHelpers();
119
+ const template = Handlebars.compile(opts.template ?? defaultTemplateSrc, { noEscape: false });
120
+ return template({
121
+ reports,
122
+ game: reports[0]?.game ?? { id: "(empty)", declaredRtp: 0, defaultMode: "(none)" },
123
+ generatedAt: opts.generatedAt ?? new Date().toISOString(),
124
+ generator: opts.generator ?? "@open-rgs/simulator",
125
+ });
126
+ }
127
+
128
+ function toNum(v: unknown): number {
129
+ if (typeof v === "number") return v;
130
+ if (typeof v === "string") return Number(v);
131
+ return 0;
132
+ }
133
+
134
+ function formatPct(n: number): string {
135
+ return (n * 100).toFixed(2) + "%";
136
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // @open-rgs/simulator public surface.
2
+
3
+ export { simulate, type SimulateOptions } from "./simulate.js";
4
+ export { mdReport, mdReportSet, type SimulationReport, type DistributionStats } from "./report.js";
5
+ export { htmlReportSet, type HtmlReportOptions } from "./html.js";
6
+ export { computeDeviations, narrate, type TargetDeviation, type DeviationStatus } from "./deviation.js";
7
+ export { mulberry32 } from "./rng.js";