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
@@ -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 (+ tile.png) → the store page
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 and tile, the sandbox in any coin
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 (`POST /engine/v1/verify`).
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 (rows, Σ weights, row size) and prints RTP, hit rate and max win.
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, and a `focus()` your script calls is undone and buys nothing) — or
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** (phone: ~364 × 720), wider in theatre mode and
385
- the whole screen in fullscreen (the site's buttons). Lay the game out for
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. The frame never grows to the page and never
388
- scrolls: the pipeline plays your game at 1194×720 and 364×720, then
389
- fullscreen at 1920×1080 and 390×844, and every control must be on screen
390
- at each — and no text, image or canvas may be cut by the frame's edge (a
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 file name and the banner comment in its
453
- head match a library we know (PixiJS and its `unsafe-eval` module, howler.js,
454
- GSAP, three.js, Phaser, Babylon.js, Matter.js, Planck.js, anime.js, jQuery,
455
- lodash, Spine, Tone.js, Hammer.js, Tween.js, p5.js, CreateJS, PlayCanvas,
456
- cannon.js, Box2D, Rapier — `KNOWN_LIBRARIES` in `engine-conformance/scan.mjs`;
457
- ask us to add one): what it would refuse in your own code it only names for
458
- the reviewer, and the library may be minified as released. Anything else
459
- under `vendor/` is read as your code (§3.7 applies, a source map and all).
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/`, named and bannered as released (§3.5: `basis_transcoder.js`,
499
- `draco_decoder.js`, `rapier*.js`, `Box2D*.js` are known). Your own code
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 engine panel (`ui.js`) does
738
- exactly this.
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
- `hbengine upload` = `verify` + `lint` + the portal's conformance run (about
803
- two minutes on real desktop and phone frames; `hbengine status` shows the
804
- state meanwhile). A version is `sandbox` only when every line is `ok`:
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×720, 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 |
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.
@@ -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', () => this.#render());
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.dispatchEvent(new CustomEvent('change', { detail: { amount: this.#amount, reason: this.reason } }));
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;