hbengine 0.2.3 → 0.4.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/engine-cli/hbengine.mjs +106 -36
- package/engine-cli/outage.mjs +9 -0
- package/engine-cli/templates.mjs +1 -0
- package/engine-cli/upload-errors.mjs +18 -0
- package/engine-cli/upload.mjs +172 -0
- package/engine-conformance/analyse.mjs +155 -15
- package/engine-conformance/bundle.mjs +23 -5
- package/engine-conformance/census-worker.mjs +9 -0
- package/engine-conformance/chrome-flags.mjs +19 -0
- package/engine-conformance/diagnostic-text.mjs +19 -0
- package/engine-conformance/diagnostics.mjs +52 -0
- package/engine-conformance/focus.mjs +42 -0
- package/engine-conformance/hints.mjs +86 -0
- package/engine-conformance/js-releases.json +2365 -0
- package/engine-conformance/js-releases.mjs +75 -0
- package/engine-conformance/run.mjs +226 -39
- package/engine-conformance/scan.mjs +71 -18
- package/engine-conformance/sound.mjs +45 -0
- package/engine-conformance/travel.mjs +132 -6
- package/engine-examples/keno/math-kit/pack.mjs +87 -10
- package/engine-examples/lines/math-kit/pack.mjs +87 -10
- package/engine-examples/plinko/math-kit/pack.mjs +87 -10
- package/engine-examples/plinko-jar/AGENTS.md +3 -0
- package/engine-examples/plinko-jar/README.md +16 -0
- package/engine-examples/plinko-jar/bundle/assets.json +1 -0
- package/engine-examples/plinko-jar/bundle/game.css +10 -0
- package/engine-examples/plinko-jar/bundle/game.js +113 -0
- package/engine-examples/plinko-jar/bundle/hbtest.json +4 -0
- package/engine-examples/plinko-jar/bundle/index.html +16 -0
- package/engine-examples/plinko-jar/bundle/model.json +1 -0
- package/engine-examples/plinko-jar/math/gen.mjs +47 -0
- package/engine-examples/plinko-jar/math/manifest.json +1 -0
- package/engine-examples/plinko-jar/math/plinko-jar-12-high.tsv +13 -0
- package/engine-examples/plinko-jar/math/plinko-jar-12-low.tsv +13 -0
- package/engine-examples/plinko-jar/math/plinko-jar-12-medium.tsv +13 -0
- package/engine-examples/plinko-jar/math/plinko-jar-16-high.tsv +17 -0
- package/engine-examples/plinko-jar/math/plinko-jar-16-low.tsv +17 -0
- package/engine-examples/plinko-jar/math/plinko-jar-16-medium.tsv +17 -0
- package/engine-examples/plinko-jar/math/plinko-jar-8-high.tsv +9 -0
- package/engine-examples/plinko-jar/math/plinko-jar-8-low.tsv +9 -0
- package/engine-examples/plinko-jar/math/plinko-jar-8-medium.tsv +9 -0
- package/engine-examples/plinko-jar/math/plinko-jar-meter-jar.tsv +5 -0
- package/engine-examples/plinko-jar/math-kit/pack.mjs +242 -0
- package/engine-examples/plinko-jar/math-kit/package.json +8 -0
- package/engine-examples/plinko-jar/math-kit/slot.mjs +94 -0
- package/engine-examples/plinko-jar/math-kit/tumble.mjs +94 -0
- package/engine-examples/tumble/math-kit/pack.mjs +87 -10
- package/engine-math/pack.mjs +87 -10
- package/engine-mcp/server.mjs +30 -14
- package/engine-sdk/GUIDE.md +234 -32
- package/engine-sdk/bet-field.js +16 -3
- package/engine-sdk/bet.js +88 -0
- package/engine-sdk/frames.js +75 -0
- package/engine-sdk/progress.js +128 -0
- package/engine-sdk/protocol.js +3 -0
- package/engine-sdk/sdk.d.ts +35 -0
- package/engine-sdk/sdk.js +9 -0
- package/engine-sdk/ui.js +3 -0
- package/engine-shell/core/rgs.js +1 -0
- package/engine-shell/core/router.js +46 -54
- package/engine-shell/host/audio-session.js +24 -0
- package/engine-shell/host/boot.js +5 -2
- package/engine-shell/host/engine-v1.js +17 -4
- package/engine-shell/host/fair.js +27 -0
- package/engine-shell/host/index.html +44 -9
- package/engine-shell/host/intro.js +99 -0
- package/engine-shell/host/page.js +25 -5
- package/engine-shell/host/sandbox-panel.js +23 -3
- package/engine-shell/host/sandbox-scenarios.js +23 -11
- package/engine-shell/host/site-bridge.js +4 -2
- package/engine-shell/verifiers/engine-verify.js +12 -3
- package/package.json +2 -1
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* rules (CSP, base, refresh, object/embed, srcdoc, outside URLs) that a tag
|
|
17
17
|
* walk serves as well as a parser would.
|
|
18
18
|
*/
|
|
19
|
-
import { closeSync, lstatSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs';
|
|
19
|
+
import { closeSync, lstatSync, openSync, readdirSync, readFileSync, readSync, realpathSync } from 'node:fs';
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
21
|
import { Worker } from 'node:worker_threads';
|
|
22
|
-
import {
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
import { join, relative, extname, posix, sep } from 'node:path';
|
|
23
24
|
import { checkAssetManifest } from '../engine-sdk/assets.js';
|
|
24
25
|
import { RULES } from './analyse.mjs';
|
|
25
26
|
|
|
@@ -52,6 +53,9 @@ const GZIP_TIMEOUT = 120 * 1000;
|
|
|
52
53
|
// files, Kraken half a second. A bundle whose code is still being read at the
|
|
53
54
|
// deadline is refused unread.
|
|
54
55
|
const ANALYSE_TIMEOUT = 120 * 1000;
|
|
56
|
+
// When the analysis runs out of time, what can be told from syntax alone
|
|
57
|
+
// (minified files, their source maps) gets this long: a parse, milliseconds.
|
|
58
|
+
const CENSUS_TIMEOUT = 30 * 1000;
|
|
55
59
|
|
|
56
60
|
const TEXT = new Set(['.html', '.htm', '.js', '.mjs', '.css', '.json', '.svg', '.xml', '.xhtml', '.xsl', '.xslt', '.txt']);
|
|
57
61
|
// The languages the site ships (arbitrium-frontend apps/web/messages).
|
|
@@ -99,13 +103,21 @@ const FORBIDDEN_MARKUP = [
|
|
|
99
103
|
// (no eval, connect-src self, base-uri none, no plugins) is the law, and the
|
|
100
104
|
// run proves the bets reach the engine.
|
|
101
105
|
|
|
102
|
-
// Libraries a studio ships under vendor
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// reviewer instead (the browser's policy blocks it at run time, and the
|
|
106
|
-
// proves the game works without it),
|
|
107
|
-
//
|
|
108
|
-
//
|
|
106
|
+
// Libraries a studio ships under vendor/. A file there is read as a library
|
|
107
|
+
// only when its bytes are a release's as published (KNOWN_JS, by SHA-256,
|
|
108
|
+
// js-releases.json): what the analyser would refuse in it is then named for
|
|
109
|
+
// the reviewer instead (the browser's policy blocks it at run time, and the
|
|
110
|
+
// run proves the game works without it), its being minified is expected, and
|
|
111
|
+
// the game's own code does not follow values into it (bundle.mjs, the pool).
|
|
112
|
+
// Anything else under vendor/ is the game's own code to the scan, whatever its
|
|
113
|
+
// name or banner. A studio with another library or release asks us to add it
|
|
114
|
+
// (js-releases.mjs).
|
|
115
|
+
export const KNOWN_JS = new Map(Object.entries(JSON.parse(readFileSync(new URL('./js-releases.json', import.meta.url), 'utf8')).files));
|
|
116
|
+
|
|
117
|
+
// The libraries we know by name: a vendor/ file named like one, carrying the
|
|
118
|
+
// banner its releases carry in the first 4 KB, claims to be it. A claim alone
|
|
119
|
+
// makes nothing a library (KNOWN_JS does); a file that claims one and is not
|
|
120
|
+
// one of its releases is named for review (unreleased-library).
|
|
109
121
|
export const KNOWN_LIBRARIES = [
|
|
110
122
|
{ name: 'PixiJS', file: /^(?:pixi|unsafe-eval|pixi-[\w.-]+)(?:[.-][\w.-]*)?\.m?js$/i, banner: /pixi\.?js/i },
|
|
111
123
|
{ name: 'howler.js', file: /^howler(?:[.-][\w.-]*)?\.m?js$/i, banner: /howler/i },
|
|
@@ -137,7 +149,9 @@ const BANNER_BYTES = 4096;
|
|
|
137
149
|
// A scheme is a word of its own: `--turn:180deg` (a CSS custom property) and
|
|
138
150
|
// `.turn:hover` (a class) are not TURN server addresses, so a scheme preceded
|
|
139
151
|
// by a word character, `-`, `.` or `#` is not one.
|
|
140
|
-
|
|
152
|
+
// A STUN/TURN address names a dotted host (turn:relay.example.com, turn:1.2.3.4): an object's
|
|
153
|
+
// `{turns: 1}` or `turns:1` in minified code is not one.
|
|
154
|
+
const URL_RE = /(?<![\w.#-])(?:(?:https?|wss?):\/?\/?[^\s"'`)<>]+|(?:stuns?|turns?):(?:\/\/)?[\w-]+\.[^\s"'`)<>]*)/g;
|
|
141
155
|
// Hosts that appear in markup as identifiers, not as network destinations.
|
|
142
156
|
const URL_IGNORE = /^https?:\/\/(www\.w3\.org|schema\.org|xmlns\.com)\//;
|
|
143
157
|
|
|
@@ -155,7 +169,7 @@ function walk(dir, root = dir, out = []) {
|
|
|
155
169
|
const p = join(dir, name);
|
|
156
170
|
const st = lstatSync(p);
|
|
157
171
|
if (st.isDirectory()) walk(p, root, out);
|
|
158
|
-
else out.push({ path: relative(root, p), abs: p, bytes: st.size, link: st.isSymbolicLink() });
|
|
172
|
+
else out.push({ path: relative(root, p).split(sep).join('/'), abs: p, bytes: st.size, link: st.isSymbolicLink() });
|
|
159
173
|
}
|
|
160
174
|
return out;
|
|
161
175
|
}
|
|
@@ -179,6 +193,8 @@ const gzipSizes = (paths, timeout) => onWorker('./gzip-sizes.mjs', paths, timeou
|
|
|
179
193
|
// The analyser's findings per code file (analyse-worker.mjs). The thread's heap
|
|
180
194
|
// is bounded: a 4 MB file's syntax tree is tens of megabytes, not a gigabyte.
|
|
181
195
|
const analyseFiles = (files, timeout) => onWorker('./analyse-worker.mjs', { files }, timeout, 'analysing the code', { maxOldGenerationSizeMb: 1024 });
|
|
196
|
+
// The quick census of the scripts (census-worker.mjs), when the analysis did not finish.
|
|
197
|
+
const censusFiles = (files, timeout) => onWorker('./census-worker.mjs', { files }, timeout, 'counting the names', { maxOldGenerationSizeMb: 1024 });
|
|
182
198
|
|
|
183
199
|
// Does this HTML frame another page of the bundle (an <iframe>, <frame>,
|
|
184
200
|
// <embed> or <object> whose src/data is a relative .html address)? A tag walk,
|
|
@@ -218,9 +234,16 @@ function head(abs) {
|
|
|
218
234
|
} finally { closeSync(fd); }
|
|
219
235
|
}
|
|
220
236
|
|
|
221
|
-
// Which known library a vendor/ file is, by
|
|
222
|
-
//
|
|
223
|
-
export function recognisedLibrary(path,
|
|
237
|
+
// Which known library a vendor/ file is, by the SHA-256 of its bytes (a
|
|
238
|
+
// release as published: KNOWN_JS); null for anything else.
|
|
239
|
+
export function recognisedLibrary(path, sha) {
|
|
240
|
+
if (!path.startsWith('vendor/')) return null;
|
|
241
|
+
return KNOWN_JS.get(sha)?.library ?? null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Which known library a vendor/ file claims to be, by its name and the banner
|
|
245
|
+
// in its head; null for none.
|
|
246
|
+
export function claimedLibrary(path, head) {
|
|
224
247
|
if (!path.startsWith('vendor/')) return null;
|
|
225
248
|
const base = posix.basename(path);
|
|
226
249
|
const lib = KNOWN_LIBRARIES.find((l) => l.file.test(base));
|
|
@@ -382,9 +405,39 @@ export async function scan(dir, { gzipTimeout = GZIP_TIMEOUT, analyseTimeout = A
|
|
|
382
405
|
const code = files.filter((f) => !f.link && ((CODE.has(ext(f)) && f.bytes <= BUDGETS.codeBytes) || (XML_PAGES.has(ext(f)) && f.bytes <= BUDGETS.fileBytes) || isData(f)));
|
|
383
406
|
const kind = (f) => (XML_PAGES.has(ext(f)) ? 'xml' : MARKUP.has(ext(f)) ? 'html' : CODE.has(ext(f)) ? 'script' : 'data');
|
|
384
407
|
let analysed = [];
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
408
|
+
let unfinished = false;
|
|
409
|
+
// A recognised library (a release's bytes) is read on its own: the game's
|
|
410
|
+
// code does not follow values into it (bundle.mjs, the pool). A vendor/
|
|
411
|
+
// file that claims a library by its name and banner without being one of
|
|
412
|
+
// its releases is the game's code, and named for review.
|
|
413
|
+
const library = new Map();
|
|
414
|
+
for (const f of code) {
|
|
415
|
+
if (!f.path.startsWith('vendor/') || !CODE.has(ext(f))) continue;
|
|
416
|
+
const sha = sha256(f.abs);
|
|
417
|
+
const lib = recognisedLibrary(f.path, sha);
|
|
418
|
+
if (lib) { library.set(f.path, lib); continue; }
|
|
419
|
+
const claim = claimedLibrary(f.path, head(f.abs));
|
|
420
|
+
if (claim) add('warn', 'unreleased-library', `${RULES['unreleased-library'].text}: named and bannered like ${claim}, sha256 ${sha}`, f.path);
|
|
421
|
+
}
|
|
422
|
+
try { analysed = await analyseFiles(code.map((f) => ({ path: f.path, abs: f.abs, kind: kind(f), library: library.get(f.path) })), analyseTimeout); }
|
|
423
|
+
catch (e) { unfinished = true; add('fail', 'analyser', `${RULES.analyser.text}: ${e.message}`); }
|
|
424
|
+
// The analysis did not finish: what the studio can act on is still told
|
|
425
|
+
// (the reason it usually does not: minified files without their source
|
|
426
|
+
// maps, libraries shipped as bundle chunks that are read as the game's
|
|
427
|
+
// own code), so the report is never only "it took too long".
|
|
428
|
+
if (unfinished) {
|
|
429
|
+
const scripts = code.filter((f) => kind(f) === 'script');
|
|
430
|
+
try {
|
|
431
|
+
for (const c of await censusFiles(scripts.map((f) => ({ path: f.path, abs: f.abs })), CENSUS_TIMEOUT)) {
|
|
432
|
+
if (library.get(c.path) || !c.minified) continue;
|
|
433
|
+
const problem = minifiedProblem(c.path, c.sourceMappingURL, byPath);
|
|
434
|
+
if (problem) add('fail', 'minified', `${RULES.minified.text}: ${c.short} of ${c.names} names are one or two letters; ${problem}`, c.path);
|
|
435
|
+
}
|
|
436
|
+
} catch { /* the analyser's own refusal already stands */ }
|
|
437
|
+
for (const f of scripts) {
|
|
438
|
+
if (f.path.startsWith('vendor/') && !library.get(f.path)) add('warn', 'unknown-library', RULES['unknown-library'].text, f.path);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
388
441
|
// A WebAssembly module is a program the analyser cannot read. One whose
|
|
389
442
|
// bytes are a known library's release (KNOWN_WASM, by SHA-256) is named for
|
|
390
443
|
// the reviewer, like the rest of what that library would be refused for;
|
|
@@ -463,7 +516,7 @@ export async function scan(dir, { gzipTimeout = GZIP_TIMEOUT, analyseTimeout = A
|
|
|
463
516
|
const mb = (b) => (b / 1024 / 1024).toFixed(1);
|
|
464
517
|
const kb = (b) => Math.round(b / 1024);
|
|
465
518
|
|
|
466
|
-
if (import.meta.url ===
|
|
519
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
467
520
|
const dir = process.argv[2];
|
|
468
521
|
if (!dir) { console.error('usage: scan.mjs <bundle-dir> [--json]'); process.exit(2); }
|
|
469
522
|
const r = await scan(dir);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The sound rule of the conformance run (run.mjs): nothing plays before the
|
|
2
|
+
// player's first tap, and the game gives a way to mute it.
|
|
3
|
+
//
|
|
4
|
+
// soundProbe runs in every frame of the game (its text is put in the init
|
|
5
|
+
// script, as the rest of the instrumentation): it wraps what starts a sound,
|
|
6
|
+
// a media element's play() unmuted and a Web Audio source's start(), and says
|
|
7
|
+
// once per kind and phase whether the frame had been touched yet (its user
|
|
8
|
+
// activation). soundVerdict turns what was said, and the label of a control
|
|
9
|
+
// found, into the run's check.
|
|
10
|
+
|
|
11
|
+
export const soundProbe = (scope, say) => {
|
|
12
|
+
const said = new Set();
|
|
13
|
+
const sound = (what) => {
|
|
14
|
+
let active = false;
|
|
15
|
+
try { active = !!(scope.navigator.userActivation && scope.navigator.userActivation.hasBeenActive); } catch { /* no activation API: counted as before */ }
|
|
16
|
+
const key = (active ? 'after:' : 'before:') + what;
|
|
17
|
+
if (!said.has(key)) { said.add(key); say(key); }
|
|
18
|
+
};
|
|
19
|
+
const media = scope.HTMLMediaElement && scope.HTMLMediaElement.prototype;
|
|
20
|
+
if (media && typeof media.play === 'function') {
|
|
21
|
+
media.play = new Proxy(media.play, { apply(t, self, a) { try { if (!self.muted && self.volume > 0) sound('media'); } catch { /* a detached element */ } return Reflect.apply(t, self, a); } });
|
|
22
|
+
}
|
|
23
|
+
const source = scope.AudioScheduledSourceNode && scope.AudioScheduledSourceNode.prototype;
|
|
24
|
+
if (source && typeof source.start === 'function') {
|
|
25
|
+
source.start = new Proxy(source.start, { apply(t, self, a) { sound('webaudio'); return Reflect.apply(t, self, a); } });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// What a control that mutes is called: its label, title or text.
|
|
30
|
+
export const SOUND_CONTROL = /\b(mute|unmute|sound|audio|volume|music)\b/i;
|
|
31
|
+
|
|
32
|
+
// soundControls lists, in a frame, the labels of the controls that look like a
|
|
33
|
+
// sound control (run in the page; SOUND_CONTROL given as its source).
|
|
34
|
+
export const soundControls = (pattern) => [...document.querySelectorAll('button, [role=button], [role=switch], input, a, [aria-label], [title]')]
|
|
35
|
+
.map((e) => [e.getAttribute('aria-label'), e.getAttribute('title'), e.textContent].filter(Boolean).join(' ').trim())
|
|
36
|
+
.filter((t) => new RegExp(pattern, 'i').test(t)).slice(0, 1);
|
|
37
|
+
|
|
38
|
+
// soundVerdict: a fail for sound before the first tap; a warning when no
|
|
39
|
+
// control to mute it was found; else a pass.
|
|
40
|
+
export function soundVerdict(said, control) {
|
|
41
|
+
const early = [...new Set(said.filter((x) => x.startsWith('before:')).map((x) => x.slice(7)))];
|
|
42
|
+
if (early.length) return { ok: false, detail: `sound started before the player's first tap (${early.join(', ')})` };
|
|
43
|
+
if (control) return { ok: true, detail: `no sound before the first tap; a sound control is there ("${control.slice(0, 40)}")` };
|
|
44
|
+
return { ok: true, detail: 'no sound before the first tap; no control labelled mute or sound was found: check the game can be muted', status: 'warning' };
|
|
45
|
+
}
|
|
@@ -68,9 +68,34 @@ export const CANARY_VARIANTS = Object.freeze(['base', 'plus1d', 'plus7d', 'plus3
|
|
|
68
68
|
/** The screenshot a variant writes after boot, as the job uploads it and the studio keeps it. */
|
|
69
69
|
export const shotName = (id) => `travel-${id}.png`;
|
|
70
70
|
/** The screenshot a short variant writes after a day of game time, kept when it differs. */
|
|
71
|
+
export const failureShotName = (id) => `travel-${id}-failure.png`;
|
|
71
72
|
export const lateShotName = (id) => `travel-${id}-late.png`;
|
|
72
73
|
/** Every screenshot name a set of variants may write (what the studio signs for). */
|
|
73
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The long session's outcome screenshots: one after each of the first rounds
|
|
76
|
+
* whose forced row is new (a loss, then each pay tier, lowest first), at most
|
|
77
|
+
* OUTCOME_SHOTS, for the reviewer to compare what the screen shows with what
|
|
78
|
+
* the maths pays.
|
|
79
|
+
*/
|
|
80
|
+
export const OUTCOME_SHOTS = 8;
|
|
81
|
+
export const outcomeShotName = (k) => `outcome-${k}.png`;
|
|
82
|
+
export const outcomeShotNames = () => Array.from({ length: OUTCOME_SHOTS }, (_, i) => outcomeShotName(i + 1));
|
|
83
|
+
export const shotNames = (variants) => variants.flatMap((v) => [failureShotName(v.id), ...(v.rounds ? [shotName(v.id), ...outcomeShotNames()] : [shotName(v.id), lateShotName(v.id)])]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What the maths pays for a forced row of the played mode and bet, in basis
|
|
87
|
+
* points of the stake: the tier whose outcomes hold it, 0 for a row no tier
|
|
88
|
+
* holds (a loss), null when the mode is not a table or the row is unknown.
|
|
89
|
+
*/
|
|
90
|
+
export function tierOf(manifest, played, row) {
|
|
91
|
+
const modes = Array.isArray(manifest?.modes) ? manifest.modes : [];
|
|
92
|
+
const mode = (played?.mode && modes.find((m) => m.id === played.mode)) || modes[0];
|
|
93
|
+
const bets = Array.isArray(mode?.bets) ? mode.bets : [];
|
|
94
|
+
const bet = (played?.bet && bets.find((b) => b.id === played.bet)) || bets[0];
|
|
95
|
+
if (!mode || mode.kind !== 'table' || !Array.isArray(bet?.tiers) || !Number.isInteger(row)) return null;
|
|
96
|
+
const t = bet.tiers.find((x) => (x.outcomes || []).some(([a, b]) => row >= a && row <= b));
|
|
97
|
+
return t ? Number(t.multBp) : 0;
|
|
98
|
+
}
|
|
74
99
|
|
|
75
100
|
/**
|
|
76
101
|
* The variants to play, in the order they are played: the whole matrix, or
|
|
@@ -103,6 +128,43 @@ export function variantsFor(set, now = new Date()) {
|
|
|
103
128
|
return [base, long, ...byClock(rest.filter((v) => v !== long), now)];
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
/**
|
|
132
|
+
* A caller's clocks for the dated variants (run.mjs --travel-clocks), checked:
|
|
133
|
+
* {id: {at, tz, locale}} for ids of the matrix that travel, an instant within
|
|
134
|
+
* three years of now (a report's clock replayed later is in the past), a zone
|
|
135
|
+
* this runtime knows, a language tag. Anything else
|
|
136
|
+
* throws: a run told to play at a clock it cannot must not quietly play another.
|
|
137
|
+
*/
|
|
138
|
+
export function travelClocks(text, now = new Date()) {
|
|
139
|
+
if (!text) return null;
|
|
140
|
+
const given = JSON.parse(text);
|
|
141
|
+
if (!given || typeof given !== 'object' || Array.isArray(given)) throw new Error('--travel-clocks: an object of variants');
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const [id, c] of Object.entries(given)) {
|
|
144
|
+
const v = VARIANTS.find((x) => x.id === id);
|
|
145
|
+
if (!v || v.clock === 'now') throw new Error(`--travel-clocks: ${id} is not a dated variant`);
|
|
146
|
+
const at = Date.parse(c && c.at);
|
|
147
|
+
if (!Number.isFinite(at) || Math.abs(at - now.getTime()) > 3 * 366 * DAY) throw new Error(`--travel-clocks: ${id}'s instant is not within three years of now`);
|
|
148
|
+
try { new Intl.DateTimeFormat('en', { timeZone: c.tz }); } catch { throw new Error(`--travel-clocks: ${id}'s zone ${c.tz} is not one`); }
|
|
149
|
+
if (!/^[a-z]{2,3}(-[A-Z]{2})?$/.test(String(c.locale))) throw new Error(`--travel-clocks: ${id}'s language ${c.locale} is not a tag`);
|
|
150
|
+
out[id] = { at: new Date(at).toISOString(), tz: c.tz, locale: c.locale };
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The variants with the caller's clocks in place of their own, the dated ones
|
|
157
|
+
* again in the order of their instants (each starts from the storage the one
|
|
158
|
+
* before it left). Those at the real time keep their place at the front.
|
|
159
|
+
*/
|
|
160
|
+
export function withClocks(variants, clocks, now = new Date()) {
|
|
161
|
+
if (!clocks) return variants;
|
|
162
|
+
const head = variants.filter((v) => v.clock === 'now');
|
|
163
|
+
const dated = variants.filter((v) => v.clock !== 'now').map((v) => (clocks[v.id] ? { ...v, ...clocks[v.id], given: true } : v));
|
|
164
|
+
const t = (v) => (v.at ? Date.parse(v.at) : clockFor(v, now).getTime());
|
|
165
|
+
return [...head, ...dated.map((v, i) => ({ v, i })).sort((a, b) => t(a.v) - t(b.v) || a.i - b.i).map((x) => x.v)];
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
/** The variants in the order of their clocks at `now`, the earliest first; two at one instant keep their order. */
|
|
107
169
|
const byClock = (variants, now) => variants.map((v, i) => ({ v, i, t: clockFor(v, now).getTime() })).sort((a, b) => a.t - b.t || a.i - b.i).map((x) => x.v);
|
|
108
170
|
|
|
@@ -812,6 +874,46 @@ export const trapOptions = (token, clock = null) => ({ token, shimSrc: String(cl
|
|
|
812
874
|
export const clockCommand = (token, op, id, ms) => `${token}:clock:${op}:${id}:${ms}`;
|
|
813
875
|
export const clockAnswer = (token, id) => `${token}:clockdone:${id}:`;
|
|
814
876
|
|
|
877
|
+
/**
|
|
878
|
+
* A `then` step's press that did not land (run.mjs thenSteps): `on`, its
|
|
879
|
+
* control is visible and enabled now; `covered`, Playwright's log of the click
|
|
880
|
+
* says something else took the pointer (a cover that later goes off with the
|
|
881
|
+
* round is still a cover); `missed`, the step's misses in a row before this
|
|
882
|
+
* one. A control that went off or away on its own while the click waited (the
|
|
883
|
+
* round outran the press, ticket #14) is let go and the count starts again;
|
|
884
|
+
* anything else is a miss, and `tries` misses in a row break the run.
|
|
885
|
+
*/
|
|
886
|
+
export function missedPress({ on, covered, missed = 0, tries = 1 }) {
|
|
887
|
+
if (!on && !covered) return { verdict: 'let go', missed: 0 };
|
|
888
|
+
const n = missed + 1;
|
|
889
|
+
return n >= tries ? { verdict: 'break', missed: 0 } : { verdict: 'again', missed: n };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* After a jump some window did not answer: the URLs of the silent windows when
|
|
894
|
+
* none of them is the game's, or null when one may be or nothing is known.
|
|
895
|
+
* `frames` ({url, game}) are the page's frames now, `answered` the hrefs the
|
|
896
|
+
* answers named (cut at 120, as clockTrap cuts them), `posted` how many
|
|
897
|
+
* windows the jump was sent to. Answers are counted per URL, so two frames
|
|
898
|
+
* of one URL are two; and the frames now must be the windows the jump went
|
|
899
|
+
* to (none came or went meanwhile), or nothing is said.
|
|
900
|
+
*/
|
|
901
|
+
export function checkerOnlySilent(frames, answered, posted) {
|
|
902
|
+
const list = frames || [];
|
|
903
|
+
if (!list.length || list.length !== posted) return null;
|
|
904
|
+
const left = new Map();
|
|
905
|
+
for (const a of answered || []) left.set(String(a), (left.get(String(a)) || 0) + 1);
|
|
906
|
+
const silent = [];
|
|
907
|
+
for (const f of list) {
|
|
908
|
+
const url = String(f.url).slice(0, 120), n = left.get(url) || 0;
|
|
909
|
+
if (n > 0) left.set(url, n - 1);
|
|
910
|
+
else silent.push(f);
|
|
911
|
+
}
|
|
912
|
+
// Two frames of one URL, one silent: either may be the one, so the game's counts.
|
|
913
|
+
const shared = (f) => list.some((g) => g !== f && g.game && String(g.url).slice(0, 120) === String(f.url).slice(0, 120));
|
|
914
|
+
return silent.length && silent.every((f) => !f.game && !shared(f)) ? silent.map((f) => String(f.url).slice(0, 80)) : null;
|
|
915
|
+
}
|
|
916
|
+
|
|
815
917
|
// ---- the long session's forced rows -----------------------------------------------------
|
|
816
918
|
|
|
817
919
|
/**
|
|
@@ -1065,6 +1167,10 @@ export const SHORT_VARIANT_MS = 60 * 1000;
|
|
|
1065
1167
|
export const SHORT_VARIANT_MIN_MS = 15 * 1000;
|
|
1066
1168
|
/** The least a long session is worth starting with. */
|
|
1067
1169
|
export const LONG_MIN_MS = 60 * 1000;
|
|
1170
|
+
/** The share of the matrix's time the long session keeps however slow the short variants are estimated. */
|
|
1171
|
+
export const LONG_SHARE = 0.25;
|
|
1172
|
+
/** What a long session needs past its rounds (the boot before them, the screenshots after). */
|
|
1173
|
+
const LONG_MARGIN_MS = 15 * 1000;
|
|
1068
1174
|
|
|
1069
1175
|
/**
|
|
1070
1176
|
* What a short variant is estimated to take, from the short variants played
|
|
@@ -1085,15 +1191,28 @@ export function shortVariantEstimate(variants) {
|
|
|
1085
1191
|
* Whether the next variant fits what is left of the matrix's real time
|
|
1086
1192
|
* (`totalMs`, none when 0), and how much of it a long session may take: the
|
|
1087
1193
|
* rest of the matrix, kept for the `after` variants after it at `perVariantMs`
|
|
1088
|
-
* each (shortVariantEstimate)
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1194
|
+
* each (shortVariantEstimate), and never less than its own share of the
|
|
1195
|
+
* matrix (LONG_SHARE) while that much is left. A variant that does not fit is
|
|
1196
|
+
* skipped and says so, instead of the whole matrix being killed at its
|
|
1197
|
+
* deadline with no report.
|
|
1198
|
+
*
|
|
1199
|
+
* The share is what a slow game's long session lives on. Reserving the
|
|
1200
|
+
* variants after it at half again the slowest one played left it nothing
|
|
1201
|
+
* once the baseline took 38 s or more (seven variants at 57 s and up, out of
|
|
1202
|
+
* at most 510 s), so the long session of a slow but valid game was always
|
|
1203
|
+
* skipped. With the share it keeps 97–128 s, and the dated variants at the
|
|
1204
|
+
* end that then no longer fit are skipped in its place (in a 510 s matrix
|
|
1205
|
+
* after a 48 s baseline, one of seven; none after a 38 s one). The long
|
|
1206
|
+
* session keeps its place right after the baseline (variantsFor): played
|
|
1207
|
+
* last, it would start from storage written at the dated variants' later
|
|
1208
|
+
* clocks, a clock that ran backwards.
|
|
1091
1209
|
*/
|
|
1092
1210
|
export function fitsBudget({ elapsedMs, totalMs, rounds, longBudgetMs, after = 0, perVariantMs = SHORT_VARIANT_MS }) {
|
|
1093
1211
|
if (!totalMs) return { fits: true, longBudgetMs };
|
|
1094
1212
|
const left = totalMs - elapsedMs;
|
|
1095
1213
|
if (!rounds) return { fits: left >= perVariantMs, longBudgetMs };
|
|
1096
|
-
const
|
|
1214
|
+
const share = Math.min(Math.round(totalMs * LONG_SHARE), left - LONG_MARGIN_MS);
|
|
1215
|
+
const room = Math.max(left - after * perVariantMs - LONG_MARGIN_MS, share);
|
|
1097
1216
|
if (room < LONG_MIN_MS) return { fits: false, longBudgetMs };
|
|
1098
1217
|
return { fits: true, longBudgetMs: Math.min(longBudgetMs, room) };
|
|
1099
1218
|
}
|
|
@@ -1169,6 +1288,7 @@ export function compareVariant(variant, baseline) {
|
|
|
1169
1288
|
if (variant.stuck || !variant.outOfTime) failures.push(`${variant.settled || 0} of ${variant.rounds} fast rounds settled${variant.stuck ? ': ' + variant.stuck : ''}`);
|
|
1170
1289
|
else warnings.push(`only ${variant.settled || 0} of ${variant.rounds} fast rounds fit the session's ${Math.round((variant.budgetMs || 0) / 1000)} s (this game's rounds are long): a trigger past round ${variant.settled || 0} is not seen`);
|
|
1171
1290
|
}
|
|
1291
|
+
if (variant.shotMissing) warnings.push(`the screenshot after the session could not be taken (${variant.shotMissing}): the game may have stopped drawing after many rounds, or the checker's renderer was too slow; play a long session in the sandbox`);
|
|
1172
1292
|
if (variant.rounds && variant.forced && variant.forcedAsked > 0 && (variant.forcedApplied || 0) < variant.forcedAsked * FORCED_HONOURED_SHARE) {
|
|
1173
1293
|
warnings.push(`the sandbox honoured ${variant.forcedApplied || 0} of ${variant.forcedAsked} forced rows${variant.forcedRefused ? ` (${variant.forcedRefused} refused)` : ''}: not every pay tier was shown`);
|
|
1174
1294
|
}
|
|
@@ -1185,8 +1305,14 @@ export function compareVariant(variant, baseline) {
|
|
|
1185
1305
|
const pct = (d) => (d.changed > STRONG_SHARE || !d.region ? `${Math.round(d.changed * 100)}%` : `a region of ${Math.round(d.region * 100)}%`);
|
|
1186
1306
|
|
|
1187
1307
|
/** The variant as one line of the report, for a person. */
|
|
1308
|
+
// What each dated variant stands for when an upload draws its instant (pipeline/sync/clocks.mjs):
|
|
1309
|
+
// the id names the fixed clock `hbengine test` plays, not the drawn one.
|
|
1310
|
+
const KINDS = { plus1d: 'a day or two on', plus7d: 'about a week on', plus30d: 'a month or two on', plus365d: 'one to two years on', saturday: 'a weekend day', dec25: 'a holiday', jan1: 'the first of a month' };
|
|
1311
|
+
|
|
1188
1312
|
export function describe(variant) {
|
|
1189
|
-
|
|
1313
|
+
// A clock the caller gave is named by its kind and its instant (what `--travel-clocks` replays),
|
|
1314
|
+
// so `jan1` at 1 March reads as the first of a month, not as a wrong 1 January.
|
|
1315
|
+
const when = variant.given ? `${KINDS[variant.id] ? KINDS[variant.id] + ', ' : ''}clock ${variant.at}` : variant.clock === 'now' ? 'real time' : `clock ${variant.clock === 'saturday' ? 'next Saturday' : variant.clock === 'dec25' ? 'next 25 December' : variant.clock === 'jan1' ? 'next 1 January' : variant.clock}`;
|
|
1190
1316
|
return `${when}, ${variant.tz}, ${variant.locale}${variant.rounds ? `, ${variant.rounds} fast rounds` : ''}`;
|
|
1191
1317
|
}
|
|
1192
1318
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Every number here is an integer: weights are BigInt, RTP is a fraction.
|
|
12
12
|
*/
|
|
13
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
|
|
16
16
|
const MAX_SUM = 1n << 63n;
|
|
@@ -121,16 +121,55 @@ function ranges(ids) {
|
|
|
121
121
|
return out;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// The verifier's bound on one bet's pay tiers (housegames-backend mathpack.MaxTiers).
|
|
125
|
+
export const MAX_TIERS = 16384;
|
|
126
|
+
|
|
127
|
+
// The most a pack's maths may be: the files in math/ together, as their bytes
|
|
128
|
+
// on disk. Housebets' limit, not a verifier's: the live engine holds a game's
|
|
129
|
+
// tables in memory, and the upload's checks slow as they grow. Checked here,
|
|
130
|
+
// by hbengine before it sends a pack, and by the upload's own check
|
|
131
|
+
// (pipeline/job), so no way in passes a bigger one. The Go side's figure is
|
|
132
|
+
// internal/archive MaxMathBytes (pack.test.mjs holds the two together).
|
|
133
|
+
export const MAX_MATH_BYTES = 200 * 1024 * 1024;
|
|
134
|
+
|
|
135
|
+
// The refusal of maths over MAX_MATH_BYTES, in the same words wherever it is
|
|
136
|
+
// checked; the size is rounded up, so maths just over never reads as 200 MB.
|
|
137
|
+
export const mathTooLarge = (bytes) => `The maths is ${Math.ceil((bytes * 10) / 1048576) / 10} MB; a pack's maths may be at most ${MAX_MATH_BYTES / 1048576} MB.`;
|
|
138
|
+
|
|
139
|
+
// The bytes of the maths in dir: its files together (math/ holds nothing below them).
|
|
140
|
+
export const mathBytes = (dir) => readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).reduce((n, e) => n + statSync(join(dir, e.name)).size, 0);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What a meter adds to the RTP of every bet of its modes, exactly (GUIDE:
|
|
144
|
+
* Meters): E[award] ÷ fill, in basis points of the stake. `rows` are the
|
|
145
|
+
* award's outcomes, `pay` the multiplier of the reference in bp.
|
|
146
|
+
*/
|
|
147
|
+
export function meterRtp({ fill, rows }) {
|
|
148
|
+
const den = rows.reduce((s, r) => s + r.weight, 0n) * BigInt(fill);
|
|
149
|
+
const num = rows.reduce((s, r) => s + r.weight * BigInt(r.pay || 0), 0n);
|
|
150
|
+
return { num, den, bp: bp4(num, den) };
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
/**
|
|
125
154
|
* Writes manifest.json and one table per mode. A mode is { id, label?, rows,
|
|
126
|
-
* bets: [id | {id, label}] }; its rows are sorted by what the first bet pays,
|
|
155
|
+
* bets: [id | {id, label}], meter? }; its rows are sorted by what the first bet pays,
|
|
127
156
|
* so each pay tier is one range. Prints every bet's exact RTP, hit rate and
|
|
128
157
|
* largest multiplier; returns them.
|
|
158
|
+
*
|
|
159
|
+
* meters: [{ id, label?, fill, rows }] — progress that pays (GUIDE: Meters).
|
|
160
|
+
* A meter's rows are its award's outcomes, each { weight, pay, data? } with
|
|
161
|
+
* `pay` in bp of the meter's reference (20000 = twice the reference); a mode
|
|
162
|
+
* names the meter its rounds fill with `meter: id`. Every bet of such a mode
|
|
163
|
+
* reports base + meter RTP, and the base must clear 90% on its own.
|
|
129
164
|
*/
|
|
130
|
-
export function writePack(dir, { game, rtpBp, modes }) {
|
|
131
|
-
|
|
165
|
+
export function writePack(dir, { game, rtpBp, modes, meters = [] }) {
|
|
166
|
+
if (!Array.isArray(modes) || modes.length < 1 || modes.length > 64) throw new Error('a pack needs 1 to 64 modes');
|
|
167
|
+
for (const mode of modes) {
|
|
168
|
+
if (!Array.isArray(mode.bets) || mode.bets.length < 1 || mode.bets.length > 256) throw new Error(`${mode.id}: 1 to 256 bets per mode`);
|
|
169
|
+
}
|
|
132
170
|
const manifest = { format: 'house-engine-math/1', game, rtpBp, modes: [] };
|
|
133
171
|
const report = [];
|
|
172
|
+
const tables = new Map();
|
|
134
173
|
for (const mode of modes) {
|
|
135
174
|
const bets = mode.bets.map((b) => (typeof b === 'string' ? { id: b } : b));
|
|
136
175
|
const first = bets[0].id;
|
|
@@ -141,25 +180,63 @@ export function writePack(dir, { game, rtpBp, modes }) {
|
|
|
141
180
|
const lines = rows.map((r) => {
|
|
142
181
|
if (r.weight <= 0n) throw new Error(`${mode.id}: a weight is not positive`);
|
|
143
182
|
const json = r.data === undefined ? '' : JSON.stringify(r.data);
|
|
144
|
-
if (json
|
|
183
|
+
if (Buffer.byteLength(json) > 4096) throw new Error(`${mode.id}: a row's data is over 4096 UTF-8 bytes`);
|
|
145
184
|
return json ? `${r.weight}\t${json}` : String(r.weight);
|
|
146
185
|
});
|
|
147
186
|
const table = `${game}-${mode.id}.tsv`;
|
|
148
|
-
|
|
187
|
+
tables.set(table, lines.join('\n') + '\n');
|
|
149
188
|
manifest.modes.push({
|
|
150
|
-
id: mode.id, ...(mode.label ? { label: mode.label } : {}), kind: 'table', table,
|
|
189
|
+
id: mode.id, ...(mode.label ? { label: mode.label } : {}), kind: 'table', table, ...(mode.meter ? { meter: mode.meter } : {}),
|
|
151
190
|
bets: bets.map((b) => {
|
|
152
191
|
const tiers = new Map();
|
|
153
192
|
rows.forEach((r, i) => { const m = r.pay[b.id] || 0; if (m > 0) (tiers.get(m) || tiers.set(m, []).get(m)).push(i); });
|
|
154
193
|
const { num, den, bp } = rtp(rows, b.id);
|
|
155
194
|
const hit = rows.reduce((s, r) => s + (r.pay[b.id] > 0 ? r.weight : 0n), 0n);
|
|
195
|
+
// The engine's verifier refuses a bet with more pay tiers than this (one tier per distinct
|
|
196
|
+
// multiplier): refused here, at the build, not at the upload (mathpack.MaxTiers).
|
|
197
|
+
if (tiers.size > MAX_TIERS) throw new Error(`${mode.id}: bet "${b.id}": ${tiers.size} distinct payouts (tiers), at most ${MAX_TIERS}: round the multipliers to fewer values`);
|
|
156
198
|
const max = Math.max(...tiers.keys());
|
|
157
|
-
|
|
199
|
+
const entry = { mode: mode.id, bet: b.id, rtp: bp, hit: bp4(hit * 10000n, den), max: max / 10000, rows: rows.length, exact: num % den === 0n };
|
|
200
|
+
if (mode.meter) {
|
|
201
|
+
const mt = meters.find((x) => x.id === mode.meter);
|
|
202
|
+
if (!mt) throw new Error(`${mode.id}: meter "${mode.meter}" is not in meters`);
|
|
203
|
+
const add = meterRtp(mt);
|
|
204
|
+
// base + E[award]/fill, as one exact fraction
|
|
205
|
+
const tn = num * add.den + add.num * den, td = den * add.den;
|
|
206
|
+
Object.assign(entry, { baseRtp: bp, meter: mode.meter, rtp: bp4(tn, td), exact: tn % td === 0n });
|
|
207
|
+
}
|
|
208
|
+
report.push(entry);
|
|
158
209
|
return { id: b.id, ...(b.label ? { label: b.label } : {}), tiers: [...tiers].map(([multBp, ids]) => ({ outcomes: ranges(ids), multBp })) };
|
|
159
210
|
}),
|
|
160
211
|
});
|
|
161
212
|
}
|
|
162
|
-
|
|
163
|
-
|
|
213
|
+
for (const mt of meters) {
|
|
214
|
+
if (!Number.isSafeInteger(mt.fill) || mt.fill < 2 || mt.fill > 1_000_000) throw new Error(`meter ${mt.id}: fill must be 2 to 1,000,000`);
|
|
215
|
+
if (!modes.some((m) => m.meter === mt.id)) throw new Error(`meter ${mt.id}: no mode fills it`);
|
|
216
|
+
const rows = [...mt.rows].sort((a, b) => (a.pay || 0) - (b.pay || 0));
|
|
217
|
+
if (rows.length < 1) throw new Error(`meter ${mt.id}: an award needs rows`);
|
|
218
|
+
const table = `${game}-meter-${mt.id}.tsv`;
|
|
219
|
+
tables.set(table, rows.map((r) => {
|
|
220
|
+
if (r.weight <= 0n) throw new Error(`meter ${mt.id}: a weight is not positive`);
|
|
221
|
+
const json = r.data === undefined ? '' : JSON.stringify(r.data);
|
|
222
|
+
return json ? `${r.weight}\t${json}` : String(r.weight);
|
|
223
|
+
}).join('\n') + '\n');
|
|
224
|
+
const tiers = new Map();
|
|
225
|
+
rows.forEach((r, i) => { const m = r.pay || 0; if (m > 0) (tiers.get(m) || tiers.set(m, []).get(m)).push(i); });
|
|
226
|
+
if (!tiers.size) throw new Error(`meter ${mt.id}: the award never pays`);
|
|
227
|
+
(manifest.meters ||= []).push({ id: mt.id, ...(mt.label ? { label: mt.label } : {}), fill: mt.fill, table,
|
|
228
|
+
award: [...tiers].map(([multBp, ids]) => ({ outcomes: ranges(ids), multBp })) });
|
|
229
|
+
const ev = meterRtp({ fill: 1, rows });
|
|
230
|
+
console.log(`meter ${mt.id}: fill ${mt.fill} award E ${bp4(ev.num, ev.den * 10000n)}× the reference adds ${meterRtp({ fill: mt.fill, rows }).bp} bp`);
|
|
231
|
+
}
|
|
232
|
+
tables.set('manifest.json', JSON.stringify(manifest) + '\n');
|
|
233
|
+
// The pack as it will be on disk, with what else is in dir already (the
|
|
234
|
+
// generator, often), measured before a file is written.
|
|
235
|
+
mkdirSync(dir, { recursive: true });
|
|
236
|
+
const others = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && !tables.has(e.name));
|
|
237
|
+
const bytes = [...tables.values()].reduce((n, text) => n + Buffer.byteLength(text), 0) + others.reduce((n, e) => n + statSync(join(dir, e.name)).size, 0);
|
|
238
|
+
if (bytes > MAX_MATH_BYTES) throw new Error(mathTooLarge(bytes));
|
|
239
|
+
for (const [name, text] of tables) writeFileSync(join(dir, name), text);
|
|
240
|
+
for (const r of report) console.log(`${r.mode}/${r.bet}: RTP ${r.rtp} bp${r.meter ? ` (base ${r.baseRtp} + meter ${r.meter})` : ''} hit ${r.hit} bp max ${r.max}× rows ${r.rows}`);
|
|
164
241
|
return report;
|
|
165
242
|
}
|