@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/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @open-rgs/simulator
|
|
2
|
+
|
|
3
|
+
Per-mode RTP + hit-rate simulator and report generator for open-rgs
|
|
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.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add -d @open-rgs/simulator
|
|
17
|
+
```
|
|
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
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
Write a `simulate.ts` next to your game's `index.ts`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { simulate, mdReportSet, mulberry32 } from "@open-rgs/simulator";
|
|
38
|
+
import { loadLuaMath } from "@open-rgs/core";
|
|
39
|
+
import { defineGame } from "@open-rgs/contract";
|
|
40
|
+
|
|
41
|
+
// Seed the MATH's rng so spins are reproducible.
|
|
42
|
+
const math = await loadLuaMath("./maths/spin.lua", {
|
|
43
|
+
rng: mulberry32(42),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const manifest = defineGame({
|
|
47
|
+
id: "hello-spin",
|
|
48
|
+
declaredRtp: 0.95,
|
|
49
|
+
defaultMode: "default",
|
|
50
|
+
modes: { default: { math, stakeMultiplier: 1 } },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const reports = await simulate(manifest, { spinsPerMode: 100_000 });
|
|
54
|
+
console.log(mdReportSet(reports));
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run it: `bun src/simulate.ts > report.md`.
|
|
58
|
+
|
|
59
|
+
## What you get back
|
|
60
|
+
|
|
61
|
+
One [`SimulationReport`](src/report.ts) per mode:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
# Simulation — hello-spin / default
|
|
65
|
+
|
|
66
|
+
math hello-spin@0.2.0 (simple)
|
|
67
|
+
|
|
68
|
+
- Measured RTP: 94.87% (declared 95.00%, Δ -0.13%)
|
|
69
|
+
- Hit rate: 38.74%
|
|
70
|
+
- Spins: 100,000 · Bet: 1u/spin · Time: 412ms
|
|
71
|
+
- Stake multiplier: 1× · Internal: no
|
|
72
|
+
|
|
73
|
+
## Multiplier distribution
|
|
74
|
+
| stat | value |
|
|
75
|
+
|--------|---------|
|
|
76
|
+
| min | 0.0000 |
|
|
77
|
+
| mean | 0.9487 |
|
|
78
|
+
| stddev | 4.2814 |
|
|
79
|
+
| p50 | 0.0000 |
|
|
80
|
+
| p90 | 1.5000 |
|
|
81
|
+
| ... | |
|
|
82
|
+
|
|
83
|
+
## Outcome types
|
|
84
|
+
| type | count | share |
|
|
85
|
+
| ------- | -------- | ------- |
|
|
86
|
+
| loss | 61,260 | 61.26% |
|
|
87
|
+
| win | 37,824 | 37.82% |
|
|
88
|
+
| scatter | 916 | 0.92% |
|
|
89
|
+
|
|
90
|
+
## Next-mode routes
|
|
91
|
+
| target | count | share |
|
|
92
|
+
| ---------- | ----- | ----- |
|
|
93
|
+
| free-spins | 916 | 0.92% |
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Reproducibility note
|
|
97
|
+
|
|
98
|
+
The simulator's own `seed` option only drives its **complex-round step
|
|
99
|
+
strategy** ("random" / "first"). To make the *math's* spins
|
|
100
|
+
reproducible, seed the math at `loadLuaMath` time:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { mulberry32 } from "@open-rgs/simulator/rng";
|
|
104
|
+
const math = await loadLuaMath("./maths/spin.lua", { rng: mulberry32(42) });
|
|
105
|
+
```
|
|
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
|
+
|
|
112
|
+
The same `mulberry32` is exported from both `@open-rgs/simulator` and
|
|
113
|
+
its `/rng` subpath, so you can import it into the simulator script *or*
|
|
114
|
+
into a separate math-loading harness without dragging the whole sim in.
|
|
115
|
+
|
|
116
|
+
## Complex-round strategies
|
|
117
|
+
|
|
118
|
+
For complex rounds (open / step / close with player actions) the
|
|
119
|
+
simulator picks actions via:
|
|
120
|
+
|
|
121
|
+
| strategy | behaviour |
|
|
122
|
+
|----------|------------------------------------------------------|
|
|
123
|
+
| `"first"`| Always `awaiting.options[0]`. Default. Deterministic. |
|
|
124
|
+
| `"random"` | Picks uniformly from `awaiting.options` using the simulator's seeded rng. |
|
|
125
|
+
|
|
126
|
+
Bespoke strategies (e.g., always-gamble, always-take) aren't first-
|
|
127
|
+
class yet — write your own loop using the orchestrator's
|
|
128
|
+
`OrchestratorAPI` if you need them.
|
|
129
|
+
|
|
130
|
+
## Caveats
|
|
131
|
+
|
|
132
|
+
- `next_mode` and `carry` are recorded but **not followed**. Each
|
|
133
|
+
mode is simulated in isolation. Cross-mode session RTP needs a
|
|
134
|
+
different harness; this one measures per-mode math behaviour.
|
|
135
|
+
- Free-round campaigns aren't simulated either — those are platform-
|
|
136
|
+
side, and the simulator skips the platform adapter entirely.
|
|
137
|
+
- The whole reel-distribution is held in memory (`number[]` of length
|
|
138
|
+
`spinsPerMode`) so percentile and stddev can be computed. 100k spins
|
|
139
|
+
≈ 800kB. For 10M-spin runs, refactor to streaming quantile sketches.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-rgs/simulator",
|
|
3
|
+
"version": "0.0.0-snapshot-20260530050213",
|
|
4
|
+
"description": "Per-mode RTP + hit-rate simulator and report generator for open-rgs games. Library + `open-rgs-sim` CLI (run via bunx).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"bun": ">=1.0.0"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/open-rgs/open-rgs.git",
|
|
12
|
+
"directory": "packages/simulator"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/open-rgs/open-rgs",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "src/index.ts",
|
|
17
|
+
"types": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./rng": "./src/rng.ts"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"open-rgs-sim": "./src/cli.ts"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"provenance": true
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"keywords": [
|
|
34
|
+
"open-rgs",
|
|
35
|
+
"rgs",
|
|
36
|
+
"simulator",
|
|
37
|
+
"rtp",
|
|
38
|
+
"slot",
|
|
39
|
+
"math"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "bun test"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@open-rgs/contract": "0.0.0-snapshot-20260530050213",
|
|
46
|
+
"handlebars": "^4.7.9"
|
|
47
|
+
}
|
|
48
|
+
}
|
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;
|
package/src/deviation.ts
ADDED
|
@@ -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";
|