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
@@ -15,17 +15,40 @@
15
15
  @font-face { font-family: 'Red Hat Display'; font-style: normal; font-weight: 700; font-display: swap; src: url(./fonts/red-hat-display-700.woff2) format('woff2'); }
16
16
  :root { color-scheme: dark; --bg:#041015; --s1:#0B1920; --s3:#0D2A38; --b:#0D2531; --b2:#113A4C; --t1:#F9F9FB; --t2:#8F96A8; --brand:#02F8F8; --brand6:#0AD4D4; --primary:#C4F4F1; --ok:#47D496; --err:#F41F50; }
17
17
  * { box-sizing: border-box; }
18
- html, body { margin: 0; height: 100%; background: var(--bg); color: var(--t1); font: 400 14px/20px Figtree, system-ui, sans-serif; touch-action: pan-x pan-y; overscroll-behavior: none; }
19
- #stage { position: relative; height: 100%; min-height: 560px; }
18
+ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--t1); font: 400 14px/20px Figtree, system-ui, sans-serif; touch-action: pan-x pan-y; overscroll-behavior: none; }
19
+ /* The stage is exactly the frame the site gives the shell, at any size, and never
20
+ scrolls: the game gets that room and lays itself out in it (docs/GAME-GUIDE.md). */
21
+ #stage { position: relative; height: 100%; overflow: hidden; }
20
22
  #game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--bg); }
21
23
  .over { position: absolute; inset: 0; display: grid; place-items: center; background: var(--bg); }
22
24
  .over[hidden] { display: none; }
23
- .loader { display: grid; gap: 14px; justify-items: center; width: min(320px, 80vw); }
24
- .h { width: 44px; height: 44px; border-radius: 10px; background: var(--s1); border: 1px solid var(--b2); display: grid; place-items: center; font: 700 26px/1 'Red Hat Display', system-ui, sans-serif; color: var(--brand); animation: pulse 1.4s ease-in-out infinite; }
25
- @keyframes pulse { 50% { opacity: .55; } }
26
- @media (prefers-reduced-motion: reduce) { .h { animation: none; } }
27
- .bar { width: 100%; height: 4px; border-radius: 999px; background: var(--s1); overflow: hidden; }
28
- .bar i { display: block; height: 100%; width: 0; background: var(--brand6); transition: width .2s; }
25
+ /* The loader (intro.js), Dion's entry screen: House Studio's six white squares snap
26
+ together from scattered, the cyan centre pops in last, the wordmark comes up.
27
+ A square takes 450 ms from its --d and is in place at 40% of it (intro.js LANDS);
28
+ the centre takes 400 ms from 420 ms and is in place at 28% (CENTRE_LANDS). */
29
+ .intro { display: grid; justify-items: center; gap: 18px; }
30
+ .intro svg { width: 76px; height: 76px; overflow: visible; }
31
+ .intro .t { fill: var(--t1); opacity: 0; transform-box: fill-box; transform-origin: center; }
32
+ .intro .c { fill: var(--brand); opacity: 0; transform-box: fill-box; transform-origin: center; }
33
+ .intro-go .t { animation: tile-in 450ms cubic-bezier(.2,.9,.3,1.15) var(--d) both; }
34
+ .intro-go .c { animation: centre-in 400ms cubic-bezier(.3,1.6,.5,1) 420ms both, breathe 2.2s ease-in-out 1.4s infinite; }
35
+ @keyframes tile-in { from { opacity: 0; transform: translate(var(--x), var(--y)) rotate(var(--r)) scale(.5); } to { opacity: 1; transform: none; } }
36
+ @keyframes centre-in { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: none; } }
37
+ @keyframes breathe { 0%, 100% { opacity: 1; } 50% { opacity: .62; } }
38
+ .intro .word { font: 700 12px/16px 'Red Hat Display', system-ui, sans-serif; letter-spacing: .34em; margin-right: -.34em; color: var(--t2); opacity: 0; }
39
+ .intro-go .word { animation: fade 350ms ease 550ms both; }
40
+ @keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
41
+ .intro .bar { width: 132px; height: 2px; border-radius: 999px; background: var(--s3); overflow: hidden; opacity: 0; }
42
+ .intro-go .bar { animation: fade 300ms ease-out 700ms both; }
43
+ .intro .bar i { display: block; height: 100%; background: var(--brand6); transform: scaleX(0); transform-origin: left; transition: transform .25s ease-out; }
44
+ .intro-out { transition: opacity 280ms ease-in; opacity: 0; }
45
+ .intro-out svg { transition: transform 280ms ease-in; transform: scale(1.06); }
46
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
47
+ @media (max-width: 480px) { .intro svg { width: 60px; height: 60px; } }
48
+ @media (prefers-reduced-motion: reduce) {
49
+ .intro-go .t, .intro-go .c, .intro-go .word, .intro-go .bar { animation: none; opacity: 1; transform: none; }
50
+ .intro-out, .intro-out svg { transition: none; }
51
+ }
29
52
  .muted { color: var(--t2); font-size: 12px; line-height: 18px; }
