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
|
@@ -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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
.
|
|
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="
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|