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
|
@@ -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
|
+
}
|
package/engine-sdk/protocol.js
CHANGED
|
@@ -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';
|
package/engine-sdk/sdk.d.ts
CHANGED
|
@@ -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;
|
package/engine-shell/core/rgs.js
CHANGED
|
@@ -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) —
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
60
|
-
* the activation it granted already live
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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)
|
|
333
|
-
//
|
|
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
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
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).
|
|
843
|
-
*
|
|
844
|
-
*
|
|
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
|
|
848
|
-
* granted already live, no
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
|
|
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 ||
|
|
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)
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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;
|