30
53
  .panel { background: var(--s1); border: 1px solid var(--b); border-radius: 12px; padding: 20px; max-width: 420px; display: grid; gap: 10px; }
31
54
  .panel h2 { margin: 0; font: 700 18px/24px 'Red Hat Display', system-ui, sans-serif; }
@@ -167,7 +190,19 @@
167
190
  <body>
168
191
  <div id="stage">
169
192
  <iframe id="game" title="game" sandbox="allow-scripts allow-same-origin" allow="autoplay" referrerpolicy="no-referrer" hidden></iframe>
170
- <div class="over" id="loading"><div class="loader"><div class="h">H</div><div class="bar"><i id="progress"></i></div><div class="muted" id="loading-text">Starting</div></div></div>
193
+ <div class="over" id="loading"><div class="intro">
194
+ <svg viewBox="0 0 32 32" aria-hidden="true">
195
+ <rect class="t" style="--d:0ms;--x:-26px;--y:-34px;--r:-50deg" x="3" y="3" width="8" height="8" rx="1.5"/>
196
+ <rect class="t" style="--d:30ms;--x:30px;--y:-38px;--r:40deg" x="21" y="3" width="8" height="8" rx="1.5"/>
197
+ <rect class="t" style="--d:60ms;--x:-46px;--y:4px;--r:35deg" x="3" y="12" width="8" height="8" rx="1.5"/>
198
+ <rect class="t" style="--d:90ms;--x:48px;--y:-2px;--r:-35deg" x="21" y="12" width="8" height="8" rx="1.5"/>
199
+ <rect class="t" style="--d:120ms;--x:-22px;--y:40px;--r:-25deg" x="3" y="21" width="8" height="8" rx="1.5"/>
200
+ <rect class="t" style="--d:150ms;--x:24px;--y:36px;--r:55deg" x="21" y="21" width="8" height="8" rx="1.5"/>
201
+ <rect class="c" x="12" y="12" width="8" height="8" rx="1.5"/>
202
+ </svg>
203
+ <div class="word" aria-hidden="true">HOUSE STUDIO</div>
204
+ <div class="bar" role="progressbar" aria-label="Loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><i data-intro-bar></i></div>
205
+ </div></div>
171
206
  <div class="over" id="failed" hidden><div class="panel"><h2>Game unavailable</h2><p id="failed-text"></p></div></div>
172
207
  <div class="auto" id="auto" hidden><span id="auto-text" role="status" aria-live="polite"></span><button type="button" class="auto-stop" id="auto-stop">Stop</button></div>
