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.
Files changed (72) hide show
  1. package/engine-cli/hbengine.mjs +106 -36
  2. package/engine-cli/outage.mjs +9 -0
  3. package/engine-cli/templates.mjs +1 -0
  4. package/engine-cli/upload-errors.mjs +18 -0
  5. package/engine-cli/upload.mjs +172 -0
  6. package/engine-conformance/analyse.mjs +155 -15
  7. package/engine-conformance/bundle.mjs +23 -5
  8. package/engine-conformance/census-worker.mjs +9 -0
  9. package/engine-conformance/chrome-flags.mjs +19 -0
  10. package/engine-conformance/diagnostic-text.mjs +19 -0
  11. package/engine-conformance/diagnostics.mjs +52 -0
  12. package/engine-conformance/focus.mjs +42 -0
  13. package/engine-conformance/hints.mjs +86 -0
  14. package/engine-conformance/js-releases.json +2365 -0
  15. package/engine-conformance/js-releases.mjs +75 -0
  16. package/engine-conformance/run.mjs +226 -39
  17. package/engine-conformance/scan.mjs +71 -18
  18. package/engine-conformance/sound.mjs +45 -0
  19. package/engine-conformance/travel.mjs +132 -6
  20. package/engine-examples/keno/math-kit/pack.mjs +87 -10
  21. package/engine-examples/lines/math-kit/pack.mjs +87 -10
  22. package/engine-examples/plinko/math-kit/pack.mjs +87 -10
  23. package/engine-examples/plinko-jar/AGENTS.md +3 -0
  24. package/engine-examples/plinko-jar/README.md +16 -0
  25. package/engine-examples/plinko-jar/bundle/assets.json +1 -0
  26. package/engine-examples/plinko-jar/bundle/game.css +10 -0
  27. package/engine-examples/plinko-jar/bundle/game.js +113 -0
  28. package/engine-examples/plinko-jar/bundle/hbtest.json +4 -0
  29. package/engine-examples/plinko-jar/bundle/index.html +16 -0
  30. package/engine-examples/plinko-jar/bundle/model.json +1 -0
  31. package/engine-examples/plinko-jar/math/gen.mjs +47 -0
  32. package/engine-examples/plinko-jar/math/manifest.json +1 -0
  33. package/engine-examples/plinko-jar/math/plinko-jar-12-high.tsv +13 -0
  34. package/engine-examples/plinko-jar/math/plinko-jar-12-low.tsv +13 -0
  35. package/engine-examples/plinko-jar/math/plinko-jar-12-medium.tsv +13 -0
  36. package/engine-examples/plinko-jar/math/plinko-jar-16-high.tsv +17 -0
  37. package/engine-examples/plinko-jar/math/plinko-jar-16-low.tsv +17 -0
  38. package/engine-examples/plinko-jar/math/plinko-jar-16-medium.tsv +17 -0
  39. package/engine-examples/plinko-jar/math/plinko-jar-8-high.tsv +9 -0
  40. package/engine-examples/plinko-jar/math/plinko-jar-8-low.tsv +9 -0
  41. package/engine-examples/plinko-jar/math/plinko-jar-8-medium.tsv +9 -0
  42. package/engine-examples/plinko-jar/math/plinko-jar-meter-jar.tsv +5 -0
  43. package/engine-examples/plinko-jar/math-kit/pack.mjs +242 -0
  44. package/engine-examples/plinko-jar/math-kit/package.json +8 -0
  45. package/engine-examples/plinko-jar/math-kit/slot.mjs +94 -0
  46. package/engine-examples/plinko-jar/math-kit/tumble.mjs +94 -0
  47. package/engine-examples/tumble/math-kit/pack.mjs +87 -10
  48. package/engine-math/pack.mjs +87 -10
  49. package/engine-mcp/server.mjs +30 -14
  50. package/engine-sdk/GUIDE.md +234 -32
  51. package/engine-sdk/bet-field.js +16 -3
  52. package/engine-sdk/bet.js +88 -0
  53. package/engine-sdk/frames.js +75 -0
  54. package/engine-sdk/progress.js +128 -0
  55. package/engine-sdk/protocol.js +3 -0
  56. package/engine-sdk/sdk.d.ts +35 -0
  57. package/engine-sdk/sdk.js +9 -0
  58. package/engine-sdk/ui.js +3 -0
  59. package/engine-shell/core/rgs.js +1 -0
  60. package/engine-shell/core/router.js +46 -54
  61. package/engine-shell/host/audio-session.js +24 -0
  62. package/engine-shell/host/boot.js +5 -2
  63. package/engine-shell/host/engine-v1.js +17 -4
  64. package/engine-shell/host/fair.js +27 -0
  65. package/engine-shell/host/index.html +44 -9
  66. package/engine-shell/host/intro.js +99 -0
  67. package/engine-shell/host/page.js +25 -5
  68. package/engine-shell/host/sandbox-panel.js +23 -3
  69. package/engine-shell/host/sandbox-scenarios.js +23 -11
  70. package/engine-shell/host/site-bridge.js +4 -2
  71. package/engine-shell/verifiers/engine-verify.js +12 -3
  72. 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 { join, relative, extname, posix } from 'node:path';
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/ that we know by name. A file named
103
- // like one, carrying the banner its releases carry in the first 4 KB, is read
104
- // as that library: what the analyser would refuse in it is named for the
105
- // reviewer instead (the browser's policy blocks it at run time, and the run
106
- // proves the game works without it), and its being minified is expected.
107
- // Anything else under vendor/ is the game's own code to the scan. A studio
108
- // with another library asks us to add it here.
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
- const URL_RE = /(?<![\w.#-])(?:https?|wss?|stuns?|turns?):\/?\/?[^\s"'`)<>]+/g;
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 its name and the banner in its
222
- // head; null for anything else.
223
- export function recognisedLibrary(path, head) {
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
- try { analysed = await analyseFiles(code.map((f) => ({ path: f.path, abs: f.abs, kind: kind(f) })), analyseTimeout); }
386
- catch (e) { add('fail', 'analyser', `${RULES.analyser.text}: ${e.message}`); }
387
- const library = new Map(code.map((f) => [f.path, recognisedLibrary(f.path, f.path.startsWith('vendor/') ? head(f.abs) : '')]));
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 === `file://${process.argv[1]}`) {
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
- export const shotNames = (variants) => variants.flatMap((v) => (v.rounds ? [shotName(v.id)] : [shotName(v.id), lateShotName(v.id)]));
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). A variant that does not fit is skipped and
1089
- * says so, instead of the whole matrix being killed at its deadline with no
1090
- * report.
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 room = left - after * perVariantMs - 15 * 1000;
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
- const when = 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}`;
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
- mkdirSync(dir, { recursive: true });
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.length > 4096) throw new Error(`${mode.id}: a row's data is over 4096 bytes`);
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
- writeFileSync(join(dir, table), lines.join('\n') + '\n');
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
- report.push({ mode: mode.id, bet: b.id, rtp: bp, hit: bp4(hit * 10000n, den), max: max / 10000, rows: rows.length, exact: num % den === 0n });
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
- writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest) + '\n');
163
- for (const r of report) console.log(`${r.mode}/${r.bet}: RTP ${r.rtp} bp hit ${r.hit} bp max ${r.max}× rows ${r.rows}`);
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
  }