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
package/engine-sdk/GUIDE.md
CHANGED
|
@@ -23,16 +23,18 @@ cd my-game
|
|
|
23
23
|
npx hbengine connect # a key from House Studio, one click (or hbengine login <portal> with a key)
|
|
24
24
|
npx hbengine verify # the engine's own verifier: exact RTP per bet, the mathHash
|
|
25
25
|
npx hbengine dev # play your bundle inside the real shell, against the sandbox (after a first upload)
|
|
26
|
-
npx hbengine upload # verify + lint + upload; the sandbox plays it; the dashboard has the checks
|
|
27
|
-
npx hbengine store # store.json
|
|
26
|
+
npx hbengine upload # verify + lint + direct-to-storage upload; the sandbox plays it; the dashboard has the checks
|
|
27
|
+
npx hbengine store # store.json → the store page (Housebets makes the tile)
|
|
28
28
|
npx hbengine submit # the newest version that passed, to review
|
|
29
29
|
npx hbengine stats # rounds, GGR and RTP by coin once it is played
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
The dashboard (sign up at the same address) does the rest: upload by
|
|
33
|
-
dragging the folder in, the store page
|
|
33
|
+
dragging the folder in, the store page, the sandbox in any coin
|
|
34
34
|
and language with a forced first round, submit for review, go live,
|
|
35
|
-
reports, statements and payouts.
|
|
35
|
+
reports, statements and payouts. The tile on Housebets is ours: we make it
|
|
36
|
+
from your game's own art when a version goes live, in the style of every
|
|
37
|
+
tile on the site.
|
|
36
38
|
|
|
37
39
|
`hbengine connect` opens the approve page in your browser; an owner or
|
|
38
40
|
developer approves there, and the terminal shows which studio did and asks
|
|
@@ -141,7 +143,7 @@ stops, the trap tile), the server never reads it:
|
|
|
141
143
|
RTP is its best stop. An outcome no bet pays on (a push, a tie, a zero
|
|
142
144
|
segment) is fine — the verifier only names it. `hbengine verify` prints RTP, hit rate, max
|
|
143
145
|
multiplier and (ladders) the RTP at every stop; what it accepts the upload
|
|
144
|
-
accepts, because it is the same code (
|
|
146
|
+
accepts, because it is the same code (the engine's own verifier).
|
|
145
147
|
- A ladder round's **outcome** is `round.state`: `open`, `cashed_out` or
|
|
146
148
|
`busted` (`settled` for a table). `round.busted` and `round.survived`
|
|
147
149
|
describe the drawn path and arrive when the round closes — after a
|
|
@@ -180,7 +182,23 @@ writePack('math', { game: 'my-slot', rtpBp: 9600, modes: [{ id: 'base', rows, be
|
|
|
180
182
|
free spins alone, priced at what they return on average (27 spins),
|
|
181
183
|
balanced to exactly 96%.
|
|
182
184
|
- **`writePack`** sorts rows so each pay tier is one range, checks the
|
|
183
|
-
limits
|
|
185
|
+
limits and prints RTP, hit rate and max win. The limits, the same the
|
|
186
|
+
upload enforces: 2 to 1,000,000 rows per mode, Σ weights under 2^63, a
|
|
187
|
+
row's data at most 4096 UTF-8 bytes, at most **16,384 distinct payouts (tiers)
|
|
188
|
+
per bet** (round multipliers to fewer values when a bonus-buy mode has
|
|
189
|
+
more), and at most 64 modes and 256 bets per mode.
|
|
190
|
+
- **Pack size.** The maths in a pack (the files in `math/` together, as
|
|
191
|
+
they are on disk) may be at most **200 MB**. `writePack` refuses more, so
|
|
192
|
+
does `hbengine` before it sends anything (`verify`, `pack`, `upload`), and
|
|
193
|
+
so does the upload's own check.
|
|
194
|
+
- With a studio key, `hbengine verify` (and the check `upload` and `pack`
|
|
195
|
+
run first) sends the maths to House Studio's hosted pre-check, which may
|
|
196
|
+
not take the larger packs within that limit: it takes about **32 MiB**
|
|
197
|
+
of request, and the maths sent as JSON are somewhat bigger than on disk
|
|
198
|
+
(each tab and line break counts twice). That is not a verdict on the
|
|
199
|
+
maths: `upload` and `pack` say the pre-check could not take the pack and
|
|
200
|
+
carry on, and the upload's own check verifies it. `verify` alone says the
|
|
201
|
+
same and exits with an error, as it could not check the maths.
|
|
184
202
|
- Tune the model until RTP *before* the fit is near the target (the gen
|
|
185
203
|
prints it): the fit should nudge, not reshape, the hit rate.
|
|
186
204
|
- Exact games (plinko, wheels, dice) need no fit: weights are exact
|
|
@@ -194,6 +212,78 @@ volatility (σ of one round's multiplier: low < 2, medium < 5, high < 15,
|
|
|
194
212
|
very high above) from the verified pack; players see the same on the
|
|
195
213
|
game's page.
|
|
196
214
|
|
|
215
|
+
### 2.2 Meters — progress that pays
|
|
216
|
+
|
|
217
|
+
A **meter** is progress a player builds across rounds that changes what a
|
|
218
|
+
round pays: a potion that fills and pours a giant crystal, a jackpot bar, a
|
|
219
|
+
"collect to bonus" counter. It is kept by the House Engine (never by your
|
|
220
|
+
page or by `engine.state`), in the same transaction as each round, and it is
|
|
221
|
+
part of the verified maths. Your page shows it; it cannot change it.
|
|
222
|
+
|
|
223
|
+
**The rule.** A meter fills with the **money staked** in its modes, not with
|
|
224
|
+
a count of rounds. Its award comes after `fill × reference` has been staked,
|
|
225
|
+
where the *reference* is the stake of the round that started the meter
|
|
226
|
+
(capped, below). The round that fills it also draws once from the meter's
|
|
227
|
+
**award table**, which pays multiples of the reference — added to that
|
|
228
|
+
round's payout, in its one credit. Then the meter starts again (whatever
|
|
229
|
+
the filling round had left over starts the next one).
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
"meters": [{ "id": "potion", "label": "Potion", "fill": 250, "table": "potion.tsv",
|
|
233
|
+
"award": [{ "outcomes": [[0, 59]], "multBp": 20000 }, { "outcomes": [[60, 84]], "multBp": 30000 }] }],
|
|
234
|
+
"modes": [{ "id": "8-low", "kind": "table", "table": "8-low.tsv", "meter": "potion", "bets": [ … ] }]
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
- `fill`: 2 to 1,000,000. `award` tiers pay `multBp` **of the reference**
|
|
238
|
+
(10000 = the reference once); rows no tier lists pay nothing.
|
|
239
|
+
- One meter per mode (`"meter": id`, table modes only); several modes may
|
|
240
|
+
share one meter. A meter is kept per player, game, meter id and coin, and
|
|
241
|
+
carries over to your new versions while its `id` stays the same — give a
|
|
242
|
+
meter a **new id** whenever you change its `fill` or its award.
|
|
243
|
+
- With the maths kit: `writePack(dir, { …, modes: [{ …, meter: 'potion' }],
|
|
244
|
+
meters: [{ id: 'potion', fill: 250, rows: [{ weight, pay, data }] }] })` —
|
|
245
|
+
`pay` in bp of the reference; it prints each bet as base + meter.
|
|
246
|
+
|
|
247
|
+
**Why it is safe, for you and for the player.** Every unit staked buys
|
|
248
|
+
exactly `1/fill` of an award worth `E[award] × reference`, so what a meter
|
|
249
|
+
adds to the RTP is exact and the same whatever the player does — small
|
|
250
|
+
stakes then a big one, stopping and coming back, any order:
|
|
251
|
+
|
|
252
|
+
RTP of a bet = its base RTP + E[award] ÷ fill
|
|
253
|
+
|
|
254
|
+
The verifier computes it exactly and refuses a pack whose total passes the
|
|
255
|
+
declared `rtpBp`; the **base alone must also be at least 90%**, so a player
|
|
256
|
+
who leaves before a meter fills has still played a fair game.
|
|
257
|
+
`hbengine verify` shows both (`rtp` and `baseRtp`) and each meter's
|
|
258
|
+
`awardEv`, `awardMax` and what it adds.
|
|
259
|
+
|
|
260
|
+
**Maximum win.** A round's base win may use at most half the round's
|
|
261
|
+
maximum win and a meter's award the other half: the stake limits of a mode
|
|
262
|
+
with a meter are set for that (`limits.maxStake` is lower than without the
|
|
263
|
+
meter), and a meter's reference is capped at the largest stake whose top
|
|
264
|
+
award fits its half (`anchorCap`) — a very large stake fills a meter of a
|
|
265
|
+
smaller reference, more often. A stake that would fill a meter while its
|
|
266
|
+
award no longer fits is refused, never reduced (code 21).
|
|
267
|
+
|
|
268
|
+
**In the page.**
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
const meters = await engine.meters(); // [{ id, label, fill, filled, target, reference, anchorCap }] in the coin
|
|
272
|
+
const { round } = await engine.play({ mode, bet, amount });
|
|
273
|
+
round.meter; // { id, before, after, award? }: award = { reference, multBp, payout, draw, data }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`round.payout` already includes the award; `award.data` is the award row's
|
|
277
|
+
data (what your page draws), `award.payout` its part of the payout. The
|
|
278
|
+
round's receipt carries the meter, and the Provably Fair dialog verifies the
|
|
279
|
+
award's draw: it is the round stream's next draw after the round's own.
|
|
280
|
+
|
|
281
|
+
**Review.** The meter is visible at all times while its mode is played, and
|
|
282
|
+
the rules say how it fills, what the award pays (on the reference, not the
|
|
283
|
+
last stake), the RTP as base + meter, and that an unfinished meter is kept
|
|
284
|
+
for the player. The `plinko-jar` template (`hbengine init --template
|
|
285
|
+
plinko-jar`) is a complete one: maths, jar and page.
|
|
286
|
+
|
|
197
287
|
## 3. The bundle
|
|
198
288
|
|
|
199
289
|
`bundle/index.html` at the root, everything relative. Budgets: boot group
|
|
@@ -211,7 +301,8 @@ const { round, balance } = await engine.play({ mode: 'coin', bet: 'heads', amoun
|
|
|
211
301
|
round.data[0] // the row JSON you wrote in the table
|
|
212
302
|
engine.revealed(round.id); // the outcome is on screen: the header balance moves now
|
|
213
303
|
// round: { id, mode, bet, kind, amount (wallet units), state 'open'|'settled'|'cashed_out'|'busted',
|
|
214
|
-
// step, payout, multBp, data: [row JSON per draw], draws: [{index, raw, rejected}], nonce, mathHash
|
|
304
|
+
// step, payout, multBp, data: [row JSON per draw], draws: [{index, raw, rejected}], nonce, mathHash,
|
|
305
|
+
// meter?: { id, before, after, award? } — a mode with a meter, §2.2 }
|
|
215
306
|
// engine.config.limits: { min, max, maxWin, maxStake: { <mode>: { <bet>: "12.50" } } } in the wallet coin
|
|
216
307
|
// (may be empty in the sandbox); maxStake is the most each bet takes, §4.1
|
|
217
308
|
// a ladder draws every step at the bet; while it is open you see only the steps played,
|
|
@@ -225,6 +316,7 @@ engine.on('balance' | 'control' | 'pause' | 'resume' | 'ended' | 'autoplay', fn)
|
|
|
225
316
|
await engine.autoplay.start({ mode, bet, rounds, stake, stopOnProfit?, stopOnLoss? }) // the player's grant for one bet: the shell shows the terms, they press Start
|
|
226
317
|
engine.autoplay.stop() // end it; the shell also shows its own Stop over your game
|
|
227
318
|
engine.state.get() / engine.state.put(revision, data) // the player's own saved data, ≤ 256 KB, any device
|
|
319
|
+
engine.meters() // the player's meters in this coin (§2.2); round.meter on every round of a meter mode
|
|
228
320
|
// both resolve { revision, data }; put with the revision you read
|
|
229
321
|
// (0 the first time); a stale revision rejects with `conflict`
|
|
230
322
|
// save when the player changes something, not per frame: over 10 writes
|
|
@@ -248,7 +340,9 @@ player has just clicked or pressed a key in your page — the shell reads the
|
|
|
248
340
|
real browser activation a click gives it, which lasts a few seconds, and
|
|
249
341
|
checks that the focus is inside your frame and got there by the player's own
|
|
250
342
|
act (a click in your page puts it there; a click elsewhere on the site page
|
|
251
|
-
does not
|
|
343
|
+
does not; a `focus()` your script calls does not count either, and before
|
|
344
|
+
the player's first tap it makes the shell refuse every play for 5 s, see
|
|
345
|
+
below) — or
|
|
252
346
|
inside an autoplay session the player started (its plays, and the steps and
|
|
253
347
|
cash-out of the ladder it opened). Call them from the handler of the
|
|
254
348
|
player's gesture, not from a timer, a load, a date or a round count, or the
|
|
@@ -282,6 +376,16 @@ so the shell bets nothing there and the refusal names the browsers that can
|
|
|
282
376
|
conformance run presses your Bet with a real click, so a game that bets from
|
|
283
377
|
the click handler passes as before.
|
|
284
378
|
|
|
379
|
+
**Never call `focus()` before the player's first tap.** A script that moves
|
|
380
|
+
the focus into its frame (an intro's Continue button focused when it
|
|
381
|
+
appears, say) is taken as focus not given by the player: the shell undoes it
|
|
382
|
+
and refuses every play with `needs-click` for the next 5 s, the player's own
|
|
383
|
+
taps included. An `autofocus` attribute and a dialog's `show()` or
|
|
384
|
+
`showModal()` move the focus too, so the same applies to them. On a phone
|
|
385
|
+
the first taps on Spin then do nothing. Desktop browsers do not show it
|
|
386
|
+
(they already have the player's activation), so you only see it on a real
|
|
387
|
+
phone; the conformance run's `focus` check catches it before then.
|
|
388
|
+
|
|
285
389
|
A ladder your page climbs itself (a crash) advances on the Bet click's
|
|
286
390
|
activation for those few seconds and within the tap's run of steps, then the
|
|
287
391
|
shell refuses the next step: on `pacing` wait `retryAfterMs` and climb on;
|
|
@@ -365,6 +469,30 @@ el.addEventListener('select', (e) => draw(e.detail.mode)); // the panel's pick
|
|
|
365
469
|
// tables: what your zone draws (a wheel's segments, a paytable) is a constant of yours.
|
|
366
470
|
```
|
|
367
471
|
|
|
472
|
+
#### Saving player progress — `engine.progress`
|
|
473
|
+
|
|
474
|
+
Anything a player builds up for show — skins, a collection, achievements,
|
|
475
|
+
settings, a streak shown for fun — goes in **`engine.progress`**. It is kept
|
|
476
|
+
on the server for the player and the game: the same on every device and in
|
|
477
|
+
every version you upload. **Browser storage is not**: each version is served
|
|
478
|
+
from its own address, so `localStorage` and IndexedDB start empty on every
|
|
479
|
+
new version and on every other device — progress kept only there "resets".
|
|
480
|
+
|
|
481
|
+
```js
|
|
482
|
+
await engine.progress.ready; // loaded: nothing is saved before this
|
|
483
|
+
const skins = engine.progress.get('skins', []); // a value, or your fallback
|
|
484
|
+
engine.progress.set('skin', 'tabby'); // saved within ~2 s, and when the page goes
|
|
485
|
+
engine.progress.update('drops', (n = 0) => n + 1);
|
|
486
|
+
await engine.progress.migrate(['my-game-skins']); // once: values you kept in localStorage move over
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
It saves at most every two seconds, merges with another tab's save instead
|
|
490
|
+
of overwriting it, and refuses a value that would take the player's saved
|
|
491
|
+
data past 256 KB when you set it (`engine.state` is the same store,
|
|
492
|
+
underneath; use one or the other for a key). Saved progress never changes a
|
|
493
|
+
payout: progress that pays is a meter (§2.2). Keep in the browser only what
|
|
494
|
+
may be lost (a panel left open, the last tab).
|
|
495
|
+
|
|
368
496
|
### 3.2 Loading in stages — `assets.json`
|
|
369
497
|
|
|
370
498
|
```json
|
|
@@ -381,13 +509,17 @@ Every file in exactly one group.
|
|
|
381
509
|
### 3.3 Fit — the frame, and what the site's bar does
|
|
382
510
|
|
|
383
511
|
The game is the frame, as a House Original is: the site gives it the
|
|
384
|
-
column's width × **720 px
|
|
385
|
-
|
|
512
|
+
column's width × **up to 720 px**, wider in theatre mode and the whole
|
|
513
|
+
screen in fullscreen (the site's buttons). On a phone or tablet the frame
|
|
514
|
+
is the room between the site's header and its bottom nav, so its height is
|
|
515
|
+
the phone's: about 440 px on a 375×667 iPhone, 700 on a tall Android; on a
|
|
516
|
+
laptop the window can make it shorter than 720 too. Lay the game out for
|
|
386
517
|
the box it is given — `height: 100%`, the board taking what is left — and
|
|
387
|
-
re-fit when it changes
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
at
|
|
518
|
+
re-fit when it changes (a portrait layout that needs 600 px loses its Spin
|
|
519
|
+
row on most phones). The frame never grows to the page and never scrolls:
|
|
520
|
+
the pipeline plays your game at 1194×720, 1194×582, 364×720 and 364×440,
|
|
521
|
+
then fullscreen at 1920×1080 and 390×844, and every control must be on
|
|
522
|
+
screen at each — and no text, image or canvas may be cut by the frame's edge (a
|
|
391
523
|
reel that runs off a phone fails). Decoration meant to bleed off the edge
|
|
392
524
|
is marked `aria-hidden="true"`; a clip inside the frame (a reel window, a
|
|
393
525
|
ticker) is yours and fine.
|
|
@@ -416,6 +548,25 @@ or otherwise.
|
|
|
416
548
|
|
|
417
549
|
### 3.4 `hbtest.json` — how the pipeline plays one bet
|
|
418
550
|
|
|
551
|
+
**A game that draws its own interface: use the SDK's Bet button.** It is the
|
|
552
|
+
easiest way through this section and the time-travel control check (§6):
|
|
553
|
+
|
|
554
|
+
```html
|
|
555
|
+
<script type="module" src="/engine-sdk/bet.js"></script>
|
|
556
|
+
<hb-bet></hb-bet> <!-- or <hb-bet><img slot="icon" src="spin.png" alt=""> Spin</hb-bet> -->
|
|
557
|
+
```
|
|
558
|
+
```js
|
|
559
|
+
bet.addEventListener('bet', () => spin()); // only the player's press fires it
|
|
560
|
+
bet.busy = true; // during a round
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Style it with `--hb-bet-bg`, `--hb-bet-color`, `--hb-bet-radius`,
|
|
564
|
+
`--hb-bet-font` and `hb-bet::part(button)`. Its size (at least 48×48), opacity,
|
|
565
|
+
visibility and pointer are the SDK's and cannot be styled away, so it always
|
|
566
|
+
passes the control check. The run finds it by itself: a game that loads
|
|
567
|
+
`/engine-sdk/bet.js` needs no `hbtest.json` (keep one only to type a stake or
|
|
568
|
+
open a screen before the bet).
|
|
569
|
+
|
|
419
570
|
```json
|
|
420
571
|
{ "open": [{ "page": "index.html", "selector": "a[href='./coin.html']" }],
|
|
421
572
|
"stake": { "page": "index.html", "selector": "hb-game hb-bet-amount input", "value": "1.00" },
|
|
@@ -444,19 +595,40 @@ The engine panel's selectors are a contract: `hb-game hb-bet-amount input`
|
|
|
444
595
|
The typed stake is parsed in every currency of the run (a fiat at 2
|
|
445
596
|
decimals, the coin at 8): keep it representable in both, `1.00` is.
|
|
446
597
|
|
|
598
|
+
**The Bet control is the player's own button.** The run presses what a
|
|
599
|
+
player presses, and the time-travel runs (§6) measure the element `bet`
|
|
600
|
+
names once the stake is typed. It must be:
|
|
601
|
+
|
|
602
|
+
- at least **32×32** CSS pixels;
|
|
603
|
+
- at least **half opaque**, counting its parents' opacity;
|
|
604
|
+
- visible (`visibility: visible`), taking the pointer (no
|
|
605
|
+
`pointer-events: none`), enabled, and inside the frame.
|
|
606
|
+
|
|
607
|
+
A stand-in the player never sees or presses is refused, even when the real
|
|
608
|
+
button is drawn on a canvas: a transparent, tiny or off-screen element
|
|
609
|
+
wired to `engine.play` would let the run pass while the button players
|
|
610
|
+
use goes untested. A game drawn on a canvas gives its Bet button as a real
|
|
611
|
+
DOM element the player clicks: a `<button>` over the canvas, styled or
|
|
612
|
+
drawn as the button itself (the canvas draws nothing beneath it), or the
|
|
613
|
+
engine panel's Bet (`hb-game [data-main]`).
|
|
614
|
+
|
|
447
615
|
### 3.5 Libraries (Pixi, Howler, GSAP …)
|
|
448
616
|
|
|
449
617
|
Ship them in the bundle under `bundle/vendor/` — nothing loads from a CDN at
|
|
450
618
|
run time. Libraries often contain code paths the player's browser will
|
|
451
619
|
refuse here (`new Function`, default CDN URLs). The scan reads a file under
|
|
452
|
-
`vendor/` as a library when its
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
620
|
+
`vendor/` as a library when its bytes are exactly a release's file as the
|
|
621
|
+
package publishes it on npm (the same bytes a CDN serves): its SHA-256 is in
|
|
622
|
+
`engine-conformance/js-releases.json` (today PixiJS 8.16.0, 8.22.0 and 7.4.3
|
|
623
|
+
with its `unsafe-eval` module, GSAP 3.14.2 and 3.15.0, and the glue of the
|
|
624
|
+
WebAssembly releases below; ask us to add a library or a version, we take
|
|
625
|
+
the hash from the published tarball, never from a file in an upload). What
|
|
626
|
+
it would refuse in your own code it then only names for the reviewer, the
|
|
627
|
+
library may be minified as released, and the scan does not follow your
|
|
628
|
+
values into it. Anything else under `vendor/` — a custom or rebuilt
|
|
629
|
+
library, a bundler chunk, a file named and bannered like a library but not
|
|
630
|
+
byte for byte its release — is read as your code (§3.7 applies, a source
|
|
631
|
+
map and all), and the scan says which library it looked like.
|
|
460
632
|
The conformance run fails a game that trips the policy even once — configure
|
|
461
633
|
the library not to take those paths. Ship the release file as it is: never
|
|
462
634
|
edit a library file to get past the scan.
|
|
@@ -495,8 +667,8 @@ only: **a known library's released module, run by that library's own code.**
|
|
|
495
667
|
Need another library or version? Ask us to add its hash (we take it from
|
|
496
668
|
the published tarball, never from a file in an upload).
|
|
497
669
|
- Only the library's own glue may reach `WebAssembly`: its `.js` under
|
|
498
|
-
`vendor/`,
|
|
499
|
-
|
|
670
|
+
`vendor/`, byte for byte as released (§3.5: the glue of every release
|
|
671
|
+
above is in `js-releases.json`). Your own code
|
|
500
672
|
reaching `WebAssembly` in any way — `WebAssembly.instantiate(…)`, a name
|
|
501
673
|
bound to it, `this.wa = WebAssembly`, a module embedded as base64 — is
|
|
502
674
|
refused. `typeof WebAssembly === 'object'` is a feature check, not a use.
|
|
@@ -734,8 +906,9 @@ stake is never below `engine.config.limits.min` and never 0: there is no
|
|
|
734
906
|
free spin (§3.3), so a play button stays disabled until the field holds a
|
|
735
907
|
playable stake (`field.reason === null` and `field.amount > 0`; an empty
|
|
736
908
|
field's `reason` is `min`, and `engine.play` itself refuses a stake of 0
|
|
737
|
-
with `limits` before anything is sent). The
|
|
738
|
-
|
|
909
|
+
with `limits` before anything is sent). The field's `change` event also fires
|
|
910
|
+
when a balance update changes `reason`, so the engine panel (`ui.js`) follows
|
|
911
|
+
the balance as well as edits and bet selection.
|
|
739
912
|
|
|
740
913
|
### 4.1 Maximum win per round
|
|
741
914
|
|
|
@@ -799,9 +972,36 @@ through `engine.money`.
|
|
|
799
972
|
|
|
800
973
|
## 6. What the upload checks — the whole list
|
|
801
974
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
975
|
+
**Run it first on your machine:** `hbengine test` plays the desktop and phone
|
|
976
|
+
checks, and `hbengine test --time-travel` the fake-clock matrix too (your
|
|
977
|
+
agent: `engine_test` with `timeTravel`). A failing line says what to change
|
|
978
|
+
under it, here and on the dashboard.
|
|
979
|
+
|
|
980
|
+
`hbengine upload` = `verify` + `lint` + the portal's conformance run: real
|
|
981
|
+
desktop and phone frames, then the fake-clock matrix below (two to ten
|
|
982
|
+
minutes; `hbengine status` shows the state meanwhile — upload the next
|
|
983
|
+
version once it says the run is over). A version can reach `sandbox` with warnings or skipped coverage. Read each
|
|
984
|
+
line: a skipped check or an endurance run that ended early is not a pass.
|
|
985
|
+
The report marks incomplete coverage explicitly; Housebets can review the
|
|
986
|
+
submission with those gaps visible and decide what still needs checking.
|
|
987
|
+
|
|
988
|
+
**The run's machine is slow on purpose.** Chrome draws on the CPU
|
|
989
|
+
(SwiftShader), with few animation frames, and the desktop and phone frames run
|
|
990
|
+
one after the other in the same browser. So: call `ready()` from `connect()`'s
|
|
991
|
+
resolution, not from `requestAnimationFrame` (a starved frame may not fire it
|
|
992
|
+
in 25 s); keep idle CSS and canvas animations cheap (a button that never stops
|
|
993
|
+
moving is never "stable" enough to click); load what the first screen needs
|
|
994
|
+
first.
|
|
995
|
+
|
|
996
|
+
Each frame-size context is closed before the next starts, including when a
|
|
997
|
+
check fails. A boot or scripted-click failure keeps a screenshot when the
|
|
998
|
+
renderer can answer, bounded console output, elapsed time, frame paths and
|
|
999
|
+
the names of observed shell messages. The JSON report puts this evidence in
|
|
1000
|
+
`diagnostics.desktop` / `diagnostics.phone`, or in a failed time-travel
|
|
1001
|
+
variant's `diagnostics`. Runner Node/Chrome versions, CPU count, load and memory
|
|
1002
|
+
are included so a local result can be compared with the hosted machine.
|
|
1003
|
+
A missing screenshot is reported explicitly; diagnostic capture has a time
|
|
1004
|
+
limit so a stuck renderer cannot keep the rest of the run waiting.
|
|
805
1005
|
|
|
806
1006
|
| check | what passes |
|
|
807
1007
|
|---|---|
|
|
@@ -812,15 +1012,17 @@ state meanwhile). A version is `sandbox` only when every line is `ok`:
|
|
|
812
1012
|
| engine use | the code connects, calls `play`, calls `revealed` |
|
|
813
1013
|
| boot (desktop, phone) | `hello` answered and `ready()` within 25 s |
|
|
814
1014
|
| idle | left untouched for 15 s, the game places no bet: a bet is always the player's act (and the shell refuses one with no click behind it, §3.1) |
|
|
1015
|
+
| focus | until the run's first click (so through the idle wait too), nothing moves the focus: no `focus()` on an element or the window, no `autofocus` element on the page, no dialog `show()` or `showModal()`; a startup focus can make the shell refuse genuine bets for five seconds (§3.1). A warning for now, not a fail |
|
|
815
1016
|
| bet | one bet through your UI (`hbtest.json`), pressed with a real click, reaches `engine.play`, then `revealed` |
|
|
816
1017
|
| shows | with `shows` in `hbtest.json`: the element shows the value of the round the server drew |
|
|
817
1018
|
| draws | after the round, the play area shows something: its visible content covers at least 2% of the frame (a board with no size fails) |
|
|
818
1019
|
| picture | the screenshot of the frame is not one flat colour (a warning for the reviewer for now): a canvas that drew nothing, or WebGL that could not start, shows a black frame. The run draws WebGL on the CPU (SwiftShader), so a WebGL game must start without a GPU |
|
|
819
1020
|
| currency | under a fiat display (desktop) and a bare coin (phone): the stake sent equals what the Originals send for the typed amount; the screen shows that currency |
|
|
820
|
-
| outage | the next bet meets an ambiguous wallet answer: the game keeps running, the site is asked to re-read the wallet |
|
|
821
|
-
| fit | the game fits the frame it is given — 1194×720 and 364×
|
|
1021
|
+
| outage | the next bet meets an ambiguous wallet answer: the game keeps running, the site is asked to re-read the wallet. **Not run yet** on the hosted upload, nor by `hbengine test` on an engine-dev with no wallet fault stub: both show it as skipped. Check it yourself: a refused or lost bet (`pending`, `server`, `unavailable`, §3.1) shows its message and gives the Bet control back, never a stuck game |
|
|
1022
|
+
| fit | the game fits the frame it is given — 1194×720, 1194×582, 364×720 and 364×440, then fullscreen 1920×1080 and 390×844: no page scrolls, every visible control is inside the frame, no text (or more than a tenth of a picture) is cut by its edge |
|
|
822
1023
|
| errors | no uncaught error in any page |
|
|
823
1024
|
| network | no request leaves the bundle's origin, and the policy refuses nothing (a blocked worker, script or style fails the run) |
|
|
1025
|
+
| time-travel | the fake-clock matrix: the game boots and plays a round with the clock moved and the player elsewhere — `base` (now, UTC, en), a day on, a week on, a month on, a year on, a weekend, a holiday, the first of a month, and `long` (300 rounds). `hbengine test --time-travel` plays each at fixed clocks; an upload plays each kind at an instant, a time zone and one of the SDK's languages drawn for that upload, so make the game right for any date, zone and language, not for a list. Each variant also measures the Bet control (§3.4: at least 32×32 px, at least half opaque, visible, enabled, in the frame): a stand-in fails as "the bet control is …". After the first round the clock jumps 30 minutes ahead (a player who left the tab and came back), and every frame must keep up within 60 seconds: every timer that came due fires at once, so a game whose timers each do heavy work (a full render, rebuilding a scene, an interval catching up tick by tick) freezes and fails "the fake clock did not move". Let timers only mark that a frame is needed and draw at most once per `requestAnimationFrame`; after a gap, run an interval's work once. `/engine-sdk/frames.js` does both: `frame(draw)` (any number of requests before the next frame is one draw) and `every(ms, fn)` (after a gap it runs once, told how many periods it missed). The matrix has 510 s; a variant that does not fit in what is left is **skipped** and shown as skipped — not run, not passed — so read the report's time-travel lines, not only the verdict |
|
|
824
1026
|
|
|
825
1027
|
Then a reviewer plays it (`sandbox → review → live`), with a kill switch at
|
|
826
1028
|
every level. Money, seeds, limits and refunds are the platform's and the
|
|
@@ -845,7 +1047,7 @@ studio would.
|
|
|
845
1047
|
|
|
846
1048
|
Every version a player can see is reviewed by a person at Housebets first. Review works like a pull request.
|
|
847
1049
|
|
|
848
|
-
1. Run `npx hbengine preflight` (or `engine_preflight`) first: on the newest version that passed every check (the one submit sends), it checks what a machine can of the review checklist — the RTP and max win your rules quote against the verified maths, rules in every language, the store page, phone and desktop fit, errors, load and network — and prints the facts the rules must quote. A pre-flight fail is pre-ticked on the reviewer's checklist; a pass is a note to them, not a tick, since each item asks more than a machine sees. Then `npx hbengine submit` (or `engine_submit`, or the Review tab) sends the newest version that passed every check.
|
|
1050
|
+
1. Run `npx hbengine preflight` (or `engine_preflight`) first: on the newest version that passed every check (the one submit sends), it checks what a machine can of the review checklist — the RTP and max win your rules quote against the verified maths (a max win is in multiples of the stake of the bet that pays it: for a buy mode, its buy price, not the base bet), rules in every language, the store page, phone and desktop fit, errors, load and network — and prints the facts the rules must quote. A pre-flight fail is pre-ticked on the reviewer's checklist; a pass is a note to them, not a tick, since each item asks more than a machine sees. Then `npx hbengine submit` (or `engine_submit`, or the Review tab) sends the newest version that passed every check.
|
|
849
1051
|
2. The reviewer plays it on desktop and phone and goes through the **review checklist** (the game's Review tab, and `checklist` in `engine_review`): the maths against the rules and the screen, the rules in every language, phone and desktop play, sound, recovery from errors, content you own, nothing aimed at children, no outcome logic or outside servers in the bundle, an honest store page. Go through it yourself before you submit.
|
|
850
1052
|
3. Anything to change is a **thread** on the version: a **must fix** blocks going live, a **suggestion** does not. Each thread names its area and says what, where and why.
|
|
851
1053
|
4. Fix every must-fix thread in the project, upload the new version and submit it. Submitting marks the open must-fix threads as fixed in that version (on the Review tab you can untick any it does not fix). To ask the reviewer something instead, reply on the thread (`npx hbengine reply --thread 12 --message "…"`, `engine_reply`, or the Review tab), in at most 4,000 characters.
|
package/engine-sdk/bet-field.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* field.bet = { mode, bet }; // the bet the stake is for (its own ceiling)
|
|
14
14
|
* engine.play({ mode, bet, amount: field.amount }); // wallet units
|
|
15
15
|
* field.disabled = true; // while a round runs
|
|
16
|
-
* field.addEventListener('change', (e) => e.detail); // { amount, reason }
|
|
16
|
+
* field.addEventListener('change', (e) => e.detail); // { amount, reason }; also on a balance update when reason changes
|
|
17
17
|
* field.value; // the same as text, like an <input>
|
|
18
18
|
*
|
|
19
19
|
* The rule is the Originals' (bet field: coinflip-frontend BetAmount.svelte,
|
|
@@ -101,6 +101,7 @@ export class BetAmountField extends HTMLElement {
|
|
|
101
101
|
#typing = false;
|
|
102
102
|
#hintTimer = 0;
|
|
103
103
|
#note = '';
|
|
104
|
+
#reasonCode = null;
|
|
104
105
|
#els;
|
|
105
106
|
|
|
106
107
|
constructor() {
|
|
@@ -128,7 +129,11 @@ export class BetAmountField extends HTMLElement {
|
|
|
128
129
|
connect(engine) {
|
|
129
130
|
this.#engine = engine;
|
|
130
131
|
this.#money = engine.money;
|
|
131
|
-
engine.on('balance', () =>
|
|
132
|
+
engine.on('balance', () => {
|
|
133
|
+
const before = this.#reasonCode;
|
|
134
|
+
this.#render();
|
|
135
|
+
this.#announceReasonChange(before);
|
|
136
|
+
});
|
|
132
137
|
const kept = this.#recall();
|
|
133
138
|
if (kept > 0) this.#amount = kept;
|
|
134
139
|
this.#render();
|
|
@@ -166,10 +171,11 @@ export class BetAmountField extends HTMLElement {
|
|
|
166
171
|
set bet(v) {
|
|
167
172
|
const next = v && typeof v.mode === 'string' && typeof v.bet === 'string' ? { mode: v.mode, bet: v.bet } : null;
|
|
168
173
|
if ((next && this.#bet && next.mode === this.#bet.mode && next.bet === this.#bet.bet) || (!next && !this.#bet)) return;
|
|
174
|
+
const before = this.#reasonCode;
|
|
169
175
|
this.#bet = next;
|
|
170
176
|
this.#render();
|
|
171
177
|
// Another bet may take less: whoever follows `reason` hears it changed.
|
|
172
|
-
this
|
|
178
|
+
this.#announceReasonChange(before, true);
|
|
173
179
|
}
|
|
174
180
|
|
|
175
181
|
/** The most the selected bet takes, in wallet units, or null when the
|
|
@@ -278,6 +284,12 @@ export class BetAmountField extends HTMLElement {
|
|
|
278
284
|
this.dispatchEvent(new CustomEvent('change', { detail: { amount: next, reason } }));
|
|
279
285
|
}
|
|
280
286
|
|
|
287
|
+
#announceReasonChange(before, always = false) {
|
|
288
|
+
const reason = this.reason;
|
|
289
|
+
if (!always && before === (reason ? reason.code : null)) return;
|
|
290
|
+
this.dispatchEvent(new CustomEvent('change', { detail: { amount: this.#amount, reason } }));
|
|
291
|
+
}
|
|
292
|
+
|
|
281
293
|
/** A note under the field for a few seconds (a refusal's message). */
|
|
282
294
|
note(text) {
|
|
283
295
|
this.#hint(String(text));
|
|
@@ -322,6 +334,7 @@ export class BetAmountField extends HTMLElement {
|
|
|
322
334
|
e.sign.classList.toggle('empty', e.input.value === '');
|
|
323
335
|
e.cap.textContent = m.caption(this.#amount) || '';
|
|
324
336
|
const reason = this.reason;
|
|
337
|
+
this.#reasonCode = reason ? reason.code : null;
|
|
325
338
|
// Red is for a stake the server would refuse; an empty field just
|
|
326
339
|
// says what the least stake is.
|
|
327
340
|
const wrong = !!reason && this.#amount > 0;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Bet button, for a game that draws its own interface.
|
|
3
|
+
*
|
|
4
|
+
* import '/engine-sdk/bet.js';
|
|
5
|
+
*
|
|
6
|
+
* <hb-bet></hb-bet> anywhere: over the canvas, in a bar
|
|
7
|
+
* <hb-bet><img slot="icon" src="spin.png" alt=""> Spin</hb-bet> the game's own art and words
|
|
8
|
+
*
|
|
9
|
+
* bet.addEventListener('bet', () => spin()); // the player's press, and nothing else
|
|
10
|
+
* bet.busy = true; // during a round: shown, not pressable
|
|
11
|
+
*
|
|
12
|
+
* The button a player presses is the one the upload's check presses and
|
|
13
|
+
* measures (GUIDE §3.4): a real button, at least 48×48 px, fully opaque,
|
|
14
|
+
* taking the pointer. Those few things are fixed here (`!important` inside
|
|
15
|
+
* the element outranks the game's styles); everything else is the game's:
|
|
16
|
+
* `--hb-bet-bg`, `--hb-bet-color`, `--hb-bet-radius`, `--hb-bet-font`, and
|
|
17
|
+
* `hb-bet::part(button)` for the rest. A game with this button needs no
|
|
18
|
+
* hbtest.json: the check finds `hb-bet` on the game's first page and presses it.
|
|
19
|
+
*
|
|
20
|
+
* `bet` fires on a press by the player only, never on a click made by code:
|
|
21
|
+
* a bet is the player's act, and the shell refuses one without it anyway.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const CSS = `
|
|
25
|
+
:host { display: inline-block; vertical-align: middle; }
|
|
26
|
+
:host([hidden]) { display: none; }
|
|
27
|
+
button {
|
|
28
|
+
min-width: 48px !important; min-height: 48px !important;
|
|
29
|
+
opacity: 1 !important; visibility: visible !important; pointer-events: auto !important;
|
|
30
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
|
31
|
+
box-sizing: border-box; width: 100%; height: 100%; padding: 0 20px; border: 0; cursor: pointer;
|
|
32
|
+
border-radius: var(--hb-bet-radius, 8px);
|
|
33
|
+
background: var(--hb-bet-bg, #28F3F3); color: var(--hb-bet-color, #041015);
|
|
34
|
+
font: 600 16px/1 var(--hb-bet-font, var(--hb-font, 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif));
|
|
35
|
+
transition: transform 120ms ease, filter 120ms ease;
|
|
36
|
+
}
|
|
37
|
+
button:hover { filter: brightness(1.08); }
|
|
38
|
+
button:active { transform: scale(0.98); }
|
|
39
|
+
button:disabled { cursor: progress; filter: saturate(0.6) brightness(0.85); transform: none; }
|
|
40
|
+
::slotted([slot="icon"]) { max-height: 100%; }
|
|
41
|
+
@media (prefers-reduced-motion: reduce) { button { transition: none; } }
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export class BetButton extends HTMLElement {
|
|
45
|
+
static observedAttributes = ['busy', 'label'];
|
|
46
|
+
#button;
|
|
47
|
+
#label;
|
|
48
|
+
|
|
49
|
+
constructor() {
|
|
50
|
+
super();
|
|
51
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
52
|
+
const style = document.createElement('style');
|
|
53
|
+
style.textContent = CSS;
|
|
54
|
+
root.append(style);
|
|
55
|
+
this.#button = document.createElement('button');
|
|
56
|
+
this.#button.type = 'button';
|
|
57
|
+
this.#button.setAttribute('part', 'button');
|
|
58
|
+
const icon = document.createElement('slot');
|
|
59
|
+
icon.name = 'icon';
|
|
60
|
+
this.#label = document.createElement('slot');
|
|
61
|
+
this.#label.textContent = 'Bet';
|
|
62
|
+
this.#button.append(icon, this.#label);
|
|
63
|
+
root.append(this.#button);
|
|
64
|
+
this.#button.addEventListener('click', (e) => {
|
|
65
|
+
if (!e.isTrusted || this.busy) return;
|
|
66
|
+
this.dispatchEvent(new CustomEvent('bet', { bubbles: true, composed: true }));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True while a round runs: the button stays on screen and takes no press. */
|
|
71
|
+
get busy() { return this.hasAttribute('busy'); }
|
|
72
|
+
set busy(on) { this.toggleAttribute('busy', !!on); }
|
|
73
|
+
|
|
74
|
+
attributeChangedCallback(name, _old, value) {
|
|
75
|
+
if (name === 'busy') {
|
|
76
|
+
this.#button.disabled = value !== null;
|
|
77
|
+
if (value !== null) this.setAttribute('aria-busy', 'true');
|
|
78
|
+
else this.removeAttribute('aria-busy');
|
|
79
|
+
}
|
|
80
|
+
if (name === 'label') this.#label.textContent = value || 'Bet';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The real button (focus, measuring). */
|
|
84
|
+
get button() { return this.#button; }
|
|
85
|
+
focus(options) { this.#button.focus(options); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (globalThis.customElements && !customElements.get('hb-bet')) customElements.define('hb-bet', BetButton);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drawing and repeating that never pile up.
|
|
3
|
+
*
|
|
4
|
+
* import { frame, every } from '/engine-sdk/frames.js';
|
|
5
|
+
* frame(draw); // draw at the next animation frame
|
|
6
|
+
* const t = every(1000, (missed) => tick(missed));
|
|
7
|
+
* t.stop();
|
|
8
|
+
*
|
|
9
|
+
* When time jumps (a player comes back to a tab after half an hour; the
|
|
10
|
+
* upload's check moves the clock 30 minutes on purpose) every timer that came
|
|
11
|
+
* due fires at once. A game whose timers each draw the whole scene, or whose
|
|
12
|
+
* interval catches up tick by tick, freezes: the check fails it "the fake
|
|
13
|
+
* clock did not move" (GUIDE §6). These two cannot:
|
|
14
|
+
*
|
|
15
|
+
* - `frame(draw)`: every request before the next animation frame is one call
|
|
16
|
+
* of `draw` (each different function once), so a burst of timers asking to
|
|
17
|
+
* draw costs one draw.
|
|
18
|
+
* - `every(ms, fn)`: once per period; after a gap it runs once, told how many
|
|
19
|
+
* periods it missed (`fn(missed)`), never once per missed period. One timer
|
|
20
|
+
* is pending at a time, so a jump can only fire it once.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const browser = () => ({
|
|
24
|
+
now: () => globalThis.performance.now(),
|
|
25
|
+
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
26
|
+
clearTimer: (t) => globalThis.clearTimeout(t),
|
|
27
|
+
raf: (fn) => (globalThis.requestAnimationFrame ? globalThis.requestAnimationFrame(fn) : globalThis.setTimeout(fn, 16)),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The helpers on a given clock (the tests'), or the page's. */
|
|
31
|
+
export function createFrames(clock = browser()) {
|
|
32
|
+
const { now, setTimer, clearTimer, raf } = clock;
|
|
33
|
+
const asked = new Set();
|
|
34
|
+
let pending = false;
|
|
35
|
+
|
|
36
|
+
function frame(draw) {
|
|
37
|
+
if (typeof draw !== 'function') throw new TypeError('frame(draw): draw is a function');
|
|
38
|
+
asked.add(draw);
|
|
39
|
+
if (pending) return;
|
|
40
|
+
pending = true;
|
|
41
|
+
raf(() => {
|
|
42
|
+
pending = false;
|
|
43
|
+
const draws = [...asked];
|
|
44
|
+
asked.clear();
|
|
45
|
+
for (const d of draws) {
|
|
46
|
+
try { d(); } catch (e) { setTimer(() => { throw e; }, 0); }
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function every(ms, fn) {
|
|
52
|
+
if (!(ms > 0) || typeof fn !== 'function') throw new TypeError('every(ms, fn): a period above 0 and a function');
|
|
53
|
+
let last = now();
|
|
54
|
+
let timer = null;
|
|
55
|
+
let stopped = false;
|
|
56
|
+
const run = () => {
|
|
57
|
+
timer = null;
|
|
58
|
+
if (stopped) return;
|
|
59
|
+
const t = now();
|
|
60
|
+
const periods = Math.max(1, Math.floor((t - last) / ms));
|
|
61
|
+
// The next run keeps the rhythm from the last period that ended, not from this late call.
|
|
62
|
+
last += periods * ms;
|
|
63
|
+
try { fn(periods - 1); } catch (e) { setTimer(() => { throw e; }, 0); }
|
|
64
|
+
if (!stopped) timer = setTimer(run, Math.max(0, last + ms - now()));
|
|
65
|
+
};
|
|
66
|
+
timer = setTimer(run, ms);
|
|
67
|
+
return { stop() { stopped = true; if (timer !== null) clearTimer(timer); timer = null; } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { frame, every };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const page = createFrames();
|
|
74
|
+
export const frame = page.frame;
|
|
75
|
+
export const every = page.every;
|