173
208
  </div>
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The loader every engine game opens with, Dion's entry screen: House Studio's
3
+ * six white squares snap together from scattered, the cyan centre pops in
4
+ * last, the wordmark comes up, and a hairline follows the game's asset groups.
5
+ * It comes to rest; only the centre tile breathes while a slow game loads.
6
+ * No words: the progress is announced to screen readers only.
7
+ *
8
+ * Sound: a low marimba note as each square lands and a warm bowl chord on the
9
+ * centre (C3–A3, a quiet octave above for phone speakers), synthesised here,
10
+ * no files. Each note peaks on the frame its tile lands. It plays only when
11
+ * the browser lets audio start without asking (the site's press carries into
12
+ * the frame through allow="autoplay") and the player has not turned the
13
+ * site's speaker off (the shell counts the bar's sound presses).
14
+ */
15
+
16
+ // When each square lands, in ms from the start: index.html's CSS (--d apart by
17
+ // STEP, a 450 ms snap whose easing puts it in place at 40%) and these must agree.
18
+ const STEP = 30, FALL = 450, IMPACT = 0.4;
19
+ const ORDER = ['tl', 'tr', 'ml', 'mr', 'bl', 'br'];
20
+ export const LANDS = ORDER.map((_, i) => i * STEP + FALL * IMPACT);
21
+ // The centre's pop overshoots: its easing reaches full size at 28% of 400 ms.
22
+ export const CENTRE_DELAY = 420, CENTRE_FALL = 400, CENTRE_IMPACT = 0.28;
23
+ export const CENTRE_LANDS = CENTRE_DELAY + CENTRE_FALL * CENTRE_IMPACT;
24
+ // The game is never held back longer than this for the mark to finish.
25
+ export const MIN_SHOWN = 1100;
26
+ export const EXIT = 280;
27
+
28
+ const NOTES = [130.81, 146.83, 164.81, 174.61, 196.0, 220.0]; // C3 D3 E3 F3 G3 A3
29
+ const CHORD = [130.81, 196.0, 329.63]; // C3 G3 E4
30
+
31
+ const SOUND_KEY = 'hb-shell-sound';
32
+ export const soundOn = (store) => { try { return !store || store.getItem(SOUND_KEY) !== 'off'; } catch { return true; } };
33
+ /** The site's bar sent `sound`: it flips the speaker, the games and this loader with it. */
34
+ export const toggleSound = (store = storage()) => { if (!store) return; try { store.setItem(SOUND_KEY, soundOn(store) ? 'off' : 'on'); } catch { /* no storage: stays on */ } };
35
+
36
+ /** A struck note: a sine with a fast attack and an exponential tail, plus a quiet octave. */
37
+ function strike(ctx, out, at, freq, { gain = 0.05, tail = 0.35, octave = 0.35 } = {}) {
38
+ for (const [f, g] of [[freq, gain], [freq * 2, gain * octave], [freq * 4, gain * 0.08]]) {
39
+ const o = ctx.createOscillator(), v = ctx.createGain();
40
+ o.type = 'sine';
41
+ o.frequency.value = f;
42
+ v.gain.setValueAtTime(0.0001, at - 0.006);
43
+ v.gain.exponentialRampToValueAtTime(g, at); // the peak, on the landing frame
44
+ v.gain.exponentialRampToValueAtTime(0.0001, at + tail * (f === freq ? 1 : 0.6));
45
+ o.connect(v).connect(out);
46
+ o.start(at - 0.006);
47
+ o.stop(at + tail + 0.05);
48
+ }
49
+ }
50
+
51
+ /** The intro's sound, scheduled against the moment the animation started. */
52
+ export function playIntro(ctx, startedAt) {
53
+ const out = ctx.createGain();
54
+ out.gain.value = 0.9;
55
+ out.connect(ctx.destination);
56
+ // The animation's start on the audio clock; a note whose tile already landed is left out.
57
+ const t0 = ctx.currentTime - (performance.now() - startedAt) / 1000;
58
+ const due = (ms) => t0 + ms / 1000 > ctx.currentTime + 0.012;
59
+ LANDS.forEach((ms, i) => { if (due(ms)) strike(ctx, out, t0 + ms / 1000, NOTES[i], { gain: 0.028 + i * 0.002, tail: 0.32 }); });
60
+ if (due(CENTRE_LANDS)) CHORD.forEach((f, i) => strike(ctx, out, t0 + CENTRE_LANDS / 1000, f, { gain: [0.07, 0.05, 0.022][i], tail: 1.9, octave: 0.3 }));
61
+ }
62
+
63
+ const storage = () => { try { return globalThis.localStorage || null; } catch { return null; } }; // a sandboxed frame may refuse it
64
+
65
+ export function intro(root, { store = storage(), now = () => performance.now(), Audio = globalThis.AudioContext || globalThis.webkitAudioContext } = {}) {
66
+ const started = now();
67
+ const bar = root.querySelector?.('[data-intro-bar]');
68
+ const meter = root.querySelector?.('[role="progressbar"]');
69
+ let done = null;
70
+ if (Audio && soundOn(store)) {
71
+ try {
72
+ const ctx = new Audio();
73
+ // Never a prompt: if the browser holds audio back, the loader is silent.
74
+ Promise.resolve(ctx.resume && ctx.resume()).then(() => {
75
+ if (ctx.state === 'running') playIntro(ctx, started);
76
+ setTimeout(() => ctx.close && ctx.close(), CENTRE_LANDS + 2500);
77
+ }, () => {});
78
+ } catch { /* no audio here */ }
79
+ }
80
+ root.classList?.add('intro-go');
81
+ return {
82
+ progress(p) {
83
+ const pct = Math.max(0, Math.min(100, Math.round(p)));
84
+ if (bar) bar.style.transform = `scaleX(${pct / 100})`;
85
+ if (meter) meter.setAttribute('aria-valuenow', String(pct));
86
+ },
87
+ /** The game is ready: the mark finishes (at most MIN_SHOWN from the start), then the loader fades. */
88
+ finish() {
89
+ if (done) return done;
90
+ if (bar) bar.style.transform = 'scaleX(1)';
91
+ const wait = Math.max(0, MIN_SHOWN - (now() - started));
92
+ done = new Promise((resolve) => setTimeout(() => {
93
+ root.classList?.add('intro-out');
94
+ setTimeout(() => { root.hidden = true; resolve(); }, EXIT);
95
+ }, wait));
96
+ return done;
97
+ },
98
+ };
99
+ }
@@ -5,9 +5,24 @@ import { API, boot } from './boot.js';
5
5
  import { CLIENT_SEED_MAX, betFor, chars, compare, drawLines, expectation, outcome, rotationDifferences, roundLine, withRevealed } from './fair.js';
