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
@@ -6,13 +6,16 @@
6
6
  * hbengine verify [dir] the math, by the engine's own verifier: RTP per bet, the hash
7
7
  * hbengine lint [dir] the bundle: budgets, staged loading, forbidden code
8
8
  * hbengine dev [dir] play the bundle inside the real shell against the sandbox
9
- * hbengine test [dir] offline: the upload's checks on desktop and phone sizes (Chrome)
9
+ * hbengine test [dir] offline: the upload's checks on desktop and phone sizes (Chrome);
10
+ * --time-travel: the fake-clock matrix the upload plays too
11
+ * --clock <variant>=<instant>,<zone>,<language>: one variant at the
12
+ * clock an upload's report names
10
13
  * hbengine pack [dir] verify + lint, then the .tgz an upload takes (math/, bundle/) to hand over
11
14
  * hbengine upload [dir] verify + lint, then the version goes to the portal, which
12
15
  * registers it with the sandbox and plays the conformance run
13
16
  * hbengine status [dir] every version of the game and its state
14
17
  * hbengine stats [dir] rounds, turnover, GGR and RTP by coin, last 30 days, and the shell's counts
15
- * hbengine store [dir] the store page from store.json (and tile.png/.webp, 3:4) to the dashboard
18
+ * hbengine store [dir] the store page from store.json to the dashboard (Housebets makes the tile)
16
19
  * hbengine submit [dir] the newest version that passed every check, to review
