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
@@ -158,13 +158,15 @@ export const RULES = Object.freeze({
158
158
  minified: { level: 'fail', text: 'minified or obfuscated (nine in ten names are one or two letters) with no source map in the bundle: a reviewer cannot read it; ship readable code, or the .map with sourcesContent beside it' },
159
159
  obfuscated: { level: 'fail', text: 'obfuscated: names or strings written to defeat a reader' },
160
160
  'wasm-source': { level: 'fail', text: 'WebAssembly: a module the analyser cannot read; only a known library\'s released module (by content hash) run by that library\'s own code is accepted (GUIDE §3.6)' },
161
- analyser: { level: 'fail', text: 'the code could not be analysed in time' },
161
+ 'unknown-library': { level: 'warn', text: 'under vendor/ but not a known library release (its bytes\' SHA-256): it is read as the game\'s own code, minified or not; ship the library\'s released file unchanged (GUIDE §3.5), or its source map' },
162
+ 'unreleased-library': { level: 'warn', text: 'claims a known library by its name and banner, but its bytes are none of that library\'s releases we know: it is read as the game\'s own code; ship the released file unchanged, or ask Housebets to add this release' },
163
+ analyser: { level: 'fail', text: 'the code could not be analysed in time. Large minified code does this: ship it readable, or with its source map (sourcesContent included), and libraries as their released files under vendor/ (GUIDE §3.5, §3.7); what could be told without the full analysis is listed beside this' },
162
164
  clock: { level: 'warn', text: 'reads the date or time (Date, Temporal, performance.timeOrigin): review checks nothing depends on the day' },
163
165
  timezone: { level: 'warn', text: 'reads the time zone, or formats by locale (getTimezoneOffset, Intl.DateTimeFormat, toLocale…)' },
164
166
  locale: { level: 'warn', text: "reads the language outside the SDK (navigator.language, Intl): engine.config.locale is the player's language" },
165
167
  fingerprint: { level: 'warn', text: "reads the browser's identity (userAgent, platform, hardware, screen depth, webdriver): review checks no behaviour depends on it" },
166
168
  timer: { level: 'warn', text: 'a timer past 60 s, or with a delay computed at run time: review checks what fires then' },
167
- 'local-storage': { level: 'warn', text: 'keeps state on the device (localStorage, sessionStorage, indexedDB, cookies): per device only, and a counter kept there can fire after N rounds; anything a player keeps across devices goes through engine.state' },
169
+ 'local-storage': { level: 'warn', text: 'keeps state on the device (localStorage, sessionStorage, indexedDB, cookies): per device only and empty again in every new version (each version has its own address), and a counter kept there can fire after N rounds; progress a player keeps goes in engine.progress (GUIDE §3.1)' },
168
170
  'input-element': { level: 'warn', text: 'creates a text field or a form: a game takes nothing typed but the stake; review checks it asks for no login, password or key' },
169
171
  'element-name': { level: 'warn', text: 'creates an element named at run time (createElement(name)): could be a script or a field; review checks the names' },
170
172
  'markup-parts': { level: 'warn', text: 'builds markup with a computed part where a tag or an attribute name goes, or as an unquoted attribute value (`<${tag}>`, `<img ${attrs}>`, `src=${u}`), or puts a value into the page one of whose alternatives is not written down (`ART[s] || s`): a part carrying an attribute would run; review checks what each part can be' },
@@ -563,7 +565,8 @@ class Analysis {
563
565
  this.resolving = new Set(); // nodes being resolved (a cycle adds nothing)
564
566
  this.fallback = new Set(); // nodes re-entered in a call's scope, being read in their own scope (resolveStatic)
565
567
  this.spreading = new Set(); // object literals being read for their members (a spread of itself adds nothing)
566
- this.cache = new Map(); // node → leaves, for a node read in its own scope
568
+ this.cache = new Map(); // node → leaves, for a node read in its own scope (and readsOf's answers, under readKeys)
569
+ this.readKeys = new WeakMap(); // read node → its key in cache for readsOf
567
570
  this.mentioned = new Map(); // node → Map(position → the global its syntax (and its names' values) mentions, or null) (globalMentioned)
568
571
  this.bases = new Map(); // a member chain's node → the name it stands on (baseName, kept: a chain as long as a file is asked at every link)
569
572
  this.globalNames = new Map(); // node → what globalName answered, in its own scope, once the scopes are built
@@ -1260,15 +1263,66 @@ class Analysis {
1260
1263
  const b = scope.resolve(x.name);
1261
1264
  const global = b ? (b.alias === 'name' && (GLOBAL_OBJECTS.has(b.name) || HOST_NAMES.has(b.name)) ? b.name : null) : x.name;
1262
1265
  if (!b && !global) return [];
1266
+ // The element writes are indexed by what they write to, once per file and
1267
+ // once for the pool (a large library asks for one list thousands of times;
1268
+ // reading every write each time cost PixiJS's bundle most of its deadline).
1269
+ // A name that is both a binding and a global (an alias of the window) reads
1270
+ // the written lists in their order, as one pass over every write would.
1271
+ // (Kept on the write index it is read from: every reset of that, a pass or the wiring, starts a new one.)
1272
+ const byKey = this.writeIndex(a);
1273
+ const local = this.elementWriteIndex(byKey, () => byKey.get('*') ?? []);
1274
+ const pooled = global != null && a.pool?.writes ? this.elementWriteIndex(a.pool, () => a.pool.writes.get('*') ?? []) : null;
1263
1275
  const out = [];
1264
- for (const w of [...(this.writeIndex(a).get('*') ?? []), ...(global != null ? a.pool?.writes?.get('*') ?? [] : [])]) {
1265
- const wt = this.targetOf(w);
1266
- if (!wt || wt.path.length || wt.key != null) continue;
1267
- if ((b && wt.binding === b) || (global != null && wt.global === global)) out.push({ value: w.value, scope: w.scope });
1268
- if (out.length > EXACT_VALUES * 4) break;
1269
- }
1276
+ const take = (index) => {
1277
+ const ws = b && global != null ? index.all.filter((w) => w.target.binding === b || w.target.global === global)
1278
+ : b ? index.byBinding.get(b) ?? [] : index.byGlobal.get(global) ?? [];
1279
+ for (const w of ws) {
1280
+ if (out.length > EXACT_VALUES * 4) return;
1281
+ out.push({ value: w.value, scope: w.scope });
1282
+ }
1283
+ };
1284
+ take(local);
1285
+ if (pooled && out.length <= EXACT_VALUES * 4) take(pooled);
1270
1286
  return out;
1271
1287
  }
1288
+ // The writes of an element (no path, no key) out of a list of '*' writes, by
1289
+ // their binding and by their global, in the list's order: kept on its owner.
1290
+ elementWriteIndex(owner, list) {
1291
+ if (!owner.elementWriteLists) {
1292
+ const all = [], byBinding = new Map(), byGlobal = new Map();
1293
+ for (const w of list()) {
1294
+ const wt = this.targetOf(w);
1295
+ if (!wt || wt.path.length || wt.key != null) continue;
1296
+ all.push(w);
1297
+ if (wt.binding) { if (!byBinding.has(wt.binding)) byBinding.set(wt.binding, []); byBinding.get(wt.binding).push(w); }
1298
+ if (wt.global != null) { if (!byGlobal.has(wt.global)) byGlobal.set(wt.global, []); byGlobal.get(wt.global).push(w); }
1299
+ }
1300
+ owner.elementWriteLists = { all, byBinding, byGlobal };
1301
+ }
1302
+ return owner.elementWriteLists;
1303
+ }
1304
+ // The writes of one key out of a writes-by-key map that a read on `global`
1305
+ // (a global name, or null) can reach: those on that global, or those on no
1306
+ // global, in the list's order. Split once per key, kept on the map (a new
1307
+ // map, after a pass or the wiring, splits afresh). A write the analyser
1308
+ // cannot place reaches nothing and is left out.
1309
+ keyWrites(byKey, key, global) {
1310
+ const list = byKey.get(key);
1311
+ if (!list) return [];
1312
+ if (!byKey.split) byKey.split = new Map();
1313
+ let s = byKey.split.get(key);
1314
+ if (!s) {
1315
+ s = { byGlobal: new Map(), none: [] };
1316
+ for (const w of list) {
1317
+ const wt = this.targetOf(w);
1318
+ if (!wt) continue;
1319
+ if (wt.global == null) s.none.push(w);
1320
+ else { if (!s.byGlobal.has(wt.global)) s.byGlobal.set(wt.global, []); s.byGlobal.get(wt.global).push(w); }
1321
+ }
1322
+ byKey.split.set(key, s);
1323
+ }
1324
+ return global != null ? s.byGlobal.get(global) ?? [] : s.none;
1325
+ }
1272
1326
  // This file's writes by key ('*' for a computed key or an element), indexed once.
1273
1327
  writeIndex(a) {
1274
1328
  if (!a.writesByKey) { a.writesByKey = new Map(); for (const w of a.writes) { if (!this.targetOf(w)) continue; const key = w.key ?? '*'; if (!a.writesByKey.has(key)) a.writesByKey.set(key, []); a.writesByKey.get(key).push(w); } }
@@ -1326,7 +1380,26 @@ class Analysis {
1326
1380
  // found at run time by a lookup the analyser could not place), with the
1327
1381
  // same key. A write on a fresh object of the code (new Image(), a literal)
1328
1382
  // reaches only reads through the same name.
1383
+ //
1384
+ // Kept per read node, as the resolver keeps its answers (resolve), and in
1385
+ // its cache, so every clearing of that (a pass, the wiring, the escapes)
1386
+ // clears these too: for a read in its own file and in a scope that is not
1387
+ // a call's, once the names are bound (not in pass 1), and only when the
1388
+ // budget lasted the whole reading. A large bundle asks for the same read
1389
+ // millions of times.
1329
1390
  readsOf(n, scope) {
1391
+ const cacheable = !scope.dynamic && !this.pass1 && (scope.analysis ?? this) === this;
1392
+ let key;
1393
+ if (cacheable) {
1394
+ key = this.readKeys.get(n);
1395
+ if (!key) this.readKeys.set(n, (key = { readsOf: n }));
1396
+ if (this.cache.has(key)) return this.cache.get(key);
1397
+ }
1398
+ const out = this.readsOfIn(n, scope);
1399
+ if (cacheable && this.steps > 0) this.cache.set(key, out);
1400
+ return out;
1401
+ }
1402
+ readsOfIn(n, scope) {
1330
1403
  const read = this.target(n, scope);
1331
1404
  if (!read) return [];
1332
1405
  const a = scope.analysis ?? this;
@@ -1335,13 +1408,17 @@ class Analysis {
1335
1408
  // written key reads every write of that key, in this file and (the pool) the others.
1336
1409
  this.writeIndex(a);
1337
1410
  // (A push, unshift or add writes an element, which an element read (no key) reads; a property read never does.)
1338
- if (read.key != null) candidates.push(...(a.writesByKey.get(read.key) ?? []));
1411
+ // A read on a global name reaches only writes on that global, and any other read only writes on no global
1412
+ // (reaches): the key's writes are taken already split so, in their order.
1413
+ if (read.key != null) candidates.push(...this.keyWrites(a.writesByKey, read.key, read.global));
1339
1414
  else if (read.binding) for (const list of a.writesByKey.values()) for (const w of list) if (w.target.binding === read.binding) candidates.push(w);
1340
- if (a.pool?.writes && read.key != null) candidates.push(...(a.pool.writes.get(read.key) ?? []));
1415
+ if (a.pool?.writes && read.key != null) candidates.push(...this.keyWrites(a.pool.writes, read.key, read.global));
1341
1416
  const out = [];
1342
1417
  const seen = new Set();
1343
- // (The read's place is found once; a write's once ever, kept on the write.)
1344
- read.elements = candidates.length ? this.staticElements(read) : null;
1418
+ // (The read's place is found once, when the key has any write at all, split or not, as the budget it spends
1419
+ // was spent before the split; a write's once ever, kept on the write.)
1420
+ const any = read.key != null ? (a.writesByKey.get(read.key)?.length || a.pool?.writes?.get(read.key)?.length) : candidates.length;
1421
+ read.elements = any ? this.staticElements(read) : null;
1345
1422
  for (const w of candidates) {
1346
1423
  if (seen.has(w) || out.length > EXACT_VALUES * 4) continue;
1347
1424
  seen.add(w);
@@ -2022,6 +2099,14 @@ class Analysis {
2022
2099
  // a walk each. A node in a call's scope (a parameter bound at one call
2023
2100
  // site) is read each time, in that scope. A cycle adds nothing; an answer
2024
2101
  // that leaned on a node still being read is not kept.
2102
+ //
2103
+ // A reading cut at MENTION_STEPS answers 'window', the cautious answer, and
2104
+ // that answer is kept for the node and position it was asked of: asked
2105
+ // again, the same reading would spend the same steps to be cut the same
2106
+ // way. (A large library asks one such question thousands of times:
2107
+ // PixiJS spent 100,000 steps on each of 3,971 of its first 4,000, past any
2108
+ // deadline.) Kept cut answers are never narrower than a full reading, so
2109
+ // nothing the analyser refuses is let through.
2025
2110
  globalMentioned(n, scope, pos = null) {
2026
2111
  n = unchain(n);
2027
2112
  if (!n || !scope) return 'window';
@@ -2031,7 +2116,16 @@ class Analysis {
2031
2116
  // (The evaluation asks the resolver nothing, so it never re-enters; were it to, what it has not read may be anything.)
2032
2117
  if (this.mentioning) return 'window';
2033
2118
  this.mentioning = true;
2034
- try { return this.globalMentionedIn(n, scope, pos); } finally { this.mentioning = false; }
2119
+ this.mentionCut = false;
2120
+ try {
2121
+ const out = this.globalMentionedIn(n, scope, pos);
2122
+ if (this.mentionCut && !scope.dynamic) {
2123
+ let m = this.mentioned.get(n);
2124
+ if (!m) this.mentioned.set(n, (m = new Map()));
2125
+ m.set(key, 'window');
2126
+ }
2127
+ return out;
2128
+ } finally { this.mentioning = false; }
2035
2129
  }
2036
2130
  globalMentionedIn(root, rootScope, rootPos) {
2037
2131
  const memoGet = (x, k) => this.mentioned.get(x)?.get(k);
@@ -2056,7 +2150,7 @@ class Analysis {
2056
2150
  };
2057
2151
  while (stack.length) {
2058
2152
  // (Not read to the end: what is left may mention anything. Nothing is kept of it.)
2059
- if (steps++ > MENTION_STEPS) return 'window';
2153
+ if (steps++ > MENTION_STEPS) { this.mentionCut = true; return 'window'; }
2060
2154
  const f = stack[stack.length - 1];
2061
2155
  if (f.i < 0) {
2062
2156
  const x = f.node;
@@ -3170,6 +3264,9 @@ class Analysis {
3170
3264
  if (!FUNCTIONS.has(l.node.type)) continue;
3171
3265
  const owner = l.scope.analysis ?? this;
3172
3266
  if (!owner.scopes.has(l.node)) continue;
3267
+ // (A recognised library's call of the game's function, a callback it was handed: called from code
3268
+ // the game's analysis does not read into (bundle.mjs, the pool), so its parameters are unknown.)
3269
+ if (this.library && !owner.library) { owner.escaped.add(l.node); continue; }
3173
3270
  let s = owner.sites.get(l.node);
3174
3271
  if (!s) owner.sites.set(l.node, (s = []));
3175
3272
  if (s.some((x) => x.args === node.arguments)) continue;
@@ -4191,6 +4288,49 @@ export function matchSelector(elements, selector) {
4191
4288
  return out;
4192
4289
  }
4193
4290
 
4291
+ // A script's names counted the way the analyser counts them (the names it
4292
+ // declares: variables, parameters, functions, classes, catch bindings), from
4293
+ // its syntax alone, without reading what anything is. For the scan's report
4294
+ // when the analysis did not finish in time: whether a file is minified can be
4295
+ // told in milliseconds, and a studio told only "it took too long" cannot act.
4296
+ export function censusSource(source) {
4297
+ const out = { parsed: false, names: 0, short: 0, minified: false, sourceMappingURL: sourceMappingURL(source) };
4298
+ let ast = null;
4299
+ for (const sourceType of ['module', 'script']) {
4300
+ try { ast = parse(source, { ecmaVersion: 'latest', allowHashBang: true, sourceType }); break; } catch { /* the other */ }
4301
+ }
4302
+ if (!ast) return out;
4303
+ out.parsed = true;
4304
+ const count = (name) => { out.names++; if (name.length <= 2) out.short++; };
4305
+ const pattern = (p) => {
4306
+ if (!p) return;
4307
+ switch (p.type) {
4308
+ case 'Identifier': count(p.name); return;
4309
+ case 'ObjectPattern': for (const q of p.properties) pattern(q.type === 'RestElement' ? q.argument : q.value); return;
4310
+ case 'ArrayPattern': for (const q of p.elements) pattern(q); return;
4311
+ case 'RestElement': pattern(p.argument); return;
4312
+ case 'AssignmentPattern': pattern(p.left); return;
4313
+ }
4314
+ };
4315
+ const stack = [ast];
4316
+ while (stack.length) {
4317
+ const n = stack.pop();
4318
+ if (!n || typeof n.type !== 'string') continue;
4319
+ if (n.type === 'VariableDeclarator') pattern(n.id);
4320
+ else if (n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression') { if (n.id) count(n.id.name); for (const q of n.params) pattern(q); }
4321
+ else if ((n.type === 'ClassDeclaration' || n.type === 'ClassExpression') && n.id) count(n.id.name);
4322
+ else if (n.type === 'CatchClause') pattern(n.param);
4323
+ for (const k in n) {
4324
+ const v = n[k];
4325
+ if (k === 'loc' || k === 'start' || k === 'end') continue;
4326
+ if (Array.isArray(v)) { for (const c of v) if (c && typeof c.type === 'string') stack.push(c); }
4327
+ else if (v && typeof v.type === 'string') stack.push(v);
4328
+ }
4329
+ }
4330
+ out.minified = out.names >= LIMITS.minNames && out.short / out.names >= LIMITS.shortNameRatio;
4331
+ return out;
4332
+ }
4333
+
4194
4334
  // The //# sourceMappingURL=… comment, read from the tail of the file where
4195
4335
  // bundlers write it.
4196
4336
  function sourceMappingURL(source) {
@@ -64,7 +64,7 @@ export function analyseBundle(files, { read = (abs) => readFileSync(abs, 'utf8')
64
64
  for (const { target, kind: k } of scriptLoads(path, text)) if (k === 'script') classic.add(target);
65
65
  }
66
66
 
67
- for (const { path, abs, kind } of files) {
67
+ for (const { path, abs, kind, library = null } of files) {
68
68
  const result = { path, findings: [], units: [], wasm: [], sourceMappingURL: null };
69
69
  out.push(result);
70
70
  try {
@@ -101,6 +101,7 @@ export function analyseBundle(files, { read = (abs) => readFileSync(abs, 'utf8')
101
101
  } else {
102
102
  const unit = prepareSource(text, { mode: classic.has(path) ? 'script' : 'auto' });
103
103
  unit.path = path;
104
+ if (library) unit.analysis.library = library;
104
105
  units.push({ path, unit, page: null, result, inline: false });
105
106
  modules.set(path, unit);
106
107
  }
@@ -118,10 +119,13 @@ export function analyseBundle(files, { read = (abs) => readFileSync(abs, 'utf8')
118
119
  return jsons.get(path);
119
120
  };
120
121
  // What a module specifier names, from a file: the module (its analysis), a JSON file ({ json }), or null.
121
- const moduleFrom = (fromPath) => (specifier) => {
122
+ // (A recognised library imported by the game's own code is a module the analyser does not read into: null, as
123
+ // for the pool below.)
124
+ const moduleFrom = (fromPath, fromLibrary) => (specifier) => {
122
125
  const target = resolveSpecifier(fromPath, specifier);
123
126
  if (target == null) return null;
124
127
  const m = modules.get(target);
128
+ if (m && m.analysis.library && !fromLibrary) return null;
125
129
  if (m) return m.ast ? { analysis: m.analysis } : null;
126
130
  if (/\.json$/i.test(target)) { const v = jsonOf(target); return v === undefined ? null : { json: v }; }
127
131
  return null;
@@ -130,10 +134,23 @@ export function analyseBundle(files, { read = (abs) => readFileSync(abs, 'utf8')
130
134
  // The pool: every classic script's root bindings and every module's globals
131
135
  // (window.X = …) by name; every write by its key; every element the code
132
136
  // writes as markup.
137
+ //
138
+ // A recognised library (scan.mjs: a known name with its release banner,
139
+ // under vendor/) is read on its own, as if it were the bundle's only file,
140
+ // and adds nothing to the pool: what it sets on the global object and what
141
+ // it writes are not followed into its code from the game's files, so a
142
+ // value the game takes from it (new PIXI.Container(), gsap.to(…)) is a
143
+ // global no file sets, as if the library were not in the bundle. Followed,
144
+ // a library of a few megabytes is read for every value the game hands it,
145
+ // past any deadline (Pump Junkie: GSAP 1 s on its own, 59 s wired to PixiJS
146
+ // and the game), and a reading cut short in it is the global object
147
+ // whenever the library mentions one (everywhere): the game's own code
148
+ // refused for the library's. What the library itself would be refused for
149
+ // is named for review (scan.mjs) and blocked at run time by the policy.
133
150
  const pool = { globals: new Map(), writes: new Map(), written: [], writtenOverflow: false };
134
151
  for (const { unit } of units) {
135
152
  const a = unit.analysis;
136
- if (!unit.ast) continue;
153
+ if (!unit.ast || a.library) continue;
137
154
  for (const [name, binding] of a.root.names) {
138
155
  if (a.mode !== 'script' && !binding.global) continue;
139
156
  if (!binding.alias && !binding.hidden) continue;
@@ -150,6 +167,7 @@ export function analyseBundle(files, { read = (abs) => readFileSync(abs, 'utf8')
150
167
  else pool.written.push(...a.written);
151
168
  }
152
169
  if (pool.written.length > 100000) { pool.written = []; pool.writtenOverflow = true; }
170
+ const alone = { globals: new Map(), writes: new Map(), written: [], writtenOverflow: false }; // (a recognised library's: nothing of another file)
153
171
 
154
172
  // The pages a script runs in: its own for an inline script; those whose
155
173
  // <script src> names it for a file; those of its importers for a module
@@ -173,8 +191,8 @@ export function analyseBundle(files, { read = (abs) => readFileSync(abs, 'utf8')
173
191
  }
174
192
  for (const { path, unit } of units) {
175
193
  const a = unit.analysis;
176
- a.modules = moduleFrom(path);
177
- a.pool = pool;
194
+ a.modules = moduleFrom(path, !!a.library);
195
+ a.pool = a.library ? alone : pool;
178
196
  a.pages = [...(pagesOf.get(unit) ?? [])];
179
197
  }
180
198
 
@@ -0,0 +1,9 @@
1
+ // The quick census of every script (analyse.mjs censusSource), for scan.mjs
2
+ // when the full analysis ran out of time: whether each file is minified, and
3
+ // its source map comment, from its syntax alone. On a worker thread with its
4
+ // own deadline, like the analysis.
5
+ import { readFileSync } from 'node:fs';
6
+ import { parentPort, workerData } from 'node:worker_threads';
7
+ import { censusSource } from './analyse.mjs';
8
+
9
+ parentPort.postMessage(workerData.files.map((f) => ({ path: f.path, ...censusSource(readFileSync(f.abs, 'utf8')) })));
@@ -0,0 +1,19 @@
1
+ // The run's --enable-features switch. Chrome keeps only the last
2
+ // --enable-features it is given, and Playwright gives its own
3
+ // (CDPScreenshotNewSurface, its screenshot path, unless
4
+ // PLAYWRIGHT_LEGACY_SCREENSHOT is set), so the run's names Playwright's
5
+ // features too: chrome-flags.test.mjs reads Playwright's list, so an upgrade
6
+ // that adds one fails the test instead of being dropped here.
7
+ //
8
+ // OriginKeyedProcessesByDefault: a process per origin. In the job the shell
9
+ // and the game are both 127.0.0.1 (two ports, one site), so Chrome gave them
10
+ // one renderer and one main thread, and a game drawing in software kept the
11
+ // shell from answering the clock's jumps in 60 s (ticket #43, Abyssal Glow
12
+ // v17–v32: "2 of 3 window(s) answered", the shell the one missing). On the
13
+ // site they are two sites, two processes; this is that.
14
+ export const PLAYWRIGHT_FEATURES = ['CDPScreenshotNewSurface'];
15
+ export const RUN_FEATURES = ['OriginKeyedProcessesByDefault'];
16
+
17
+ export function enableFeatures(env = process.env) {
18
+ return '--enable-features=' + [...(env.PLAYWRIGHT_LEGACY_SCREENSHOT ? [] : PLAYWRIGHT_FEATURES), ...RUN_FEATURES].join(',');
19
+ }
@@ -0,0 +1,19 @@
1
+ // An error the run reports, as the studio reads it: whole up to 2,000
2
+ // characters (a stack, a source map's position, a studio's own diagnostic);
3
+ // longer, its start and its end with the cut marked, never just its first
4
+ // 160 characters. A page that throws in a loop keeps its first 100.
5
+ export const DIAGNOSTIC_TEXT_LIMIT = 2000;
6
+ export const DIAGNOSTIC_ENTRY_LIMIT = 100;
7
+
8
+ const CUT = ' … [truncated] … ';
9
+
10
+ export function diagnosticText(value) {
11
+ const text = String(value);
12
+ if (text.length <= DIAGNOSTIC_TEXT_LIMIT) return text;
13
+ const head = Math.floor((DIAGNOSTIC_TEXT_LIMIT - CUT.length) / 2);
14
+ return text.slice(0, head) + CUT + text.slice(-(DIAGNOSTIC_TEXT_LIMIT - CUT.length - head));
15
+ }
16
+
17
+ export function appendDiagnostic(list, value) {
18
+ if (list.length < DIAGNOSTIC_ENTRY_LIMIT) list.push(diagnosticText(value));
19
+ }
@@ -0,0 +1,52 @@
1
+ import { loadavg, cpus, freemem, totalmem } from 'node:os';
2
+ import { writeFileSync } from 'node:fs';
3
+
4
+ // Failure evidence must not hold up the next check when a renderer has stopped
5
+ // answering. Keep the report bounded and never copy launch URLs or messages.
6
+ async function bounded(work, fallback, ms = 1500) {
7
+ let timer;
8
+ try { return await Promise.race([Promise.resolve().then(work), new Promise((r) => { timer = setTimeout(() => r(fallback), ms); })]); }
9
+ catch { return fallback; }
10
+ finally { clearTimeout(timer); }
11
+ }
12
+
13
+ export function runnerInfo(browser) {
14
+ return { node: process.version, browser: browser.version(), platform: process.platform,
15
+ cpuCount: cpus().length, loadAverage: loadavg(), freeMemoryBytes: freemem(), totalMemoryBytes: totalmem() };
16
+ }
17
+
18
+ export function consoleRecorder(page, token) {
19
+ const lines = [];
20
+ page.on('console', (m) => {
21
+ if (lines.length >= 100) return;
22
+ const text = m.text();
23
+ if (text.startsWith(token + ':')) return;
24
+ // Console text is studio evidence, never instructions. Strip common
25
+ // credential forms and URL queries before storing it in the report.
26
+ lines.push({ level: m.type(), text: text.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
27
+ .replace(/(https?:\/\/[^\s?#]+)[?#][^\s]*/g, '$1[redacted]').slice(0, 500) });
28
+ });
29
+ return lines;
30
+ }
31
+
32
+ export function handshakeProbe(scope, say) {
33
+ scope.addEventListener('message', event => {
34
+ const d = event.data;
35
+ if (event.isTrusted && event.source === scope.parent && d?.hb === 1 && d.kind === 'evt' && d.type === 'init') say('received');
36
+ });
37
+ }
38
+
39
+ export async function failureEvidence(page, { console: lines, runner, started, shot, shotFile, log, initReceived = false }) {
40
+ const messages = await bounded(async () => (await log()).slice(-30).map((m) => ({
41
+ at: m.at, type: typeof m.d?.type === 'string' ? m.d.type.slice(0, 80) : '',
42
+ message: typeof m.d?.message === 'string' ? m.d.message.slice(0, 80) : ''
43
+ })), []);
44
+ const frames = page.frames().slice(0, 20).map((f) => {
45
+ try { const u = new URL(f.url()); return u.origin + u.pathname; } catch { return 'unknown'; }
46
+ });
47
+ const png = await bounded(() => page.screenshot({ timeout: 1200 }), null);
48
+ let saved = false;
49
+ if (png) { try { writeFileSync(shotFile, png); saved = true; } catch { /* report the missing image */ } }
50
+ return { elapsedMs: Date.now() - started, runner, console: lines.slice(), frames, initReceived,
51
+ messages, screenshot: saved ? shot : null, screenshotUnavailable: !saved };
52
+ }
@@ -0,0 +1,42 @@
1
+ // Record script focus before the first genuine input. Browser activation can
2
+ // already be live in an automated browser, so it is not proof of a first tap.
3
+ // This probe only observes calls; the shell's activation/focus gate is unchanged.
4
+ // Each way a page moves the focus by itself is said once, by its name:
5
+ // focus() on an HTML or SVG element or the window, a dialog's show() or
6
+ // showModal(), and an autofocus element put on the page.
7
+ export function focusProbe(scope, say) {
8
+ let touched = false;
9
+ let moving = 0;
10
+ const said = new Set();
11
+ const early = (what) => { if (!touched && !said.has(what)) { said.add(what); say(what); } };
12
+ const input = (event) => { if (event.isTrusted) touched = true; };
13
+ for (const type of ['pointerdown', 'touchstart', 'keydown']) scope.addEventListener(type, input, { capture: true, passive: true });
14
+ const wrap = (object, key, what) => {
15
+ if (!object || typeof object[key] !== 'function') return;
16
+ object[key] = new Proxy(object[key], { apply(target, self, args) {
17
+ early(what);
18
+ moving++;
19
+ try { return Reflect.apply(target, self, args); } finally { moving--; }
20
+ } });
21
+ };
22
+ wrap(scope.HTMLElement?.prototype, 'focus', 'focus()');
23
+ wrap(scope.SVGElement?.prototype, 'focus', 'focus()');
24
+ wrap(scope, 'focus', 'focus()');
25
+ wrap(scope.HTMLDialogElement?.prototype, 'show', 'show()');
26
+ wrap(scope.HTMLDialogElement?.prototype, 'showModal', 'showModal()');
27
+ // Native autofocus (including popovers and closed shadow roots) emits a
28
+ // composed focusin event. Observe the actual focus change rather than
29
+ // guessing from DOM insertion: detached/hidden autofocus cannot fail.
30
+ scope.addEventListener('focusin', event => {
31
+ if (event.isTrusted && !moving) early('autofocus');
32
+ }, { capture: true });
33
+
34
+ }
35
+
36
+ // focusVerdict: a fail naming each way the focus moved before the first tap;
37
+ // else a pass.
38
+ export function focusVerdict(said) {
39
+ const early = [...new Set(said)];
40
+ if (early.length) return { ok: false, detail: `script focus before the first tap (${early.join(', ')}): move the focus only after the player's first tap; a startup focus can block genuine bets for 5 seconds (GUIDE §3.1)` };
41
+ return { ok: true, detail: 'no script focus before the first tap' };
42
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * What a failing check means for the studio and what to change: one table, the
3
+ * same words wherever a check is read (the report the dashboard shows, `hbengine
4
+ * test`, the agent's engine_test, the mail). Written from the failures studios
5
+ * actually hit (every upload's report, 29 Oct 2026): a game that never said it
6
+ * was ready (Octo Feast, 10 versions), a frame it did not fit (Wolf Street, 6),
7
+ * a bet the run could not press (5), the clock jump no frame answered (Octo
8
+ * Feast, Wolf Street, Aurora Wheel, Pump Junkie: 7), a stand-in Bet control
9
+ * (Pump Junkie, 3). Plain rules on the check's own text, nothing guessed: a
10
+ * failure the table does not know gets no hint rather than a wrong one.
11
+ */
12
+
13
+ const TEST = 'Run `hbengine test` to see it on your machine before you upload.';
14
+ const TRAVEL = 'Run `hbengine test --time-travel` to see it on your machine before you upload; an upload plays each variant at a clock of its own, which `--clock <variant>=<instant>,<zone>,<language>` (as the line above names it) replays.';
15
+
16
+ const HINTS = [
17
+ {
18
+ rule: 'boot', match: /no ready within/,
19
+ hint: 'The game did not call engine.ready() in time. The check runs on a slow machine on purpose (Chrome draws on the CPU, with few animation frames), as a player on an old phone would. Call ready() as soon as connect() resolves and the first screen can show, not from requestAnimationFrame; load only what the first screen needs before it (assets.json, the boot group) and the rest after. GUIDE §6, §3.2. ' + TEST,
20
+ },
21
+ {
22
+ rule: 'bet', match: /no hbtest\.json/,
23
+ hint: 'The run needs to know your Bet button: use the SDK\'s <hb-bet> (/engine-sdk/bet.js) and the run finds it by itself, or add bundle/hbtest.json with the selector of your Bet button and the stake to type first. GUIDE §3.4. ' + TEST,
24
+ },
25
+ {
26
+ rule: 'bet', match: /hbtest\.json step failed/,
27
+ hint: 'The run could not press what hbtest.json names. The selector must match one element that is shown, enabled, inside the frame and not moving when a round can start, at desktop and phone size (a button that animates forever is never still enough to press). The SDK\'s <hb-bet> (/engine-sdk/bet.js) is built to be pressed. GUIDE §3.4. ' + TEST,
28
+ },
29
+ {
30
+ rule: 'idle', match: /./,
31
+ hint: 'A bet happened while nobody touched the game. A bet is always the player\'s act: no auto-start, no bet from a timer, no demo round through engine.play. GUIDE §3.1. ' + TEST,
32
+ },
33
+ {
34
+ rule: 'fit', match: /does not fit/,
35
+ hint: 'The game must fit the frame it is given: 1194×720, 1194×582, 364×720 and 364×440 (a phone between the site\'s header and bottom nav), then fullscreen 1920×1080 and 390×844. Size the page to the frame (no fixed widths, no page scroll), keep every control inside it and no text cut at its edge. GUIDE §3.3. ' + TEST,
36
+ },
37
+ {
38
+ rule: 'draws', match: /./,
39
+ hint: 'After the round the play area showed almost nothing. If the game draws with WebGL, it must also start without a GPU (the run draws on the CPU, SwiftShader): check the canvas is created and drawn when no hardware acceleration is there. GUIDE §6. ' + TEST,
40
+ },
41
+ {
42
+ rule: 'errors', match: /./,
43
+ hint: 'A page threw an error nobody caught; the line above names it. Fix it where it is thrown: in production it would stop the game for a player. ' + TEST,
44
+ },
45
+ {
46
+ rule: 'network', match: /./,
47
+ hint: 'Everything the game loads ships in its bundle and comes from its own origin: no outside fonts, CDNs, analytics or frames; workers and styles from your own files. GUIDE §3.7. ' + TEST,
48
+ },
49
+ {
50
+ rule: 'currency', match: /./,
51
+ hint: 'Money is shown and sent only through the SDK (engine.money, engine.play with the amount the player typed): never compute a stake, convert a currency or format money yourself. GUIDE §4. ' + TEST,
52
+ },
53
+ {
54
+ // Only when it is the variant's one failure: a failure of the game's own beside it is the game's to fix.
55
+ rule: 'time-travel', match: /the run broke: the checker's own page did not keep up with the clock[^;]*$/,
56
+ hint: 'Most likely on our side, not your game\'s: every window of your game moved its clock in time, and one of the checker\'s own pages (the site or the House Engine shell) did not. Upload the same build again; if it keeps happening, reply on your support ticket with the version number. If your game draws a lot every frame, drawing less often (/engine-sdk/frames.js) also leaves the checker more room.',
57
+ },
58
+ {
59
+ rule: 'time-travel', match: /fake clock did not move/,
60
+ hint: 'After the first round the check moves the game\'s clock 30 minutes ahead, as when a player leaves the tab and comes back, and every frame of the game must keep up within a minute. Yours did not: when time jumps, every timer that came due fires at once, and if each of them does heavy work (a full render, rebuilding a scene, catching up an interval tick by tick) the page freezes. Let timers only mark that a frame is needed and draw at most once per requestAnimationFrame; after a big gap in time, run an interval\'s work once, not once per missed tick; never reload the page on a jump. /engine-sdk/frames.js does both for you: frame(draw) and every(ms, fn). GUIDE §6 (time-travel). ' + TRAVEL,
61
+ },
62
+ {
63
+ rule: 'time-travel', match: /bet control (is|was not found|was hidden|is disabled)/,
64
+ hint: 'The Bet control the check found is not the button a player presses: it must be the real control, at least 32×32 px, at least half opaque, visible, enabled and inside the frame. An invisible stand-in that forwards clicks to a canvas fails. The SDK\'s <hb-bet> (/engine-sdk/bet.js) always passes this: use it for your Bet. GUIDE §3.4, §6. ' + TRAVEL,
65
+ },
66
+ {
67
+ // A variant that did not boot and says nothing else (no error, no request,
68
+ // no broken run): what the shell's own 10 s start-up limit leaves when the
69
+ // check machine's engine is slow, as the last variants of a matrix were
70
+ // (studio tickets, Nov 2026) while they booted at once on the studio's machine.
71
+ rule: 'time-travel', match: /^(?!.*the run broke).*: the game did not boot$/,
72
+ hint: 'At another date, time zone or language the game did not start, and it threw no error. That can be your code, or the check machine: the shell gives the engine 10 seconds to open a session and shows its own "The game could not start" screen past that, and on a busy check machine a variant late in the matrix can take that long. First play that variant on your machine (`hbengine test --time-travel`, its clock replayed with `--clock` as below). If it boots there in a few seconds and only variants near the end of the list failed, it was most likely the machine: upload again. If it does not boot there, look for code that reads the date, the zone or the language at start and stops before engine.ready() without an error (a date parsed by hand, a calendar or seasonal theme, a translation that is missing). GUIDE §5, §6. ' + TRAVEL,
73
+ },
74
+ {
75
+ rule: 'time-travel', match: /did not boot|round did not end/,
76
+ hint: 'At another date, time zone or language the game did not start or did not finish a round. Look for code that reads the date, the zone or the language at start (a date parsed by hand, a calendar or seasonal theme, a translation that is missing): the line above names the variant. GUIDE §5, §6. ' + TRAVEL,
77
+ },
78
+ ];
79
+
80
+ /** The hint for a failing check ({ rule, detail }), or '' when the table has none. */
81
+ export function hintFor(check) {
82
+ if (!check || check.ok || check.status === 'skipped') return '';
83
+ const detail = String(check.detail || '');
84
+ for (const h of HINTS) if (h.rule === check.rule && h.match.test(detail)) return h.hint;
85
+ return '';
86
+ }