6
6
  import { loadPack, play, sha256Hex } from './engine-v1.js';
7
7
  import { STRINGS, fill, wordsFor } from './strings.js';
8
+ import { createAudioSession } from './audio-session.js';
9
+ import { intro, toggleSound, soundOn } from './intro.js';
8
10
  const $ = (id) => document.getElementById(id);
9
11
  const frame = $('game');
10
12
  const groups = new Map();
13
+ // The loader starts with the page: the mark builds while the game's first assets come in.
14
+ const loader = intro($('loading'));
15
+ const audioSession = createAudioSession();
16
+ let soundEnabled = true;
17
+ try { soundEnabled = soundOn(globalThis.localStorage); } catch { /* storage may be blocked */ }
18
+ // Release the page-wide override on departure or backgrounding. A bfcache
19
+ // return may restore only an explicit sound-on choice from this page.
20
+ window.addEventListener?.('pagehide', () => audioSession.suspend());
21
+ window.addEventListener?.('pageshow', () => audioSession.resume());
22
+ document.addEventListener('visibilitychange', () => {
23
+ if (document.visibilityState === 'hidden') audioSession.suspend();
24
+ else audioSession.resume();
25
+ });
11
26
  // How long after the autoplay confirmation opens its Start stays dead. The
12
27
  // game chose the moment the dialog appeared and knows where Start renders, so
13
28
  // a player already tapping Spin where Start will be must not land the next tap