17
20
  * hbengine live [dir] the newest approved version, live (an owner's key)
18
21
  * hbengine preflight [dir] the review checklist items a machine can check, on the newest version
@@ -39,19 +42,24 @@
39
42
  * Offline (the kit), verify and dev start the kit's engine-dev: the
40
43
  * engine's own maths and round rules on this machine, play money.
41
44
  */
45
+ import { fileURLToPath } from 'node:url';
42
46
  import { execFileSync, spawn, spawnSync } from 'node:child_process';
43
- import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
47
+ import { chmodSync, cpSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
44
48
  import { createHash } from 'node:crypto';
45
- import { homedir } from 'node:os';
49
+ import { homedir, tmpdir } from 'node:os';
46
50
  import { dirname, join, resolve } from 'node:path';
47
51
  import { createInterface } from 'node:readline/promises';
48
52
  import { handshake } from './connect.mjs';
53
+ import { MAX_MATH_BYTES, mathBytes, mathTooLarge } from '../engine-math/pack.mjs';
54
+ import { outageArgs } from './outage.mjs';
49
55
  import { TEMPLATES } from './templates.mjs';
56
+ import { uploadArchive } from './upload.mjs';
57
+ import { uploadFailure } from './upload-errors.mjs';
50
58
 
51
- const PKG = new URL('..', import.meta.url).pathname;
59
+ const PKG = fileURLToPath(new URL('..', import.meta.url));
52
60
  const [cmd, ...rest] = process.argv.slice(2);
53
61
  // Flags that stand alone; every other --flag takes the word after it as its value.
54
- const BOOLEAN = new Set(['--fixed', '--close']);
62
+ const BOOLEAN = new Set(['--fixed', '--close', '--time-travel']);
55
63
  const takesValue = (i) => i >= 0 && rest[i].startsWith('--') && !BOOLEAN.has(rest[i]);
56
64
  const opt = (k, d) => { const i = rest.findIndex((a, j) => a === '--' + k && !takesValue(j - 1)); return i >= 0 ? rest[i + 1] : d; };
57
65
  const flag = (k) => rest.some((a, j) => a === '--' + k && !takesValue(j - 1));
@@ -136,17 +144,21 @@ const get = async (url, auth = {}) => {
136
144
  };
137
145
  // A 429 or 503 with Retry-After is the studio saying its pipeline is busy
138
146
  // (another pack in the verifier, the play-tests full), not that the pack is
139
- // bad: the call is made again after that wait, three times at most.
140
- const post = async (url, body, type = 'application/json', auth = {}, tries = 3) => {
147
+ // bad: the call is made again after that wait, three times at most. A caller
148
+ // that can do without the call takes a size refusal itself (tooLarge), with
149
+ // the reason given, whatever the body.
150
+ const post = async (url, body, type = 'application/json', auth = {}, { tries = 3, tooLarge } = {}) => {
141
151
  const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': type, ...UA, ...auth }, body }).catch((e) => fail(`${url}: ${e.message} — is it running?`));
142
152
  const text = await r.text();
143
153
  let j;
144
- try { j = JSON.parse(text); } catch { fail(`${url}: HTTP ${r.status} ${text.slice(0, 120)}`); }
154
+ try { j = JSON.parse(text); } catch { j = null; }
155
+ if (tooLarge && !r.ok && (r.status === 413 || /size limit/.test(j?.error || ''))) return tooLarge(j?.error || `HTTP ${r.status}`);
156
+ if (!j) fail(`${url}: HTTP ${r.status} ${text.slice(0, 120)}`);
145
157
  const after = Number(r.headers.get('retry-after'));
146
158
  if ((r.status === 429 || r.status === 503) && after > 0 && tries > 1) {
147
159
  console.error(`${j.error || `${url}: HTTP ${r.status}`} — trying again in ${after} s`);
148
160
  await new Promise((resolve) => setTimeout(resolve, Math.min(after, 60) * 1000));
149
- return post(url, body, type, auth, tries - 1);
161
+ return post(url, body, type, auth, { tries: tries - 1, tooLarge });
150
162
  }
151
163
  if (!r.ok) fail(j.error || `${url}: HTTP ${r.status}`);
152
164
  return j;
@@ -186,6 +198,7 @@ async function init() {
186
198
  mode.table = rename(mode.table);
187
199
  for (const step of mode.steps || []) step.table = rename(step.table);
188
200
  }
201
+ for (const meter of m.meters || []) meter.table = rename(meter.table);
189
202
  writeFileSync(manifest, JSON.stringify(m, null, 2) + '\n');
190
203
  }
191
204
  console.log(`${dir}: a ${template} game as "${slug}".\nNext: edit math/ and bundle/, then hbengine verify and hbengine dev; hbengine pack makes the file to hand over (or hbengine upload with a studio key).`);
@@ -208,20 +221,48 @@ async function sandbox(rgs) {
208
221
  return { offline: true, child };
209
222
  }
210
223
 
211
- async function verify() {
224
+ // The studio's pre-check refused the pack for its size (the hosted verifier
225
+ // takes about 32 MiB of request). The maths are within Housebets' limit by
226
+ // then (verify refuses more before sending), so that is no verdict on them,
227
+ // and an upload verifies the whole pack in its own check: before one (upload,
228
+ // pack) it is a warning, alone (verify) a failure that says so.
229
+ // The most the hosted pre-check takes in one request: Cloud Run's HTTP/1
230
+ // request limit (32 MiB), below the portal's own 64 MB (studio/rgs.go verifyMax).
231
+ const PRECHECK_MAX = 32 * 1024 * 1024;
232
+ const preCheckTooLarge = (beforeUpload) => (said) => {
233
+ const why = `the hosted pre-check could not take a maths pack this large (the studio said: ${said.replace(/\.$/, '')}). That is not a verdict on the maths`;
234
+ if (!beforeUpload) fail(`math not verified: ${why}. An upload verifies the whole pack in its own check: hbengine upload, or hbengine pack and upload the .tgz in the dashboard.`);
235
+ console.error(`warning: math not checked before the upload: ${why}: the upload's own check will verify it.`);
236
+ return null;
237
+ };
238
+
239
+ async function verify({ beforeUpload = false } = {}) {
212
240
  const { rgs } = project();
241
+ const mathDir = join(dir, 'math');
242
+ // Housebets' limit on a pack's maths, before a byte is sent: the upload refuses more too.
243
+ const held = mathBytes(mathDir);
244
+ if (held > MAX_MATH_BYTES) fail(mathTooLarge(held));
213
245
  const box = login() ? {} : await sandbox(rgs);
214
246
  process.on('exit', () => box.child?.kill());
215
- const mathDir = join(dir, 'math');
216
247
  const files = Object.fromEntries(readdirSync(mathDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => [e.name, readFileSync(join(mathDir, e.name), 'utf8')]));
217
248
  // With a key the studio platform asks the engine (CI has no sandbox); without, the local sandbox.
218
249
  const l = login();
219
- const r = l ? await post(`${l.portal.replace(/\/$/, '')}/engine/portal/v1/verify`, JSON.stringify({ files }), 'application/json', { Authorization: 'Bearer ' + l.key })
220
- : await post(`${rgs}/engine/v1/verify`, JSON.stringify({ files }));
250
+ const body = JSON.stringify({ files });
251
+ // A pack bigger than the hosted pre-check takes is not sent: the front end
252
+ // drops a request that size before House Studio can answer its 413, which
253
+ // would read as a network failure. It is left to the upload's own check.
254
+ if (l && Buffer.byteLength(body) > PRECHECK_MAX) {
255
+ if (preCheckTooLarge(beforeUpload)(`the maths are ${Math.ceil(Buffer.byteLength(body) / 1048576)} MB as JSON, and it takes ${PRECHECK_MAX / 1048576} MB at most`) === null) return;
256
+ }
257
+ const r = l ? await post(`${l.portal.replace(/\/$/, '')}/engine/portal/v1/verify`, body, 'application/json', { Authorization: 'Bearer ' + l.key }, { tooLarge: preCheckTooLarge(beforeUpload) })
258
+ : await post(`${rgs}/engine/v1/verify`, body);
259
+ if (!r) return;
221
260
  if (!r.ok) fail('math does not verify:\n ' + r.errors.join('\n '));
222
261
  console.log(`math ok hash ${r.hash}`);
223
262
  const pct = (bp) => (Number(bp) / 100).toFixed(4) + '%';
224
- for (const m of r.report.modes) for (const b of m.bets) console.log(` ${m.id}/${b.id}: RTP ${pct(b.rtp)} hit ${pct(b.hitRate)} max ${b.maxMult}×${b.stops ? ' stops ' + b.stops.map(pct).join(' ') : ''}`);
263
+ for (const m of r.report.modes) for (const b of m.bets) console.log(` ${m.id}/${b.id}: RTP ${pct(b.rtp)}${b.baseRtp ? ` (base ${pct(b.baseRtp)} + meter ${m.meter})` : ''} hit ${pct(b.hitRate)} max ${b.maxMult}×${b.stops ? ' stops ' + b.stops.map(pct).join(' ') : ''}`);
264
+ // Meters (GUIDE §2.2): what each adds to every bet of its modes, exactly.
265
+ for (const mt of r.report.meters || []) console.log(` meter ${mt.id}: fill ${mt.fill} award ${mt.awardEv}× the reference on average, up to ${mt.awardMax}× adds ${pct(mt.rtp)}`);
225
266
  for (const w of r.report.warnings || []) console.log(' warning: ' + w);
226
267
  box.child?.kill();
227
268
  }
@@ -249,17 +290,40 @@ async function dev() {
249
290
  }
250
291
 
251
292
  async function upload() {
252
- await verify();
293
+ await verify({ beforeUpload: true });
253
294
  lint();
254
295
  const p = project();
255
296
  const { slug, site } = p;
256
297
  const { base, auth } = games(p);
257
298
  const g = await post(base, JSON.stringify({ slug, title: p.title || slug }), 'application/json', auth);
258
- const tgz = execFileSync('tar', ['-czf', '-', 'math', 'bundle'], { cwd: dir, env: TAR_ENV, maxBuffer: 200 * 1024 * 1024 });
259
- let v = await post(`${base}/${slug}/uploads`, tgz, 'application/gzip', auth);
299
+ const tempDir = mkdtempSync(join(tmpdir(), 'hbengine-upload-'));
300
+ const archive = join(tempDir, 'archive.tgz');
301
+ let v;
302
+ try {
303
+ execFileSync('tar', ['-czf', archive, 'math', 'bundle'], { cwd: dir, env: TAR_ENV });
304
+ const { size } = statSync(archive);
305
+ const hash = createHash('sha256');
306
+ for await (const chunk of createReadStream(archive)) hash.update(chunk);
307
+ process.stdout.write('upload ');
308
+ try { v = await uploadArchive({
309
+ file: archive,
310
+ size,
311
+ sha256: hash.digest('hex'),
312
+ base,
313
+ slug,
314
+ auth,
315
+ headers: UA,
316
+ onProgress: () => process.stdout.write('.')
317
+ }); } catch (error) {
318
+ throw new Error(await uploadFailure(error, { base, slug, auth, headers: UA }));
319
+ }
320
+ console.log();
321
+ } finally {
322
+ rmSync(tempDir, { recursive: true, force: true });
323
+ }
260
324
  console.log(`version ${v.n} ${v.state}${v.hash ? ' math ' + v.hash.slice(0, 12) : ''}`);
261
325
  if (v.state === 'failed') fail(report(v));
262
- console.log('the sandbox plays it now on desktop and phone (about two minutes; hbengine status shows the state meanwhile)');
326
+ console.log('the sandbox checks desktop and phone, then the date/endurance matrix (usually two to ten minutes; hbengine status shows progress)');
263
327
  process.stdout.write('conformance run ');
264
328
  for (let waited = 0; v.run && v.run.detail === 'running'; waited += 2) {
265
329
  if (waited > 600) fail('\nthe run is still going after ten minutes: hbengine status');
@@ -389,18 +453,10 @@ async function storeCmd() {
389
453
  const j = await r.json().catch(() => ({}));
390
454
  if (r.status === 404) fail(`${p.slug} is not on the studio platform yet: hbengine upload creates it`);
391
455
  if (!r.ok) fail(j.error || `HTTP ${r.status}`);
392
- const tile = ['tile.png', 'tile.webp'].map((t) => join(dir, t)).find(existsSync);
393
- let kept = ''; // why the tile stayed as it was
394
- if (tile) {
395
- const t = await fetch(`${base}/${p.slug}/tile`, { method: 'PUT', headers: { ...UA, ...auth }, body: readFileSync(tile) });
396
- const why = t.ok ? '' : (await t.json().catch(() => ({}))).error || `HTTP ${t.status}`;
397
- // 409: the game is live and its tile is the one review saw (until Housebets reset it).
398
- // The page is saved; the tile staying is said, not an error, or every store of a live game fails.
399
- if (t.status === 409) kept = why;
400
- else if (!t.ok) fail('tile: ' + why);
401
- }
402
- const missing = tile ? (j.missing || []).filter((m) => m !== 'a tile') : j.missing || [];
403
- console.log(`store page saved${tile && !kept ? ' with the tile' : ''}.${kept ? ' Tile unchanged: ' + kept : ''}${missing.length ? ' Before review it needs: ' + missing.join(', ') + '.' : ' Ready for review: hbengine submit.'}`);
456
+ // The tile is ours: Housebets makes it from the game's art when a version goes live.
457
+ const tile = ['tile.png', 'tile.webp'].find((t) => existsSync(join(dir, t)));
458
+ const missing = j.missing || [];
459
+ console.log(`store page saved.${missing.length ? ' Before review it needs: ' + missing.join(', ') + '.' : ' Ready for review: hbengine submit.'}${tile ? ` ${tile} was not sent: Housebets makes the tile from your game's art when a version goes live.` : ''}`);
404
460
  }
405
461
 
406
462
  async function submit() {
@@ -515,6 +571,7 @@ async function feedback() {
515
571
  console.log(`ticket #${r.id} sent to Housebets${game ? ` (about ${game})` : ''}. The answer comes by email and in hbengine tickets.\n ${r.url}`);
516
572
  }
517
573
 
574
+
518
575
  async function tickets() {
519
576
  const { base, auth } = support();
520
577
  const r = await get(base, auth);
@@ -529,13 +586,26 @@ async function tickets() {
529
586
  }
530
587
 
531
588
  // The upload's conformance run, offline against engine-dev (needs Chrome
532
- // and playwright-core: npm install playwright-core).
589
+ // and playwright-core: npm install playwright-core). With --time-travel, the
590
+ // fake-clock matrix the upload plays after it too (every variant, the clock
591
+ // jumps and the long session: several minutes), so a version that passes here
592
+ // meets the same checks there. An engine-dev with no wallet fault stub has
593
+ // the outage check skipped, as the hosted upload does (outage.mjs).
533
594
  async function test() {
534
595
  const { slug, rgs } = project();
535
596
  const box = await sandbox(rgs);
536
597
  if (!box.offline) fail(`${rgs} is not engine-dev: with a sandbox, hbengine upload runs these checks`);
537
598
  process.on('exit', () => box.child?.kill());
538
- const r = spawnSync('node', [join(PKG, 'engine-conformance', 'run.mjs'), join(dir, 'bundle'), '--slug', slug, '--game-id', '0', '--rgs', rgs], { stdio: 'inherit' });
599
+ const travel = flag('time-travel') ? ['--time-travel', 'all', '--manifest', join(dir, 'math', 'manifest.json')] : [];
600
+ // `--clock plus7d=2026-11-05T14:00:00.000Z,Asia/Tokyo,ja`: one variant at the clock an
601
+ // upload's report names (the upload draws its own), replayed against the baseline.
602
+ const clock = opt('clock', '');
603
+ if (clock) {
604
+ const m = /^([a-z0-9]+)=([^,]+),([^,]+),([^,]+)$/.exec(clock) || fail('--clock <variant>=<instant>,<zone>,<language>, as the report line names it');
605
+ if (!travel.length) fail('--clock replays a time-travel variant: add --time-travel');
606
+ travel.push('--travel-clocks', JSON.stringify({ [m[1]]: { at: m[2], tz: m[3], locale: m[4] } }), '--variants', `base,${m[1]}`);
607
+ }
608
+ const r = spawnSync('node', [join(PKG, 'engine-conformance', 'run.mjs'), join(dir, 'bundle'), '--slug', slug, '--game-id', '0', '--rgs', rgs, ...(await outageArgs(rgs)), ...travel], { stdio: 'inherit' });
539
609
  process.exit(r.status ?? 1);
540
610
  }
541
611
 
@@ -544,13 +614,13 @@ async function test() {
544
614
  // check it. The store page travels on its own (hbengine store, or by hand).
545
615
  async function pack() {
546
616
  const p = project();
547
- await verify();
617
+ await verify({ beforeUpload: true });
548
618
  lint();
549
619
  mkdirSync(join(dir, 'dist'), { recursive: true });
550
620
  const out = join(dir, 'dist', `${p.slug}-${new Date().toISOString().slice(0, 10)}.tgz`);
551
621
  execFileSync('tar', ['-czf', out, '-C', dir, 'math', 'bundle'], { env: TAR_ENV });
552
622
  const sum = createHash('sha256').update(readFileSync(out)).digest('hex');
553
- const store = ['store.json', 'tile.png', 'tile.webp'].filter((f) => existsSync(join(dir, f)));
623
+ const store = ['store.json'].filter((f) => existsSync(join(dir, f)));
554
624
  console.log(`${out}\nsha256 ${sum}\nsend both; the receiver uploads the .tgz in the dashboard (the game's page, New version).` + (store.length ? `\nthe store page is not in it: send ${store.join(' and ')} too.` : ''));
555
625
  }
556
626
 
@@ -561,5 +631,5 @@ function templates() {
561
631
  const mcp = async () => (await import('../engine-mcp/server.mjs')).serve();
562
632
  const version = () => console.log(VERSION);
563
633
  const commands = { init, verify, lint, dev, test, pack, upload, status, stats, store: storeCmd, submit, live, preflight, review, reply, feedback, tickets, login: loginCmd, connect, mcp, templates, version };
564
- if (!commands[cmd]) fail('usage: hbengine init|verify|lint|dev|test|pack|upload|status|stats|store|submit|live|preflight|review|reply|feedback|tickets|connect|login|mcp|templates|version [dir]');
634
+ if (!commands[cmd]) fail('usage: hbengine init|verify|lint|dev|test|pack|upload|status|stats|store|submit|live|preflight|review|reply|feedback|tickets|support|admin|connect|login|mcp|templates|version [dir]');
565
635
  await commands[cmd]();
@@ -0,0 +1,9 @@
1
+ // The upload's outage check arms engine-dev's wallet fault stub (POST /dev/fault). A kit
2
+ // whose engine-dev has none failed every game on it, for the sandbox and not the game: an
3
+ // engine-dev that answers 404 there gets the run's --skip-outage, and the check reads as
4
+ // skipped, as on the hosted upload. Asked with HEAD, so asking never arms a fault; any
5
+ // answer but a clear 404 leaves the run to arm it and judge.
6
+ export async function outageArgs(rgs, fetch = globalThis.fetch) {
7
+ const status = await fetch(`${rgs}/dev/fault`, { method: 'HEAD' }).then((r) => r.status).catch(() => 0);
8
+ return status === 404 ? ['--skip-outage'] : [];
9
+ }
@@ -6,6 +6,7 @@ export const TEMPLATES = {
6
6
  lines: 'five reels, paylines, scatters and free spins, built by the maths kit',
7
7
  tumble: 'a 6 × 5 scatter-pays grid with tumbles, free spins with multipliers (the maths kit)',
8
8
  plinko: 'rows and risk levels, each bucket weighted by the binomial',
9
+ 'plinko-jar': 'plinko with a jar that fills with every stake and pays a bonus when full (a meter)',
9
10
  keno: '40 numbers, 10 drawn, 1–10 picks; exact hypergeometric weights',
10
11
  crash: 'a ladder that climbs by itself: cash out before it busts, 99% at every level',
11
12
  mines: 'a 5 × 5 grid, 1–24 mines: reveal tiles, cash out; exact weights per reveal',
@@ -0,0 +1,18 @@
1
+ // A failed upload is not permission to upload the same archive again: the
2
+ // server may have accepted it before the response was lost. Read status once
3
+ // for context, with a short deadline, and leave any retry to the studio.
4
+ export async function uploadFailure(error, { base, slug, auth = {}, headers = {}, fetchImpl = fetch }) {
5
+ let checking = [];
6
+ try {
7
+ const r = await fetchImpl(`${base}/${slug}`, { headers: { ...headers, ...auth }, signal: AbortSignal.timeout(3000) });
8
+ if (r.ok) {
9
+ const game = await r.json();
10
+ checking = (Array.isArray(game.uploads) ? game.uploads : []).filter(v =>
11
+ Number.isInteger(v.n) && v.n > 0 && ['uploaded', 'verified'].includes(v.state)).map(v => v.n).slice(-10);
12
+ }
13
+ } catch { /* status is optional; retain the original failure */ }
14
+ const message = String(error.message || 'Upload failed').replace(/https?:\/\/[^\s?#]+[?#][^\s]*/g, '[upload URL redacted]');
15
+ const running = checking.length ? ` Version${checking.length > 1 ? 's' : ''} ${checking.map(n => `v${n}`).join(', ')} still have checks running.` : '';
16
+ const kind = error.status === 429 || error.status === 503 ? ' The service is busy; follow any retry guidance above.' : '';
17
+ return `${message}.${running}${kind} Run hbengine status for this project before retrying: an upload may have been accepted even if its response was lost. Do not repeatedly upload the same version.`;
18
+ }
@@ -0,0 +1,172 @@
1
+ import { createReadStream } from 'node:fs';
2
+
3
+ const RETRIES = 5;
4
+
5
+ const json = async (response) => {
6
+ const text = await response.text();
7
+ let body;
8
+ try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
9
+ return { response, body };
10
+ };
11
+
12
+ const errorMessage = (url, response, body) => body.error || `${url}: HTTP ${response.status}`;
13
+
14
+ const responseError = (url, response, body) => {
15
+ const error = new Error(errorMessage(url, response, body));
16
+ error.status = response.status;
17
+ return error;
18
+ };
19
+
20
+ const sessionExpiredError = () => {
21
+ const error = new Error('Upload session expired or was cancelled; run hbengine upload again.');
22
+ error.sessionExpired = true;
23
+ return error;
24
+ };
25
+
26
+ const isSessionExpired = (status) => status === 404 || status === 410;
27
+
28
+ const requestJson = async (url, options = {}) => {
29
+ const { response, body } = await json(await fetch(url, options));
30
+ if (!response.ok) throw responseError(url, response, body);
31
+ return body;
32
+ };
33
+
34
+ const readChunk = async (file, start, end) => {
35
+ const parts = [];
36
+ for await (const part of createReadStream(file, { start, end })) parts.push(part);
37
+ return Buffer.concat(parts);
38
+ };
39
+
40
+ const rangeEnd = (range) => {
41
+ const match = /^bytes=(?:\d+)-(\d+)$/.exec(range || '');
42
+ return match ? Number(match[1]) : -1;
43
+ };
44
+
45
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46
+
47
+ async function committed(fileUrl, size) {
48
+ const response = await fetch(fileUrl, {
49
+ method: 'PUT',
50
+ headers: { 'Content-Length': '0', 'Content-Range': `bytes */${size}` },
51
+ body: ''
52
+ });
53
+ if (response.status === 200 || response.status === 201) return size;
54
+ if (response.status === 308) return rangeEnd(response.headers.get('range')) + 1;
55
+ const { body } = await json(response);
56
+ if (isSessionExpired(response.status)) throw sessionExpiredError();
57
+ throw responseError(fileUrl, response, body);
58
+ }
59
+
60
+ const retryDelay = (attempt) => Math.min(1000, 100 * 2 ** attempt);
61
+
62
+ const retryableError = (error) => !error.sessionExpired && (error.status === undefined || (error.status >= 500 && error.status <= 599));
63
+
64
+ const recoverOffset = async (uploadUrl, size, attempt) => {
65
+ let lastError;
66
+ while (attempt < RETRIES) {
67
+ await sleep(retryDelay(attempt++));
68
+ try {
69
+ return { next: await committed(uploadUrl, size), attempt };
70
+ } catch (error) {
71
+ if (!retryableError(error)) throw error;
72
+ lastError = error;
73
+ }
74
+ }
75
+ throw lastError;
76
+ };
77
+
78
+ async function putChunks(file, size, session, onProgress = () => {}) {
79
+ const { uploadUrl, chunkBytes } = session;
80
+ if (typeof uploadUrl !== 'string' || !uploadUrl || !Number.isInteger(chunkBytes) || chunkBytes <= 0) {
81
+ throw new Error('upload session returned an invalid uploadUrl or chunkBytes');
82
+ }
83
+ let offset = 0;
84
+ // 308s in a row that kept nothing: storage that takes a chunk without
85
+ // keeping it would otherwise be sent the same bytes forever.
86
+ let stalls = 0;
87
+ while (offset < size) {
88
+ const end = Math.min(offset + chunkBytes, size) - 1;
89
+ let attempt = 0;
90
+ while (true) {
91
+ let response;
92
+ try {
93
+ const body = await readChunk(file, offset, end);
94
+ response = await fetch(uploadUrl, {
95
+ method: 'PUT',
96
+ headers: {
97
+ 'Content-Length': String(body.length),
98
+ 'Content-Range': `bytes ${offset}-${end}/${size}`
99
+ },
100
+ body
101
+ });
102
+ } catch (error) {
103
+ if (attempt >= RETRIES) throw error;
104
+ const recovery = await recoverOffset(uploadUrl, size, attempt);
105
+ attempt = recovery.attempt;
106
+ const next = recovery.next;
107
+ if (next >= size) { offset = size; onProgress(size, size); break; }
108
+ if (next > end) { offset = next; onProgress(offset, size); break; }
109
+ offset = next;
110
+ continue;
111
+ }
112
+ if (response.status === 200 || response.status === 201) {
113
+ offset = size;
114
+ onProgress(offset, size);
115
+ break;
116
+ }
117
+ if (response.status === 308) {
118
+ const next = rangeEnd(response.headers.get('range')) + 1;
119
+ if (next > offset) stalls = 0;
120
+ else if (++stalls > RETRIES) throw new Error(`${uploadUrl}: storage is not keeping the upload; run hbengine upload again`);
121
+ offset = Math.min(next, size);
122
+ onProgress(offset, size);
123
+ break;
124
+ }
125
+ if (response.status < 500 || response.status > 599) {
126
+ const { body } = await json(response);
127
+ if (isSessionExpired(response.status)) throw sessionExpiredError();
128
+ throw responseError(uploadUrl, response, body);
129
+ }
130
+ await response.text().catch(() => {});
131
+ if (attempt >= RETRIES) throw new Error(`${uploadUrl}: HTTP ${response.status}`);
132
+ const recovery = await recoverOffset(uploadUrl, size, attempt);
133
+ attempt = recovery.attempt;
134
+ const next = recovery.next;
135
+ if (next >= size) { offset = size; onProgress(size, size); break; }
136
+ if (next > end) { offset = next; onProgress(offset, size); break; }
137
+ offset = next;
138
+ }
139
+ }
140
+ }
141
+
142
+ /** Upload an archive using the direct-storage contract, with legacy fallback. */
143
+ export async function uploadArchive({ file, size, sha256, base, slug, auth = {}, headers = {}, onProgress }) {
144
+ const sessionUrl = `${base}/${slug}/upload-sessions`;
145
+ let session;
146
+ try {
147
+ session = await requestJson(sessionUrl, {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json', ...headers, ...auth },
150
+ body: JSON.stringify({ size, sha256 })
151
+ });
152
+ } catch (error) {
153
+ if (error.status !== 404 && error.status !== 405) throw error;
154
+ const response = await fetch(`${base}/${slug}/uploads`, {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/gzip', 'Content-Length': String(size), ...headers, ...auth },
157
+ body: createReadStream(file),
158
+ duplex: 'half'
159
+ });
160
+ const result = await json(response);
161
+ if (!response.ok) throw new Error(errorMessage(`${base}/${slug}/uploads`, response, result.body));
162
+ onProgress?.(size, size);
163
+ return result.body;
164
+ }
165
+
166
+ await putChunks(file, size, session, onProgress);
167
+ return requestJson(`${sessionUrl}/${session.id}/complete`, {
168
+ method: 'POST',
169
+ headers: { ...headers, ...auth }
170
+ });
171
+ }
172
+