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
@@ -0,0 +1,128 @@
1
+ /**
2
+ * engine.progress: the player's saved progress (skins, settings, a
3
+ * collection, achievements), kept on the server through engine.state so it is
4
+ * there on every device and in every version of the game. Browser storage is
5
+ * not: each version is served from its own origin, so localStorage starts
6
+ * empty on every new version (GUIDE §3.1, "Saving player progress").
7
+ *
8
+ * await engine.progress.ready; // loaded; nothing is saved before
9
+ * engine.progress.get('skins', []); // a value, or the fallback
10
+ * engine.progress.set('skin', 'tabby'); // saved within ~2 s, and when the page goes
11
+ * engine.progress.update('wins', (n = 0) => n + 1);
12
+ * await engine.progress.flush(); // save now (before leaving a screen, say)
13
+ * await engine.progress.migrate(['my-game-v1']); // once: localStorage keys moved to the server
14
+ *
15
+ * What it does for you: never saves before the server's copy is loaded (an
16
+ * empty page never overwrites a player's progress); batches writes; on a
17
+ * stale revision (another tab saved) reloads, puts your pending changes on
18
+ * top and saves again; refuses a value that would take the whole above the
19
+ * server's limit before the server does. Progress lives under `progress` in
20
+ * the state document; anything else a game keeps there is left as it is.
21
+ *
22
+ * Progress never changes what a round pays: it is cosmetic. Progress that
23
+ * pays is a meter (GUIDE §2.2), kept by the engine with the round.
24
+ */
25
+ import { STATE_MAX_BYTES } from './protocol.js';
26
+
27
+ const utf8 = (s) => new TextEncoder().encode(s).length;
28
+
29
+ /**
30
+ * createProgress(state, { delay, setTimer, clearTimer, storage, onError })
31
+ * state engine.state ({ get, put })
32
+ * delay ms between a change and its save (2000)
33
+ */
34
+ export function createProgress(state, { delay = 2000, setTimer = setTimeout, clearTimer = clearTimeout, storage = globalThis.localStorage, onError = () => {} } = {}) {
35
+ let revision = 0, doc = {}, values = {};
36
+ const pending = new Map(); // key → value (undefined: delete)
37
+ let timer = null, saving = null, loaded = false;
38
+
39
+ const read = (s) => {
40
+ revision = s.revision || 0;
41
+ doc = s.data && typeof s.data === 'object' && !Array.isArray(s.data) ? s.data : {};
42
+ values = doc.progress && typeof doc.progress === 'object' && !Array.isArray(doc.progress) ? { ...doc.progress } : {};
43
+ };
44
+ const withPending = () => {
45
+ const out = { ...values };
46
+ for (const [k, v] of pending) if (v === undefined) delete out[k]; else out[k] = v;
47
+ return out;
48
+ };
49
+ const ready = state.get().then((s) => { read(s); loaded = true; });
50
+
51
+ function schedule() {
52
+ if (timer !== null) return;
53
+ timer = setTimer(() => { timer = null; flush().catch(onError); }, delay);
54
+ }
55
+
56
+ async function save() {
57
+ await ready;
58
+ for (let attempt = 0; pending.size && attempt < 5; attempt++) {
59
+ const sent = new Map(pending);
60
+ const next = withPending();
61
+ try {
62
+ const r = await state.put(revision, { ...doc, progress: next });
63
+ read(r);
64
+ for (const [k, v] of sent) if (pending.get(k) === v) pending.delete(k);
65
+ } catch (e) {
66
+ if (!e || e.code !== 'conflict') throw e;
67
+ // Another tab or device saved first: theirs, then ours on top.
68
+ read(await state.get());
69
+ }
70
+ }
71
+ }
72
+
73
+ function flush() {
74
+ if (timer !== null) { clearTimer(timer); timer = null; }
75
+ if (saving) return saving.then(() => (pending.size ? flush() : undefined));
76
+ if (!pending.size) return ready;
77
+ saving = save().finally(() => { saving = null; });
78
+ return saving;
79
+ }
80
+
81
+ function set(key, value) {
82
+ if (typeof key !== 'string' || !key) throw new TypeError('progress: a key is a non-empty string');
83
+ if (value !== undefined) JSON.stringify(value); // throws on what cannot be saved
84
+ const trial = { ...withPending() };
85
+ if (value === undefined) delete trial[key]; else trial[key] = value;
86
+ if (utf8(JSON.stringify({ ...doc, progress: trial })) > STATE_MAX_BYTES) throw new RangeError(`progress: saving "${key}" would pass the ${STATE_MAX_BYTES / 1024} KB a player's saved data may take`);
87
+ pending.set(key, value);
88
+ if (loaded) schedule(); else ready.then(schedule);
89
+ }
90
+
91
+ if (typeof addEventListener === 'function') {
92
+ addEventListener('pagehide', () => { if (pending.size) flush().catch(() => {}); });
93
+ }
94
+
95
+ return Object.freeze({
96
+ ready,
97
+ get: (key, fallback) => { const v = withPending()[key]; return v === undefined ? fallback : v; },
98
+ set,
99
+ update: (key, fn) => { const v = withPending()[key]; set(key, fn(v)); },
100
+ remove: (key) => set(key, undefined),
101
+ keys: () => Object.keys(withPending()),
102
+ flush,
103
+ /**
104
+ * Moves values a game kept in localStorage to the server, once: a key
105
+ * the server already has is left as the server has it. The keys are
106
+ * removed from localStorage once saved.
107
+ */
108
+ async migrate(keys) {
109
+ await ready;
110
+ if (!storage) return [];
111
+ const moved = [];
112
+ for (const k of keys) {
113
+ let raw = null;
114
+ try { raw = storage.getItem(k); } catch { raw = null; }
115
+ if (raw === null || withPending()[k] !== undefined) continue;
116
+ let value = raw;
117
+ try { value = JSON.parse(raw); } catch { /* kept as text */ }
118
+ set(k, value);
119
+ moved.push(k);
120
+ }
121
+ if (moved.length) {
122
+ await flush();
123
+ for (const k of moved) { try { storage.removeItem(k); } catch { /* gone already */ } }
124
+ }
125
+ return moved;
126
+ },
127
+ });
128
+ }
@@ -33,6 +33,8 @@ export const REQUESTS = Object.freeze({
33
33
  ROUND: 'round',
34
34
  STATE_GET: 'state.get',
35
35
  STATE_PUT: 'state.put',
36
+ // The player's meters of this game in the session's coin (GUIDE: Meters).
37
+ METERS_GET: 'meters.get',
36
38
  // Autoplay is the shell's: the game asks for a session (on the player's
37
39
  // click; the shell confirms it with the player in its own dialog) and
38
40
  // plays inside it; the shell counts the rounds, holds the plays to the
@@ -154,6 +156,7 @@ const REQUEST_BODY = {
154
156
  return typeof b.roundId === 'string' && UUID_RE.test(b.roundId) ? null : 'roundId';
155
157
  },
156
158
  [REQUESTS.STATE_GET]: (b) => (Object.keys(b).length === 0 ? null : 'unknown key'),
159
+ [REQUESTS.METERS_GET]: (b) => (Object.keys(b).length === 0 ? null : 'unknown key'),
157
160
  [REQUESTS.STATE_PUT]: (b) => {
158
161
  if (!keysWithin(b, ['revision', 'data'])) return 'unknown key';
159
162
  if (!isCount(b.revision)) return 'revision';
@@ -51,8 +51,23 @@ export interface Round {
51
51
  data: any[];
52
52
  payout?: string; multBp?: number; payoutMultiplier?: number; settledAt?: string;
53
53
  survived?: number; busted?: boolean;
54
+ /** A round of a mode with a meter: the meter before and after it, and the award when this round filled it. */
55
+ meter?: RoundMeter;
54
56
  }
55
57
 
58
+ /** A meter's state, in the coin, as decimal strings. */
59
+ export interface MeterValue { filled: string; target: string; reference: string }
60
+
61
+ /** What a round did to its meter (GUIDE: Meters). */
62
+ export interface RoundMeter {
63
+ id: string; label?: string; fill?: number; before: MeterValue; after: MeterValue;
64
+ /** The award this round paid (already in `payout`): `multBp` of `reference`, its draw and its row's data. */
65
+ award?: { reference: string; multBp: number; payout: number; draw: { index: number; raw: string; rejected: number; draw: number }; data: any };
66
+ }
67
+
68
+ /** One meter as engine.meters() reads it. `anchorCap`: the largest reference a new meter starts at in this coin. */
69
+ export interface Meter extends MeterValue { id: string; label?: string; fill: number; anchorCap: string }
70
+
56
71
  /** Money exactly as the House Originals show it, in the player's coin or fiat. Amounts are wallet units. */
57
72
  export interface Money {
58
73
  coin: string; isFiat: boolean; unit: string; sign: string; mark: string;
@@ -108,6 +123,10 @@ export interface Engine {
108
123
  readonly balance: number | null;
109
124
  /** The player's saved data; neither call needs a click (a save moves no money). */
110
125
  readonly state: { get(): Promise<{ revision: number; data: unknown }>; put(revision: number, data: unknown): Promise<{ revision: number; data: unknown }> };
126
+ /** The player's meters of this game in the session's coin (empty when the pack has none). */
127
+ meters(): Promise<Meter[]>;
128
+ /** The player's saved progress (cosmetic), on the server: every device, every version (GUIDE §3.1). */
129
+ readonly progress: Progress;
111
130
  /** Places a bet; rejects with EngineError. Call it from the player's click (or inside an
112
131
  * autoplay session): the shell refuses a play with no activation behind it (`needs-click`). */
113
132
  play(bet: { mode: string; bet: string; amount: number }): Promise<{ round: Round; balance?: number }>;
@@ -136,3 +155,19 @@ export interface Engine {
136
155
  }
137
156
 
138
157
  export function connect(options?: { timeoutMs?: number }): Promise<Engine>;
158
+
159
+ /** engine.progress: saved progress over engine.state, safe by default. */
160
+ export interface Progress {
161
+ /** Resolves once the server's copy is loaded; nothing is saved before. */
162
+ readonly ready: Promise<void>;
163
+ get<T = unknown>(key: string, fallback?: T): T;
164
+ /** Saved within ~2 s and when the page goes; throws past the 256 KB limit. */
165
+ set(key: string, value: unknown): void;
166
+ update<T = unknown>(key: string, fn: (value: T | undefined) => T): void;
167
+ remove(key: string): void;
168
+ keys(): string[];
169
+ /** Saves now. */
170
+ flush(): Promise<void>;
171
+ /** Moves these localStorage keys to the server once (a key the server has wins); returns the keys moved. */
172
+ migrate(keys: string[]): Promise<string[]>;
173
+ }
package/engine-sdk/sdk.js CHANGED
@@ -25,6 +25,7 @@
25
25
  * answering you then — play inside it, and follow its `autoplay` events; the
26
26
  * shell shows its own Stop over the game.
27
27
  */
28
+ import { createProgress } from './progress.js';
28
29
  import {
29
30
  ERRORS,
30
31
  GAME_EVENTS,
@@ -129,6 +130,14 @@ class Engine {
129
130
  get: () => this.#ask(REQUESTS.STATE_GET, {}),
130
131
  put: (revision, data) => this.#ask(REQUESTS.STATE_PUT, { revision, data }),
131
132
  });
133
+ // The player's meters (the pack's `meters`): what is in each, in the
134
+ // session's coin. Every round of a mode with a meter also carries
135
+ // `round.meter` (before, after, and the award when it filled).
136
+ this.meters = () => this.#ask(REQUESTS.METERS_GET, {}).then((b) => b.meters);
137
+ // Saved progress over engine.state (progress.js): made on first use,
138
+ // so a game that does not use it reads nothing at boot.
139
+ let progress = null;
140
+ Object.defineProperty(this, 'progress', { enumerable: true, get: () => (progress ||= createProgress(this.state)) });
132
141
  // Autoplay is a session the shell grants and runs the count of:
133
142
  // start({ mode, bet, rounds, stake, stopOnProfit?, stopOnLoss? }) → { session }, on the player's click;
134
143
  // rounds ≤ 1000, every play in it on that bet and none above `stake`;
package/engine-sdk/ui.js CHANGED
@@ -272,8 +272,11 @@ export async function mount(el, { reveal = async () => {}, step = async () => {}
272
272
  try {
273
273
  ({ session } = await engine.autoplay.start(req));
274
274
  } catch (e) {
275
+ // Cancelled or refused: the panel repaints, or the controls the
276
+ // wait disabled stay disabled with no run going.
275
277
  busy = false;
276
278
  refused(e);
279
+ render();
277
280
  return null;
278
281
  }
279
282
  busy = false;
@@ -171,6 +171,7 @@ export function createRgs({ base, sessionId, fetch: doFetch = globalThis.fetch,
171
171
  setClientSeed: (generation, clientSeed) => rotation('PUT', '/seeds/client', { generation, clientSeed }),
172
172
  rotateSeeds: (generation) => rotation('POST', '/seeds/rotate', { generation }),
173
173
  getState: () => call('GET', '/state'),
174
+ getMeters: () => call('GET', '/meters'),
174
175
  putState: ({ revision, data }) => call('PUT', '/state', { revision, data }),
175
176
  // Sandbox only (the server refuses it anywhere else): the next round resolves to these rows.
176
177
  force: (indices, mode) => call('POST', '/sandbox/force', { indices, ...(mode ? { mode } : {}) }),
@@ -40,9 +40,10 @@
40
40
  * and no input on the shell's own DOM — or, with the shell on the site's
41
41
  * origin, on the site's page: boot.js listens on the site's document as on
42
42
  * its own, and the site may report a press of its own besides (`site-input`
43
- * on its bridge) — in the last ACTIVATION_TTL_MS (`shellInput`, the browsers'
44
- * transient-activation lifetime, so a shell or site press's activation is
45
- * never mistaken for a game click's). What one gesture can buy is bounded
43
+ * on its bridge) — since the focus last entered the game frame, within
44
+ * ACTIVATION_TTL_MS (`shellInput`: a press out there that left the focus in
45
+ * the frame is that press's activation, not a game click's; the player's
46
+ * click back into the game ends it at once). What one gesture can buy is bounded
46
47
  * too: one play per CLICK_MIN_GAP_MS and CLICK_BURST_MAX per
47
48
  * CLICK_BURST_WINDOW_MS; one advance per ADVANCE_MIN_GAP_MS and
48
49
  * ADVANCE_BURST_MAX per window (a hidden trigger must not climb a ladder to
@@ -56,21 +57,18 @@
56
57
  * in current browsers), and the player's next keystrokes — meant for the
57
58
  * site's chat — would then land in the frame, granting it activation. So the
58
59
  * page tells the router whenever the focus enters the game frame
59
- * (`frameFocused`): the player's own focusing (a click, a tap) arrives with
60
- * the activation it granted already live, no shell or site press within the
61
- * activation's lifetime, and not within that lifetime of the focus leaving
62
- * the frame (the page watches the exits too: a game sees its own window blur
63
- * when the player tabs out of it and can take the focus straight back, with
64
- * the activation of that Tab — a key in the game's own document — still live,
65
- * and the shell cannot tell such a return from the player's click back; so
66
- * for ACTIVATION_TTL_MS after leaving the frame, only a Tab the page saw
67
- * brings the player back in) — a Tab into the frame is the player's (the
68
- * page says when the entry followed a Tab), though the Tab itself is a shell
69
- * or site key; a focus with no activation behind it was scripted, and no
70
- * play or action is taken on that focus (`stolenFocus`) until the player
71
- * focuses the frame themselves — the page also blurs a stolen focus back out
72
- * of the frame at once, so the keystrokes land on the shell's own document
73
- * (shell input: the shadow above) rather than in the game.
60
+ * (`frameFocused`): the player's own focusing (a click, a tap, a Tab) arrives
61
+ * with the activation it granted already live; a focus with no activation
62
+ * behind it was scripted, and no play or action is taken on that focus
63
+ * (`stolenFocus`) until the player focuses the frame themselves — the page
64
+ * also blurs a stolen focus back out of the frame at once, so the keystrokes
65
+ * land on the shell's own document (shell input: the shadow above) rather
66
+ * than in the game. A focus the game takes back while a press of the
67
+ * player's out there still has the activation live is not told from the
68
+ * player's click back. Telling them apart took refusing every play for 5 s
69
+ * after any click on the site or the shell (Rewards, chat, Provably Fair,
70
+ * then Spin: refused). Such a steal buys one play per press, within the
71
+ * bet's cap, and is left to review and monitoring.
74
72
  *
75
73
  * Deny by default. Every request the game may make has a kind
76
74
  * (`REQUEST_KIND`): a read (the round receipt, the saved state read) and a
@@ -206,7 +204,7 @@ export { AUTOPLAY_MAX_ROUNDS };
206
204
  // (SPEC §2), which tells a bundle it is being tested, so neither a round nor
207
205
  // a play or action answer carries it to the game (the studio's sandbox shows
208
206
  // it in the shell's own fairness dialog).
209
- export const ROUND_FIELDS = Object.freeze(['roundId', 'game', 'mathHash', 'mode', 'bet', 'kind', 'nonce', 'draws', 'multBp', 'payoutMultiplier', 'survived', 'busted', 'step', 'currency', 'amount', 'payout', 'state', 'createdAt', 'settledAt']);
207
+ export const ROUND_FIELDS = Object.freeze(['roundId', 'game', 'mathHash', 'mode', 'bet', 'kind', 'nonce', 'draws', 'multBp', 'payoutMultiplier', 'survived', 'busted', 'step', 'currency', 'amount', 'payout', 'state', 'createdAt', 'settledAt', 'meter']);
210
208
 
211
209
  /** The receipt cut to ROUND_FIELDS. */
212
210
  export function roundForGame(body) {
@@ -236,6 +234,7 @@ export function moneyForGame(body) {
236
234
  export const REQUEST_KIND = Object.freeze({
237
235
  [REQUESTS.ROUND]: 'read',
238
236
  [REQUESTS.STATE_GET]: 'read',
237
+ [REQUESTS.METERS_GET]: 'read',
239
238
  [REQUESTS.STATE_PUT]: 'save',
240
239
  [REQUESTS.PLAY]: 'money',
241
240
  [REQUESTS.ACTION]: 'money',
@@ -329,19 +328,24 @@ export function createRouter({ self, gameWindow, gameOrigin = '*', rgs, site, in
329
328
  let clickPlays = [];
330
329
  let clickAdvances = [];
331
330
  // When the player last pressed or typed on the shell's own DOM, or on the
332
- // site's page around it (shellInput): for ACTIVATION_TTL_MS after it,
333
- // activation proves nothing about the game.
331
+ // site's page around it (shellInput), and when the focus last entered the
332
+ // game frame as the player's (frameFocused). A press out there that left
333
+ // the focus in the frame (a control that keeps it, a key) shadows the
334
+ // activation for ACTIVATION_TTL_MS: it is that press's, not the game's.
335
+ // The player's way back to the game (a click or a tap in it) is an entry
336
+ // after the press, and ends the shadow at once: Rewards, then Spin, plays.
334
337
  let lastShellInput = -Infinity;
338
+ let lastEntryAt = -Infinity;
335
339
  // Whether the focus now in the game frame got there by the game's script
336
340
  // (frameFocused): no play or action rides on it until the player focuses
337
341
  // the frame themselves. `stolenAt` is when the game last entered with no
338
342
  // activation behind it: for ACTIVATION_TTL_MS after, a focus with the
339
343
  // activation live is not taken for the player's either (the game steals, a
340
344
  // keystroke lands in it, it steals again on that keystroke's activation).
341
- // An entry under a shell or site press's shadow, or within ACTIVATION_TTL_MS
342
- // of the focus leaving the frame (the game taking it back on its own blur),
343
- // is refused too, but opens no window of its own: the shadow ends with that
344
- // press's activation, the exit's window with the exit's.
345
+ // A focus the game takes back with a site press's activation still live
346
+ // is not told from the player's click back (one play per press, within
347
+ // the bet's cap; the 5-second refusal it took to tell them apart broke
348
+ // every Spin after Rewards, chat or Provably Fair).
345
349
  let stolenFocus = false;
346
350
  let stolenAt = -Infinity;
347
351
  // The autoplay session in force, or null:
@@ -579,7 +583,7 @@ export function createRouter({ self, gameWindow, gameOrigin = '*', rgs, site, in
579
583
  const live = activation();
580
584
  if (live === null) return { code: ERRORS.CLICK, message: word('browserNoClick') };
581
585
  if (!live) return { code: ERRORS.CLICK, message: word('needsClick') };
582
- if (now() - lastShellInput < ACTIVATION_TTL_MS) return { code: ERRORS.CLICK, message: word('needsClick') };
586
+ if (now() - lastShellInput < ACTIVATION_TTL_MS && lastEntryAt <= lastShellInput) return { code: ERRORS.CLICK, message: word('needsClick') };
583
587
  if (!focusInGame()) return { code: ERRORS.CLICK, message: word('needsClick') };
584
588
  if (stolenFocus) return { code: ERRORS.CLICK, message: word('needsClick') };
585
589
  return null;
@@ -773,7 +777,7 @@ export function createRouter({ self, gameWindow, gameOrigin = '*', rgs, site, in
773
777
  }
774
778
  case 'read': {
775
779
  // The game never sees seed material (SPEC §2); the receipt is the dialog's.
776
- const r = type === REQUESTS.ROUND ? await rgs.round(body.roundId) : await rgs.getState();
780
+ const r = type === REQUESTS.ROUND ? await rgs.round(body.roundId) : type === REQUESTS.METERS_GET ? await rgs.getMeters() : await rgs.getState();
777
781
  return r.ok ? toGame(answer(id, type === REQUESTS.ROUND ? roundForGame(r.body) : r.body)) : refused(id, r.error, { id, type, body });
778
782
  }
779
783
  default:
@@ -839,37 +843,25 @@ export function createRouter({ self, gameWindow, gameOrigin = '*', rgs, site, in
839
843
  /** A trusted input the player made outside the game frame: on the shell's own DOM, or on
840
844
  * the site's page around the shell (page.js listens on both documents when the shell is a
841
845
  * same-origin frame of the site; the site may also report a press of its own as `site-input`
842
- * on its bridge, boot.js). For ACTIVATION_TTL_MS the window's activation is that press's,
843
- * not the game's, and no play or action is taken on it — so a bundle that stole the focus
844
- * back by script rides nothing from a click or a key in the chat or header. */
846
+ * on its bridge, boot.js). The window's activation is then that press's, not the game's:
847
+ * until the focus next enters the game frame as the player's (frameFocused), or for
848
+ * ACTIVATION_TTL_MS, no play or action is taken on it. */
845
849
  shellInput: () => { lastShellInput = now(); },
846
850
  /** The focus entered the game frame (the page reads it off document.activeElement). The
847
- * player's own focusing — a click or a tap in the frame — comes with the activation it
848
- * granted already live, no shell or site press within the activation's lifetime, no
849
- * scripted focus of the game's within it either, and not within the lifetime of the focus
850
- * last leaving the frame (`sinceExitMs`, as the page saw it: a bundle sees its own window
851
- * blur and can take the focus straight back with the activation of the player's last key
852
- * in the frame — the Tab that took them out — still live, and the shell cannot tell that
853
- * from a click back; so a re-entry within ACTIVATION_TTL_MS of leaving is the player's
854
- * only via a Tab the page saw). A Tab into the frame (`viaTab`: the page saw the player's
855
- * Tab key just before the entry) is the player's though the Tab itself was a key on the
856
- * shell's or the site's document, and whatever the time since leaving — it still needs
857
- * the activation live and no scripted entry within the lifetime. Anything else is the
858
- * game's script (`window.focus()` from inside the frame): no play or action rides on that
859
- * focus until the player focuses the frame themselves. An entry with no activation opens
860
- * the ACTIVATION_TTL_MS window in which no entry is the player's; one merely shadowed by
861
- * a press, or merely quick after an exit, does not (its cost ends with the press's
862
- * activation, or ACTIVATION_TTL_MS after the exit). Fail closed on the time: only a number
863
- * of milliseconds at or past the lifetime says the exit is long enough ago; anything else
864
- * (NaN, a string) is a quick entry. No time at all (the default) is no exit yet: the frame
865
- * was never seen focused before. Returns whether the focus was the player's; the page
866
- * blurs a stolen one straight back out of the frame. */
867
- frameFocused: (viaTab = false, sinceExitMs = Infinity) => {
851
+ * player's own focusing — a click or a tap in the frame, a Tab into it — comes with the
852
+ * activation it granted already live, and no scripted focus of the game's within the
853
+ * activation's lifetime. Anything else is the game's script (`window.focus()` from inside
854
+ * the frame): no play or action rides on that focus until the player focuses the frame
855
+ * themselves, and an entry with no activation opens the ACTIVATION_TTL_MS window in which
856
+ * no entry is the player's. An entry taken as the player's ends the shadow of an earlier
857
+ * shell or site press (shellInput). The page's arguments (whether a Tab came first, the time
858
+ * since the focus left) are no longer read. Returns whether the focus was the player's; the
859
+ * page blurs a stolen one straight back out of the frame. */
860
+ frameFocused: () => {
868
861
  const noActivation = activation() !== true;
869
- const shadowed = viaTab !== true && now() - lastShellInput < ACTIVATION_TTL_MS;
870
- const quick = viaTab !== true && !(typeof sinceExitMs === 'number' && sinceExitMs >= ACTIVATION_TTL_MS);
871
862
  if (noActivation) stolenAt = now();
872
- stolenFocus = noActivation || shadowed || quick || now() - stolenAt < ACTIVATION_TTL_MS;
863
+ stolenFocus = noActivation || now() - stolenAt < ACTIVATION_TTL_MS;
864
+ if (!stolenFocus) lastEntryAt = now();
873
865
  return !stolenFocus;
874
866
  },
875
867
  /** The page is hidden (visibilitychange): a session is for a player who is looking,
@@ -0,0 +1,24 @@
1
+ // Only the trusted shell requests playback mode. No media/microphone access,
2
+ // AudioContext, or playback is started here. A fresh page does not opt in.
3
+ export function createAudioSession(navigator = globalThis.navigator) {
4
+ let wanted = false, suspended = false, owned = false;
5
+ const apply = () => {
6
+ try {
7
+ const session = navigator?.audioSession;
8
+ if (!session) return;
9
+ if (wanted && !suspended) {
10
+ if (session.type !== 'playback') session.type = 'playback';
11
+ owned = session.type === 'playback';
12
+ } else if (owned) {
13
+ if (session.type === 'playback') session.type = 'auto';
14
+ owned = false;
15
+ }
16
+ } catch { /* Unsupported, denied or interrupted: ordinary audio still works. */ }
17
+ };
18
+ return {
19
+ setEnabled(on) { wanted = !!on; apply(); },
20
+ suspend() { suspended = true; apply(); },
21
+ resume() { suspended = false; apply(); },
22
+ release() { wanted = false; apply(); },
23
+ };
24
+ }
@@ -306,7 +306,7 @@ export async function boot({ ui }) {
306
306
  },
307
307
  ready: () => {
308
308
  ui.ready();
309
- bridge.ready();
309
+ bridge.ready(descriptor.category);
310
310
  },
311
311
  loading: ui.loading,
312
312
  fairness: fairness.game,
@@ -331,7 +331,10 @@ export async function boot({ ui }) {
331
331
  if (!cmd) return;
332
332
  if (cmd.cancel) ui.fairnessCancel(cmd.cancel);
333
333
  else if (cmd.input) router.shellInput();
334
- else if (cmd.control) router.tell('control', { action: cmd.control });
334
+ else if (cmd.control) {
335
+ if (cmd.control === 'sound') ui.soundPressed?.();
336
+ router.tell('control', { action: cmd.control });
337
+ }
335
338
  else fairness.player(cmd.fairness, cmd.requestId);
336
339
  });
337
340
  // No site around the shell (opened directly, or a stand): the shell
@@ -308,8 +308,11 @@ export function packFromDocument(source) {
308
308
  if (!doc.tables || typeof doc.tables !== 'object') throw new Error('the document has no tables');
309
309
  const manifestCanonical = canonicalJSON(manifestText);
310
310
  const tables = {};
311
- for (const m of doc.manifest.modes) {
312
- const names = m.kind === 'table' ? [m.table] : (m.steps || []).map((s) => s.table);
311
+ // Every table the manifest names, the meters' awards' too: the hash is
312
+ // over all of them.
313
+ const lists = doc.manifest.modes.map((m) => (m.kind === 'table' ? [m.table] : (m.steps || []).map((s) => s.table)));
314
+ for (const mt of Array.isArray(doc.manifest.meters) ? doc.manifest.meters : []) lists.push([mt.table]);
315
+ for (const names of lists) {
313
316
  for (const n of names) {
314
317
  if (tables[n]) continue;
315
318
  if (typeof doc.tables[n] !== 'string') throw new Error(`the document lacks table ${n}`);
@@ -401,7 +404,7 @@ const inRanges = (rs, i) => rs.some(([lo, hi]) => i >= lo && i <= hi);
401
404
  * is drawn every step at the bet; a cash-out after k steps pays ladderBp[k-1],
402
405
  * which the receipt's `multBp` names.
403
406
  */
404
- export async function play(pack, modeId, betId, serverSeed, clientSeed, nonce) {
407
+ export async function play(pack, modeId, betId, serverSeed, clientSeed, nonce, { award = false } = {}) {
405
408
  const mode = pack.manifest.modes.find((m) => m.id === modeId);
406
409
  if (!mode) throw new Error(`no mode ${modeId}`);
407
410
  const bet = mode.bets.find((b) => b.id === betId);
@@ -410,7 +413,17 @@ export async function play(pack, modeId, betId, serverSeed, clientSeed, nonce) {
410
413
  if (mode.kind === 'table') {
411
414
  const d = await pick(pack.tables[mode.table], s);
412
415
  const tier = bet.tiers.find((t) => inRanges(t.outcomes, d.index));
413
- return { mode: modeId, bet: betId, kind: 'table', draws: [d], multBp: tier ? tier.multBp : 0, blocks: s.blocks };
416
+ const out = { mode: modeId, bet: betId, kind: 'table', draws: [d], multBp: tier ? tier.multBp : 0, blocks: s.blocks };
417
+ // A round that filled its meter: the award is the stream's next draw,
418
+ // from the meter's table, paying its tier's multBp of the reference.
419
+ const meter = award && mode.meter && (pack.manifest.meters || []).find((m) => m.id === mode.meter);
420
+ if (meter) {
421
+ const a = await pick(pack.tables[meter.table], s);
422
+ const t = meter.award.find((x) => inRanges(x.outcomes, a.index));
423
+ out.award = { draw: a, multBp: t ? t.multBp : 0 };
424
+ out.blocks = s.blocks;
425
+ }
426
+ return out;
414
427
  }
415
428
  const draws = []; let survived = 0, busted = false;
416
429
  for (const step of mode.steps) {
@@ -60,9 +60,22 @@ export function expectation(r) {
60
60
  state: typeof r.state === 'string' && STATE.test(r.state) ? r.state : null,
61
61
  step: Number.isInteger(r.step) && r.step >= 0 ? r.step : null,
62
62
  serverSeedHash: typeof r.serverSeedHash === 'string' && HEX64.test(r.serverSeedHash) ? r.serverSeedHash : null,
63
+ ...(meterAward(r) ? { award: meterAward(r) } : {}),
63
64
  };
64
65
  }
65
66
 
67
+ // The receipt's meter award (a round that filled its meter): its draw and
68
+ // multiplier; null when the round paid none; `unreadable` when it names one
69
+ // that cannot be read.
70
+ function meterAward(r) {
71
+ const a = r && r.meter && r.meter.award;
72
+ if (!a) return null;
73
+ const d = a.draw || {};
74
+ const raw = typeof d.raw === 'string' && RAW.test(d.raw) ? d.raw : null;
75
+ if (raw === null || !Number.isInteger(d.index) || d.index < 0 || !Number.isInteger(a.multBp) || a.multBp < 0) return { unreadable: true };
76
+ return { raw, index: d.index, rejected: Number.isInteger(d.rejected) && d.rejected > 0 ? d.rejected : 0, multBp: a.multBp };
77
+ }
78
+
66
79
  /**
67
80
  * The bet a recomputation pays by: the receipt's when the receipt is this
68
81
  * mode's, the mode's only bet, else its first — and then the multiplier is
@@ -274,6 +287,20 @@ export function compare(expect, got, pack, seedHash) {
274
287
  if (g.index !== w.index) differences.push(`draw ${k + 1}: row ${g.index} here, ${w.index} on the receipt`);
275
288
  if ((g.rejected || 0) !== w.rejected) differences.push(`draw ${k + 1}: ${g.rejected || 0} rejected here, ${w.rejected} on the receipt`);
276
289
  });
290
+ if (expect.award) {
291
+ const a = expect.award, g = got.award;
292
+ if (a.unreadable) {
293
+ differences.push("the receipt's meter award could not be read");
294
+ unreadable = true;
295
+ } else if (!g) {
296
+ differences.push('the receipt pays a meter award this mode cannot draw');
297
+ } else {
298
+ if (g.draw.raw.toString() !== a.raw) differences.push(`meter award: raw ${g.draw.raw} here, ${a.raw} on the receipt`);
299
+ if (g.draw.index !== a.index) differences.push(`meter award: row ${g.draw.index} here, ${a.index} on the receipt`);
300
+ if ((g.draw.rejected || 0) !== a.rejected) differences.push(`meter award: ${g.draw.rejected || 0} rejected here, ${a.rejected} on the receipt`);
301
+ if (g.multBp !== a.multBp) differences.push(`meter award: ${times(g.multBp)} the reference here, ${times(a.multBp)} on the receipt`);
302
+ }
303
+ }
277
304
  if (expect.multBp === null) {
278
305
  differences.push('the receipt names no multiplier');
279
306
  unreadable = true;