@@ -521,7 +536,7 @@ const fair = {
521
536
  const modeId = this.modeId(pack);
522
537
  const bet = betFor(pack, modeId, receipt);
523
538
  if (!bet.id) throw new Error('bet');
524
- const got = await play(pack, modeId, bet.id, server, client, nonce);
539
+ const got = await play(pack, modeId, bet.id, server, client, nonce, { award: !!(receipt && receipt.mode === modeId && receipt.meter && receipt.meter.award) });
525
540
  if (check !== this.check) return;
526
541
  const round = receipt && receipt.mode === modeId ? receipt : null;
527
542
  let text = w.computedResult + ': ' + outcome(pack, got, round, bet.known, w);
@@ -589,13 +604,18 @@ const ui = {
589
604
  loading(group, loaded, total) {
590
605
  groups.set(group, [loaded, total]);
591
606
  let l = 0, t = 0; for (const [a, b] of groups.values()) { l += a; t += b; }
592
- $('progress').style.width = (t ? Math.round(l / t * 100) : 0) + '%';
593
- $('loading-text').textContent = 'Loading ' + group;
607
+ loader.progress(t ? l / t * 100 : 0);
608
+ },
609
+ ready() { loader.finish(); },
610
+ /** The site's speaker was pressed: the loader stays silent next time if it is now off. */
611
+ soundPressed() {
612
+ soundEnabled = !soundEnabled;
613
+ toggleSound();
614
+ audioSession.setEnabled(soundEnabled);
594
615
  },
595
- ready() { $('loading').hidden = true; },
596
616
  started(info) { window.__engine = info; },
597
617
  round(r) { window.__rounds = (window.__rounds || []).concat(r); },
598
- fail(message, err) { if (err) console.warn(err); $('loading').hidden = true; $('failed-text').textContent = message; $('failed').hidden = false; },
618
+ fail(message, err) { audioSession.release(); if (err) console.warn(err); $('loading').hidden = true; $('failed-text').textContent = message; $('failed').hidden = false; },
599
619
  /** The player's language: the dialog's words. */
600
620
  locale(lang) { fair.setLocale(lang); },
601
621
  // The player has played this long: the game is paused until they say
@@ -248,7 +248,8 @@ export function createSandboxPanel({ rgs, router, faults, api, slug, mathHash, f
248
248
  let observationSequence = 0;
249
249
  let scenarioStartSequence = 0;
250
250
  let scenarioStartAt = 0;
251
- if (typeof router.observe === 'function') router.observe((observation) => {
251
+ let gameRefusalWaitActive = false;
252
+ const dispatchObservation = (observation) => {
252
253
  const observed = { ...observation, observedAt: Date.now(), observationSequence: ++observationSequence };
253
254
  if (!runActive) return;
254
255
  observations.push(observed);
@@ -269,7 +270,8 @@ export function createSandboxPanel({ rgs, router, faults, api, slug, mathHash, f
269
270
  waiter.resolve(observed);
270
271
  }
271
272
  }
272
- });
273
+ };
274
+ if (typeof router.observe === 'function') router.observe(dispatchObservation);
273
275
  const waitFor = (predicate, ms = 90_000, registerCancel) => {
274
276
  const startSequence = scenarioStartSequence;
275
277
  const startAt = scenarioStartAt;
@@ -354,9 +356,13 @@ export function createSandboxPanel({ rgs, router, faults, api, slug, mathHash, f
354
356
  stop.type = 'button';
355
357
  stop.textContent = 'Stop';
356
358
  stop.hidden = true;
359
+ const gameRefusal = document.createElement('button');
360
+ gameRefusal.type = 'button';
361
+ gameRefusal.textContent = 'The game refused it';
362
+ gameRefusal.hidden = true;
357
363
  const summary = document.createElement('ul');
358
364
  summary.className = 'sandbox-test-summary';
359
- scenariosSection.append(scenariosBox, runAll, skip, stop, summary);
365
+ scenariosSection.append(scenariosBox, runAll, skip, stop, gameRefusal, summary);
360
366
  panel.append(scenariosSection);
361
367
  let scenarioEntries = [];
362
368
  let scenarioButtons = [];
@@ -434,6 +440,10 @@ export function createSandboxPanel({ rgs, router, faults, api, slug, mathHash, f
434
440
  scenarioStartSequence = observationSequence;
435
441
  scenarioStartAt = Date.now();
436
442
  },
443
+ offerGameRefusal: (on) => {
444
+ gameRefusalWaitActive = on === true && runActive;
445
+ gameRefusal.hidden = !gameRefusalWaitActive;
446
+ },
437
447
  awaitVerdict: (item) => new Promise((resolve) => {
438
448
  reviewOutcome = item;
439
449
  verdictResolve = resolve;
@@ -484,6 +494,8 @@ export function createSandboxPanel({ rgs, router, faults, api, slug, mathHash, f
484
494
  return outcome;
485
495
  } finally {
486
496
  runActive = false;
497
+ gameRefusalWaitActive = false;
498
+ gameRefusal.hidden = true;
487
499
  root.classList.remove('sandbox-test-running');
488
500
  setRunningControls(false);
489
501
  skip.hidden = true;
@@ -544,6 +556,8 @@ export function createSandboxPanel({ rgs, router, faults, api, slug, mathHash, f
544
556
  status.textContent = warning ? `Stopped at ${stoppedAt}/${applicable.length}: ${warning}` : stoppedAt ? `Stopped at ${stoppedAt}/${applicable.length}` : `Finished ${results.length} / ${applicable.length}`;
545
557
  } finally {
546
558
  runActive = false;
559
+ gameRefusalWaitActive = false;
560
+ gameRefusal.hidden = true;
547
561
  root.classList.remove('sandbox-test-running');
548
562
  setRunningControls(false);
549
563
  skip.hidden = true;
@@ -555,6 +569,12 @@ export function createSandboxPanel({ rgs, router, faults, api, slug, mathHash, f
555
569
  }
556
570
  skip.addEventListener('click', () => activeRunner?.skip());
557
571
  stop.addEventListener('click', () => activeRunner?.stop());
572
+ gameRefusal.addEventListener('click', () => {
573
+ if (!gameRefusalWaitActive) return;
574
+ gameRefusalWaitActive = false;
575
+ gameRefusal.hidden = true;
576
+ dispatchObservation({ type: 'game-refused' });
577
+ });
558
578
  const balance = document.createElement('div');
559
579
  balance.className = 'sandbox-test-section';
560
580
  balance.append(text('h3', 'Balance'));
@@ -117,10 +117,11 @@ function checkRound(observation, entry, check = {}) {
117
117
  return expected(check.detail || 'The runner observed the expected round.', observation, check.review ? { review: true } : {});
118
118
  }
119
119
 
120
- async function wait(ctx, predicate, instruction) {
120
+ async function wait(ctx, predicate, instruction, { gameRefusal = false } = {}) {
121
121
  try {
122
122
  ctx.beforeWait?.();
123
- const observation = await ctx.waitFor(predicate, SCENARIO_TIMEOUT_MS);
123
+ ctx.offerGameRefusal?.(gameRefusal);
124
+ const observation = await ctx.waitFor((event) => (gameRefusal && event?.type === 'game-refused') || predicate(event), SCENARIO_TIMEOUT_MS);
124
125
  if (observation === SCENARIO_TIMEOUT) return result('skipped', `Skipped after 90 seconds: ${instruction}`);
125
126
  if (observation) return { observation };
126
127
  const status = ctx.stopRequested?.() ? 'stopped' : ctx.skipRequested?.() ? 'skipped' : 'needs-verdict';
@@ -130,9 +131,15 @@ async function wait(ctx, predicate, instruction) {
130
131
  } catch (error) {
131
132
  if (error?.code === 'timeout' || error?.name === 'TimeoutError') return result('skipped', `Skipped after 90 seconds: ${instruction}`);
132
133
  throw error;
134
+ } finally {
135
+ if (gameRefusal) ctx.offerGameRefusal?.(false);
133
136
  }
134
137
  }
135
138
 
139
+ function gameRefusalResult(observation) {
140
+ return expected('The game refused the stake itself.', observation, { verdict: 'game refused it' });
141
+ }
142
+
136
143
  async function setupBalance(ctx, amount) {
137
144
  const response = await ctx.setBalance(amount);
138
145
  return response?.ok === false ? result('different', `Different: setup refused (${response.error?.message || 'balance refused'}).`) : null;
@@ -151,13 +158,14 @@ function ladderInstruction(ctx, entry, suffix) {
151
158
 
152
159
  const definitions = [
153
160
  {
154
- id: 'cant-afford-minimum', group: 'Balance', title: "Can't afford the minimum stake", todo: 'Press Bet once', look: 'An insufficient-balance refusal', applies: (ctx) => !!playEntry(ctx),
161
+ id: 'cant-afford-minimum', group: 'Balance', title: "Can't afford the minimum stake", todo: 'Press Bet once', look: 'An insufficient-balance refusal, or the game refusing it itself', applies: (ctx) => !!playEntry(ctx),
155
162
  async run(ctx) {
156
163
  const setup = await setupBalance(ctx, Math.max(0, minimumStake(ctx) - walletUnit(ctx)));
157
164
  if (setup) return setup;
158
165
  ctx.say?.(betInstruction(ctx, playEntry(ctx)));
159
- const waited = await wait(ctx, (event) => refusalFor(event) || event.type === 'settled', this.todo);
166
+ const waited = await wait(ctx, (event) => refusalFor(event) || event.type === 'settled', this.todo, { gameRefusal: true });
160
167
  if (!waited.observation) return waited;
168
+ if (waited.observation.type === 'game-refused') return gameRefusalResult(waited.observation);
161
169
  if (waited.observation.type === 'settled') return result('different', 'Different: a settled round occurred.', waited.observation);
162
170
  return waited.observation.code === 'insufficient-balance'
163
171
  ? expected('Refused with insufficient-balance', waited.observation, { review: true })
@@ -165,7 +173,7 @@ const definitions = [
165
173
  },
166
174
  },
167
175
  {
168
- id: 'exactly-one-bet', group: 'Balance', title: 'Exactly one bet', todo: 'Bet the minimum stake', look: 'One losing round, then insufficient-balance', applies: (ctx) => !!playEntry(ctx, (entry) => !!losingEntry(ctx, entry)),
176
+ id: 'exactly-one-bet', group: 'Balance', title: 'Exactly one bet', todo: 'Bet the minimum stake', look: 'One losing round, then insufficient-balance or the game refusing it itself', applies: (ctx) => !!playEntry(ctx, (entry) => !!losingEntry(ctx, entry)),
169
177
  async run(ctx) {
170
178
  const entry = playEntry(ctx, (candidate) => !!losingEntry(ctx, candidate));
171
179
  let setup = await setupBalance(ctx, minimumStake(ctx));
@@ -178,22 +186,24 @@ const definitions = [
178
186
  if (!first.observation) return first;
179
187
  const firstCheck = checkRound(first.observation, losingEntry(ctx, entry), { loss: true });
180
188
  if (firstCheck.status !== 'expected') return firstCheck;
181
- const second = await wait(ctx, (event) => refusalFor(event), this.todo);
189
+ const second = await wait(ctx, (event) => refusalFor(event), this.todo, { gameRefusal: true });
182
190
  if (!second.observation) return second;
191
+ if (second.observation.type === 'game-refused') return gameRefusalResult(second.observation);
183
192
  return second.observation.code === 'insufficient-balance'
184
193
  ? expected('Exactly one losing bet, then insufficient-balance', second.observation, { review: true })
185
194
  : result('different', `Different: second bet refused with ${second.observation.code}.`, second.observation);
186
195
  },
187
196
  },
188
197
  {
189
- id: 'stake-above-balance', group: 'Balance', title: 'Stake above the balance', todo: 'Press Bet with a stake above the balance', look: 'An insufficient-balance refusal or the game explanation', applies: (ctx) => !!playEntry(ctx),
198
+ id: 'stake-above-balance', group: 'Balance', title: 'Stake above the balance', todo: 'Press Bet with a stake above the balance', look: 'An insufficient-balance refusal, or the game refusing it itself', applies: (ctx) => !!playEntry(ctx),
190
199
  async run(ctx) {
191
200
  const entry = playEntry(ctx);
192
201
  const setup = await setupBalance(ctx, minimumStake(ctx));
193
202
  if (setup) return setup;
194
203
  ctx.say?.(`Bet more than the balance (${minimumStake(ctx)}) on ${entry.modeLabel || entry.mode} / ${entry.betLabel || entry.bet}`);
195
- const waited = await wait(ctx, (event) => refusalFor(event) || event.type === 'settled', this.todo);
204
+ const waited = await wait(ctx, (event) => refusalFor(event) || event.type === 'settled', this.todo, { gameRefusal: true });
196
205
  if (!waited.observation) return waited;
206
+ if (waited.observation.type === 'game-refused') return gameRefusalResult(waited.observation);
197
207
  if (waited.observation.type === 'settled') return result('different', 'Different: a settled round occurred.', waited.observation);
198
208
  return waited.observation.code === 'insufficient-balance'
199
209
  ? expected('Refused with insufficient-balance', waited.observation, { review: true })
@@ -201,13 +211,14 @@ const definitions = [
201
211
  },
202
212
  },
203
213
  {
204
- id: 'empty-wallet', group: 'Balance', title: 'Empty wallet', todo: 'Press Bet once', look: 'A refusal for insufficient balance', applies: (ctx) => !!playEntry(ctx),
214
+ id: 'empty-wallet', group: 'Balance', title: 'Empty wallet', todo: 'Press Bet once', look: 'An insufficient-balance refusal, or the game refusing it itself', applies: (ctx) => !!playEntry(ctx),
205
215
  async run(ctx) {
206
216
  const setup = await setupBalance(ctx, 0);
207
217
  if (setup) return setup;
208
218
  ctx.say?.(`Empty wallet — ${betInstruction(ctx, playEntry(ctx))}`);
209
- const waited = await wait(ctx, (event) => refusalFor(event) || event.type === 'settled', this.todo);
219
+ const waited = await wait(ctx, (event) => refusalFor(event) || event.type === 'settled', this.todo, { gameRefusal: true });
210
220
  if (!waited.observation) return waited;
221
+ if (waited.observation.type === 'game-refused') return gameRefusalResult(waited.observation);
211
222
  if (waited.observation.type === 'settled') return result('different', 'Different: a settled round occurred.', waited.observation);
212
223
  return waited.observation.code === 'insufficient-balance'
213
224
  ? expected('Refused with insufficient-balance', waited.observation, { review: true })
@@ -380,7 +391,7 @@ export function applicableScenarios(ctx) {
380
391
  });
381
392
  }
382
393
 
383
- export function createScenarioContext({ entries: scenarioEntries = [], limits = {}, walletDecimals = 8, setBalance, force, clearForce, armFault, clearFaults, reload, currentBalance, startingBalance, say, waitFor, skip, beforeScenario, beforeWait, awaitVerdict, cancelVerdict, resolveVerdict } = {}) {
394
+ export function createScenarioContext({ entries: scenarioEntries = [], limits = {}, walletDecimals = 8, setBalance, force, clearForce, armFault, clearFaults, reload, currentBalance, startingBalance, say, waitFor, skip, beforeScenario, beforeWait, offerGameRefusal, awaitVerdict, cancelVerdict, resolveVerdict } = {}) {
384
395
  const context = {
385
396
  entries: scenarioEntries,
386
397
  limits,
@@ -398,6 +409,7 @@ export function createScenarioContext({ entries: scenarioEntries = [], limits =
398
409
  skip,
399
410
  beforeScenario,
400
411
  beforeWait,
412
+ offerGameRefusal,
401
413
  awaitVerdict,
402
414
  cancelVerdict,
403
415
  resolveVerdict,
@@ -61,8 +61,10 @@ export function createSiteBridge({ parent, parentOrigin, gameId, currencyId }) {
61
61
  flat(EV.reconcile, ids(r));
62
62
  }
63
63
  },
64
- ready: () => {
65
- post({ message: 'gameLoaded', data: { progress: 100 } });
64
+ // category: the studio's word for the game ("slots", "plinko", …), from its
65
+ // descriptor; the site shows "In game" in its header for a slot.
66
+ ready: (category) => {
67
+ post({ message: 'gameLoaded', data: { progress: 100, ...(typeof category === 'string' && /^[a-z]{1,16}$/.test(category) ? { category } : {}) } });
66
68
  post({ type: 'housebets-game-controls-ready' });
67
69
  },
68
70
  /**
@@ -250,8 +250,9 @@ function loadPack(dir) {
250
250
  const manifestText = readManifestText(path.join(dir, 'manifest.json'));
251
251
  const manifest = JSON.parse(manifestText);
252
252
  const tables = {};
253
- for (const m of manifest.modes) {
254
- const names = m.kind === 'table' ? [m.table] : m.steps.map((s) => s.table);
253
+ const lists = manifest.modes.map((m) => (m.kind === 'table' ? [m.table] : m.steps.map((s) => s.table)));
254
+ for (const mt of manifest.meters || []) lists.push([mt.table]); // the meters' award tables are in the hash too
255
+ for (const names of lists) {
255
256
  for (const n of names) if (!tables[n]) tables[n] = parseTable(n, readText(path.join(dir, n)));
256
257
  }
257
258
  return { manifest, manifestText, tables };
@@ -307,7 +308,15 @@ function play(pack, modeId, betId, serverSeed, clientSeed, nonce) {
307
308
  if (mode.kind === 'table') {
308
309
  const d = pick(pack.tables[mode.table], s);
309
310
  const tier = bet.tiers.find((t) => inRanges(t.outcomes, d.index));
310
- return { mode: modeId, bet: betId, draws: [d], multBp: tier ? tier.multBp : 0 };
311
+ const out = { mode: modeId, bet: betId, draws: [d], multBp: tier ? tier.multBp : 0 };
312
+ // The meter's award (a round that filled it): the stream's next draw.
313
+ const meter = mode.meter && (pack.manifest.meters || []).find((m) => m.id === mode.meter);
314
+ if (meter) {
315
+ const a = pick(pack.tables[meter.table], s);
316
+ const t = meter.award.find((x) => inRanges(x.outcomes, a.index));
317
+ out.award = { draw: a, multBp: t ? t.multBp : 0 };
318
+ }
319
+ return out;
311
320
  }
312
321
  const draws = []; let survived = 0, busted = false;
313
322
  for (const step of mode.steps) {
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "hbengine",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Build, verify and ship House Engine games to House Studio, from the terminal or from an AI agent (MCP).",
5
5
  "type": "module",
6
6
  "bin": { "hbengine": "engine-cli/hbengine.mjs" },
7
7
  "engines": { "node": ">=20" },
8
+ "dependencies": { "playwright-core": "1.58.2" },
8
9
  "homepage": "https://housestudio.io",
9
10
  "license": "UNLICENSED"
10
11
  }