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-cli/hbengine.mjs
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
259
|
-
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
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'
|
|
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
|
+
}
|
package/engine-cli/templates.mjs
CHANGED
|
@@ -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
|
+
|