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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Adds rows to js-releases.json: the SHA-256 of every JavaScript file of a
|
|
3
|
+
// library release as published on npm. Usage:
|
|
4
|
+
//
|
|
5
|
+
// node packages/engine-conformance/js-releases.mjs PixiJS pixi.js@8.16.0 [pixi.js@8.15.0 …]
|
|
6
|
+
//
|
|
7
|
+
// The first argument is the library's name as KNOWN_LIBRARIES (scan.mjs)
|
|
8
|
+
// spells it; each following one a package at an exact version. The tarball is
|
|
9
|
+
// fetched from the registry, checked against the registry's integrity, and read
|
|
10
|
+
// in memory; nothing in it is run. A file in a bundle's vendor/ whose bytes are
|
|
11
|
+
// one of these is read as that library (scan.mjs, recognisedLibrary).
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { gunzipSync } from 'node:zlib';
|
|
15
|
+
|
|
16
|
+
const FILE = new URL('./js-releases.json', import.meta.url);
|
|
17
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
18
|
+
// The files a studio copies into vendor/: a release's browser builds (dist/,
|
|
19
|
+
// build/, umd/, browser/, or the package's root) and three.js's loader glue
|
|
20
|
+
// (examples/jsm/libs/), not its sources or modules.
|
|
21
|
+
const SHIPPED = /^(?:(?:dist|build|umd|browser|examples\/jsm\/libs)\/(?:[^/]+\/)*)?[^/]+\.m?js$/i;
|
|
22
|
+
|
|
23
|
+
// The regular files of a tar archive: { name, data }. ustar and GNU long names;
|
|
24
|
+
// the package/ prefix npm puts on every entry is removed.
|
|
25
|
+
export function tarFiles(buf) {
|
|
26
|
+
const out = [];
|
|
27
|
+
let longName = null;
|
|
28
|
+
for (let off = 0; off + 512 <= buf.length; ) {
|
|
29
|
+
const h = buf.subarray(off, off + 512);
|
|
30
|
+
if (h.every((b) => b === 0)) break;
|
|
31
|
+
const str = (a, n) => { const s = h.subarray(a, a + n); const z = s.indexOf(0); return s.subarray(0, z < 0 ? n : z).toString('utf8'); };
|
|
32
|
+
const size = parseInt(str(124, 12).trim() || '0', 8);
|
|
33
|
+
const type = String.fromCharCode(h[156] || 48);
|
|
34
|
+
const prefix = str(345, 155);
|
|
35
|
+
let name = longName ?? (prefix ? `${prefix}/${str(0, 100)}` : str(0, 100));
|
|
36
|
+
longName = null;
|
|
37
|
+
const data = buf.subarray(off + 512, off + 512 + size);
|
|
38
|
+
if (type === 'L') longName = data.toString('utf8').replace(/\0+$/, '');
|
|
39
|
+
else if (type === '0' || type === '\0') out.push({ name: name.replace(/^[^/]+\//, ''), data });
|
|
40
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function release(spec) {
|
|
46
|
+
const at = spec.lastIndexOf('@');
|
|
47
|
+
if (at <= 0) throw new Error(`${spec}: give package@version`);
|
|
48
|
+
const pkg = spec.slice(0, at), version = spec.slice(at + 1);
|
|
49
|
+
const meta = await (await fetch(`${REGISTRY}/${pkg.replace('/', '%2f')}/${version}`)).json();
|
|
50
|
+
if (!meta?.dist?.tarball || !meta.dist.integrity?.startsWith('sha512-')) throw new Error(`${spec}: not on the registry`);
|
|
51
|
+
const tgz = Buffer.from(await (await fetch(meta.dist.tarball)).arrayBuffer());
|
|
52
|
+
if ('sha512-' + createHash('sha512').update(tgz).digest('base64') !== meta.dist.integrity) throw new Error(`${spec}: the tarball does not match the registry's integrity`);
|
|
53
|
+
return { pkg, version, files: tarFiles(gunzipSync(tgz)).filter((f) => SHIPPED.test(f.name)) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
57
|
+
const [library, ...specs] = process.argv.slice(2);
|
|
58
|
+
if (!library || !specs.length) { console.error('usage: js-releases.mjs <library> <package@version>…'); process.exit(2); }
|
|
59
|
+
const doc = JSON.parse(readFileSync(FILE, 'utf8'));
|
|
60
|
+
let added = 0;
|
|
61
|
+
for (const spec of specs) {
|
|
62
|
+
const r = await release(spec);
|
|
63
|
+
for (const f of r.files) {
|
|
64
|
+
const sha = createHash('sha256').update(f.data).digest('hex');
|
|
65
|
+
const row = doc.files[sha] ?? (doc.files[sha] = { library, file: f.name, bytes: f.data.length, releases: [] });
|
|
66
|
+
if (row.library !== library) throw new Error(`${sha}: already a file of ${row.library}`);
|
|
67
|
+
const release = `${r.pkg}@${r.version}`;
|
|
68
|
+
if (!row.releases.includes(release)) { row.releases.push(release); added++; }
|
|
69
|
+
}
|
|
70
|
+
console.log(`${spec}: ${r.files.length} files`);
|
|
71
|
+
}
|
|
72
|
+
doc.files = Object.fromEntries(Object.entries(doc.files).sort(([, a], [, b]) => a.library.localeCompare(b.library) || a.file.localeCompare(b.file) || a.releases[0].localeCompare(b.releases[0], undefined, { numeric: true })));
|
|
73
|
+
writeFileSync(FILE, JSON.stringify(doc, null, 2) + '\n');
|
|
74
|
+
console.log(`${added} rows or versions added`);
|
|
75
|
+
}
|
|
@@ -25,8 +25,11 @@
|
|
|
25
25
|
* to the policy's report path (only the browser reports there)
|
|
26
26
|
* fit the game fits the frame it is given, as a House Original does:
|
|
27
27
|
* no page scrolls inside it and every visible control lies
|
|
28
|
-
* within it, at 720 high
|
|
29
|
-
*
|
|
28
|
+
* within it, at 720 high, at the shortest frame the site gives
|
|
29
|
+
* (desktop 1194×582, a 1366×768 laptop; phone 364×440, a
|
|
30
|
+
* 375×667 phone between the site's header and bottom nav) and
|
|
31
|
+
* in fullscreen (desktop 1920×1080, phone 390×844). The site
|
|
32
|
+
* never grows the frame.
|
|
30
33
|
* bet with the studio's hbtest.json: one bet placed through the
|
|
31
34
|
* game's own UI reaches engine.play and is followed by
|
|
32
35
|
* `revealed` (the site's round-end) within the reveal deadline.
|
|
@@ -40,6 +43,10 @@
|
|
|
40
43
|
* outage the next bet meets an ambiguous wallet answer (engine-dev's
|
|
41
44
|
* /dev/fault): the game does not break, the site is asked to
|
|
42
45
|
* re-read the wallet
|
|
46
|
+
* sound untouched, the game starts no sound (no media element plays
|
|
47
|
+
* unmuted, no Web Audio source starts) before the player's first
|
|
48
|
+
* tap; a control labelled mute or sound is looked for, and a
|
|
49
|
+
* game without one is a warning for the reviewer, not a fail
|
|
43
50
|
*
|
|
44
51
|
* The run plays against engine-dev (the offline sandbox, `--rgs`), never a
|
|
45
52
|
* platform: it needs no credential, and holds none. Chrome runs with its own
|
|
@@ -124,15 +131,22 @@
|
|
|
124
131
|
* names an element whose text must carry a value of the round (data.0.side).
|
|
125
132
|
*/
|
|
126
133
|
import http from 'node:http';
|
|
134
|
+
import { fileURLToPath } from 'node:url';
|
|
127
135
|
import { randomBytes } from 'node:crypto';
|
|
128
|
-
import { readFileSync, existsSync, statSync, writeFileSync } from 'node:fs';
|
|
136
|
+
import { readFileSync, readdirSync, existsSync, statSync, writeFileSync } from 'node:fs';
|
|
129
137
|
import { flatShare } from './picture.mjs';
|
|
130
138
|
import { join, extname, normalize } from 'node:path';
|
|
131
139
|
import { moneyFor } from '../engine-sdk/money.js';
|
|
132
140
|
import { walletFrom } from '../engine-shell/host/boot.js';
|
|
133
141
|
import { bundleCSP, shellCSP, reportingEndpoints } from './csp.mjs';
|
|
134
|
-
import { GRID, STORAGE_FILE_MAX, WALK, carriedStorage, checksFor, clockFor, clockShim, clockTrap, controlProblem, credentialHits, fitsBudget, forcedRows, handedStorage, inputSignature, lateShotName, medianGrid, shortVariantEstimate, shotName, startInstant, trapOptions, travelAnswer, variantsFor, visualDifference, workerShim } from './travel.mjs';
|
|
142
|
+
import { GRID, STORAGE_FILE_MAX, WALK, carriedStorage, checksFor, checkerOnlySilent, clockFor, clockShim, clockTrap, controlProblem, credentialHits, fitsBudget, failureShotName, forcedRows, handedStorage, inputSignature, lateShotName, medianGrid, missedPress, shortVariantEstimate, shotName, startInstant, trapOptions, travelAnswer, travelClocks, variantsFor, visualDifference, withClocks, workerShim, OUTCOME_SHOTS, outcomeShotName, tierOf } from './travel.mjs';
|
|
143
|
+
import { hintFor } from './hints.mjs';
|
|
135
144
|
import { clockSource, siteJump, startClock } from './clock.mjs';
|
|
145
|
+
import { appendDiagnostic } from './diagnostic-text.mjs';
|
|
146
|
+
import { enableFeatures } from './chrome-flags.mjs';
|
|
147
|
+
import { SOUND_CONTROL, soundControls, soundProbe, soundVerdict } from './sound.mjs';
|
|
148
|
+
import { consoleRecorder, failureEvidence, handshakeProbe, runnerInfo } from './diagnostics.mjs';
|
|
149
|
+
import { focusProbe, focusVerdict } from './focus.mjs';
|
|
136
150
|
|
|
137
151
|
const args = process.argv.slice(2);
|
|
138
152
|
const BUNDLE = args[0];
|
|
@@ -154,6 +168,11 @@ const TRAVEL_BUDGET_MS = Math.max(10, Number(opt('travel-budget', '240')) || 240
|
|
|
154
168
|
const TRAVEL_TOTAL_MS = Math.max(0, Number(opt('travel-total', '0')) || 0) * 1000;
|
|
155
169
|
// `--variants base,long`: a part of the matrix, for working on one variant (never the job's).
|
|
156
170
|
const TRAVEL_ONLY = opt('variants', '') ? opt('variants').split(',') : null;
|
|
171
|
+
// `--travel-clocks <json>`: a dated variant's instant, zone and language given by
|
|
172
|
+
// the caller instead of travel.mjs's ({"plus7d": {"at": ISO, "tz", "locale"}}).
|
|
173
|
+
// The check job draws its own for every upload, so the dates a game is played at
|
|
174
|
+
// there are not the ones this file names (travel.mjs travelClocks checks them).
|
|
175
|
+
const TRAVEL_CLOCKS = travelClocks(opt('travel-clocks', ''));
|
|
157
176
|
// `--storage <file>`: the game's storage as an earlier run left it, read before
|
|
158
177
|
// the first variant and written after the last (the daily canary carries it
|
|
159
178
|
// from one day to the next, so a counter kept across visits counts the
|
|
@@ -163,7 +182,7 @@ const UNSANDBOXED = process.env.ENGINE_RUN_UNSANDBOXED === '1';
|
|
|
163
182
|
// Chrome: --chrome, CHROME_PATH, the Mac install; otherwise playwright-core's
|
|
164
183
|
// own Chromium (`npx playwright-core install chromium`).
|
|
165
184
|
const CHROME = opt('chrome', process.env.CHROME_PATH || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : undefined));
|
|
166
|
-
const PKG = new URL('..', import.meta.url)
|
|
185
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url)); // packages/
|
|
167
186
|
// Three origins on free ports, so any number of runs play at once: the
|
|
168
187
|
// site on localhost, the shell and the studio on 127.0.0.1 (another site).
|
|
169
188
|
const ORIGIN = { site: '', shell: '', studio: '' };
|
|
@@ -318,7 +337,15 @@ if (args.includes('--dev')) {
|
|
|
318
337
|
console.log(`playing ${SLUG} from ${BUNDLE}\nopen ${ORIGIN.site}/#${encodeURIComponent(url)}\n(edit the bundle and reload; Ctrl-C to stop)`);
|
|
319
338
|
await new Promise(() => {});
|
|
320
339
|
}
|
|
321
|
-
|
|
340
|
+
// Without hbtest.json, a game that loads the SDK's Bet button (the platform's
|
|
341
|
+
// /engine-sdk/bet.js, <hb-bet>) is pressed through it: the run finds the
|
|
342
|
+
// button a player presses by itself, at the game's own stake.
|
|
343
|
+
const usesBetButton = () => {
|
|
344
|
+
const files = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((d) => (d.isDirectory() ? files(join(dir, d.name)) : /\.(html?|m?js)$/i.test(d.name) ? [join(dir, d.name)] : []));
|
|
345
|
+
try { return files(BUNDLE).some((f) => /engine-sdk\/bet\.js/.test(readFileSync(f, 'utf8'))); } catch { return false; }
|
|
346
|
+
};
|
|
347
|
+
const hbtest = existsSync(join(BUNDLE, 'hbtest.json')) ? JSON.parse(readFileSync(join(BUNDLE, 'hbtest.json'), 'utf8'))
|
|
348
|
+
: usesBetButton() ? { bet: { page: 'index.html', selector: 'hb-bet' } } : null;
|
|
322
349
|
const results = [];
|
|
323
350
|
const check = (size, rule, ok, detail, status) => results.push({ size, rule, ok, detail, ...(status ? { status } : {}) });
|
|
324
351
|
const { chromium } = await import('playwright-core');
|
|
@@ -342,7 +369,10 @@ const launchEnv = { PATH: process.env.PATH, HOME: process.env.HOME || '/tmp', LA
|
|
|
342
369
|
// sandbox (the job: one upload, no role, no egress); a run without the sandbox (a stand on a
|
|
343
370
|
// machine that cannot give one) keeps WebGL off.
|
|
344
371
|
const SOFTWARE_WEBGL = UNSANDBOXED ? [] : ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'];
|
|
345
|
-
|
|
372
|
+
// A process per origin, so a game drawing in software cannot keep the shell
|
|
373
|
+
// from answering the clock (chrome-flags.mjs).
|
|
374
|
+
const FEATURES = [enableFeatures(process.env)];
|
|
375
|
+
const launchChrome = (tz) => chromium.launch({ executablePath: CHROME, headless: true, chromiumSandbox: !UNSANDBOXED, env: tz ? { ...launchEnv, TZ: tz } : launchEnv, ignoreDefaultArgs: ['--enable-automation'], args: ['--autoplay-policy=no-user-gesture-required', '--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled', ...FEATURES, ...SOFTWARE_WEBGL] });
|
|
346
376
|
const browsers = new Map();
|
|
347
377
|
const browserFor = async (tz) => { if (!browsers.has(tz)) browsers.set(tz, await launchChrome(tz)); return browsers.get(tz); };
|
|
348
378
|
let browser;
|
|
@@ -354,6 +384,43 @@ try {
|
|
|
354
384
|
process.exit(3);
|
|
355
385
|
}
|
|
356
386
|
|
|
387
|
+
// How fast this machine runs a game, measured the same way everywhere (the
|
|
388
|
+
// upload's job, a studio's laptop): plain JavaScript, a 2D canvas, and WebGL
|
|
389
|
+
// as this Chrome draws it (on the CPU here, SwiftShader). Recorded, not judged:
|
|
390
|
+
// it is how a run on a studio's machine is held to the job's pace.
|
|
391
|
+
async function machineSpeed(chrome) {
|
|
392
|
+
const page = await chrome.newPage();
|
|
393
|
+
try {
|
|
394
|
+
return await page.evaluate(() => {
|
|
395
|
+
const time = (f) => { const t = performance.now(); f(); return Math.round(performance.now() - t); };
|
|
396
|
+
const js = time(() => { let x = 0; for (let i = 0; i < 3e7; i++) x += Math.sqrt(i); return x; });
|
|
397
|
+
const c2 = document.createElement('canvas'); c2.width = 1194; c2.height = 720;
|
|
398
|
+
const g = c2.getContext('2d');
|
|
399
|
+
const d2 = time(() => { for (let i = 0; i < 200; i++) { g.fillStyle = `hsl(${i},60%,50%)`; g.fillRect(0, 0, 1194, 720); for (let k = 0; k < 50; k++) g.fillRect((k * 37 + i) % 1194, (k * 53) % 720, 64, 64); } g.getImageData(0, 0, 1, 1); });
|
|
400
|
+
const c3 = document.createElement('canvas'); c3.width = 1194; c3.height = 720;
|
|
401
|
+
const gl = c3.getContext('webgl');
|
|
402
|
+
let webgl = null;
|
|
403
|
+
if (gl) {
|
|
404
|
+
// A full-frame quad through a small shader, 60 times: what a frame of a 2D game costs, roughly.
|
|
405
|
+
const sh = (type, src) => { const o = gl.createShader(type); gl.shaderSource(o, src); gl.compileShader(o); return o; };
|
|
406
|
+
const prog = gl.createProgram();
|
|
407
|
+
gl.attachShader(prog, sh(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'));
|
|
408
|
+
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, 'precision mediump float;uniform float t;void main(){vec2 u=gl_FragCoord.xy/700.;float v=0.;for(int i=0;i<8;i++)v+=sin(u.x*float(i)+t)*cos(u.y*float(i));gl_FragColor=vec4(v,u,1.);}'));
|
|
409
|
+
gl.linkProgram(prog); gl.useProgram(prog);
|
|
410
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
|
411
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
|
412
|
+
const at = gl.getAttribLocation(prog, 'p'); gl.enableVertexAttribArray(at); gl.vertexAttribPointer(at, 2, gl.FLOAT, false, 0, 0);
|
|
413
|
+
const ut = gl.getUniformLocation(prog, 't');
|
|
414
|
+
webgl = time(() => { for (let i = 0; i < 60; i++) { gl.uniform1f(ut, i); gl.drawArrays(gl.TRIANGLES, 0, 6); gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4)); } });
|
|
415
|
+
}
|
|
416
|
+
return { jsMs: js, canvasMs: d2, webglMs: webgl };
|
|
417
|
+
});
|
|
418
|
+
} finally {
|
|
419
|
+
await page.close().catch(() => {});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const bench = await machineSpeed(browser).catch(() => null);
|
|
423
|
+
|
|
357
424
|
// The currency matrix: each size plays under another of the player's displays,
|
|
358
425
|
// a fiat at a rate and the bare coin. A game must show that currency and send
|
|
359
426
|
// the stake the Originals would (engine-sdk money.js on the shell's wallet).
|
|
@@ -362,6 +429,11 @@ const DISPLAYS = {
|
|
|
362
429
|
phone: { symbol: 'USDT', name: 'USDT', isFiat: false, rate: 1, precision: 8, shows: 'USDT' },
|
|
363
430
|
};
|
|
364
431
|
const FULLSCREEN = { desktop: { width: 1920, height: 1080 }, phone: { width: 390, height: 844 } };
|
|
432
|
+
// The shortest frame the site gives a game, at the frame's own width: on a
|
|
433
|
+
// phone the frame is the room between the site's header and bottom nav (a
|
|
434
|
+
// 375×667 iPhone with Safari's bars: 440 high); on a laptop the window caps it
|
|
435
|
+
// (1366×768: 582 high). A game lays itself out in any frame from these up.
|
|
436
|
+
const SHORT = { desktop: 582, phone: 440 };
|
|
365
437
|
const SESSION_CURRENCY = { code: 1, symbol: 'USDT', decimals: 8 };
|
|
366
438
|
|
|
367
439
|
const network = (size, outside, refused, rtc = 0, workers = 0, reports = 0) => check(size, 'network', !outside.length && !refused.length && !rtc && !workers && !reports,
|
|
@@ -435,6 +507,19 @@ const CLOCK = CLOCK_SOURCE ? { source: CLOCK_SOURCE, start: String(startClock) }
|
|
|
435
507
|
// (given the fake clock's text when the variant travels, for the workers).
|
|
436
508
|
const initScript = ({ travel, at }) => `(() => {
|
|
437
509
|
const scope = globalThis;
|
|
510
|
+
// location.origin identifies the URL even when the sandbox's security
|
|
511
|
+
// origin is opaque. An about:blank/srcdoc child inherits its parent's.
|
|
512
|
+
const studioFrame = (() => {
|
|
513
|
+
let frame = scope;
|
|
514
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
515
|
+
try {
|
|
516
|
+
if (frame.location.origin === ${JSON.stringify(ORIGIN.studio)}) return true;
|
|
517
|
+
if (frame === frame.parent) break;
|
|
518
|
+
frame = frame.parent;
|
|
519
|
+
} catch { break; }
|
|
520
|
+
}
|
|
521
|
+
return false;
|
|
522
|
+
})();
|
|
438
523
|
const natives = (${String(clockShim)})(scope, { capture: true });
|
|
439
524
|
let fake = null, jump = null;
|
|
440
525
|
${travel ? `{
|
|
@@ -445,6 +530,11 @@ const initScript = ({ travel, at }) => `(() => {
|
|
|
445
530
|
}` : ''}
|
|
446
531
|
const setAt = (${String(clockShim)})(scope, { natives, fake });
|
|
447
532
|
(${String(INSTRUMENT_PAGE)})(scope, ${JSON.stringify(RUN_TOKEN)});
|
|
533
|
+
if (studioFrame) (${String(handshakeProbe)})(scope, ((say) => (key) => say(${JSON.stringify(RUN_TOKEN)} + ':init:' + key))(scope.console.debug.bind(scope.console)));
|
|
534
|
+
if (studioFrame) (${String(focusProbe)})(scope, ((say) => (key) => say(${JSON.stringify(RUN_TOKEN)} + ':focus:' + key))(scope.console.debug.bind(scope.console)));
|
|
535
|
+
// The sound rule is about the game: the site's page and the House Engine
|
|
536
|
+
// shell (whose loader plays its own short sound) are ours, not the studio's.
|
|
537
|
+
if (!${JSON.stringify([ORIGIN.site, ORIGIN.shell])}.includes(scope.location.origin)) (${String(soundProbe)})(scope, ((say) => (key) => say(${JSON.stringify(RUN_TOKEN)} + ':sound:' + key))(scope.console.debug.bind(scope.console)));
|
|
448
538
|
(${String(clockTrap)})(scope, ${JSON.stringify(trapOptions(RUN_TOKEN, travel ? CLOCK : null))}, { setAt, jump, realNow: natives.Date.now });
|
|
449
539
|
})();\n`;
|
|
450
540
|
// A frame of the game's: the bundle's own pages, and every frame they make,
|
|
@@ -461,9 +551,9 @@ const listIn = async (page, expr) => (await Promise.all(studioFrames(page).map((
|
|
|
461
551
|
// through the console (dialogs, peer connections, workers, canvas text). The
|
|
462
552
|
// same for every run.
|
|
463
553
|
function instrument(page) {
|
|
464
|
-
const errors = [], outside = [], refused = [], plays = [], consoleErrors = [], dialogs = [], workers = [], canvasText = [];
|
|
465
|
-
let reports = 0, rtc = 0, unshimmed = 0;
|
|
466
|
-
page.on('pageerror', (e) => errors
|
|
554
|
+
const errors = [], outside = [], refused = [], plays = [], consoleErrors = [], dialogs = [], workers = [], canvasText = [], sounds = [], scriptFocus = [];
|
|
555
|
+
let reports = 0, rtc = 0, unshimmed = 0, initSeen = 0;
|
|
556
|
+
page.on('pageerror', (e) => appendDiagnostic(errors, e.message));
|
|
467
557
|
page.on('console', (m) => {
|
|
468
558
|
const text = m.text();
|
|
469
559
|
if (text.startsWith(RUN_TOKEN + ':')) {
|
|
@@ -475,12 +565,15 @@ function instrument(page) {
|
|
|
475
565
|
else if (kind === 'worker') workers.push(payload);
|
|
476
566
|
else if (kind === 'unshimmed') unshimmed++;
|
|
477
567
|
else if (kind === 'text') canvasText.push(payload);
|
|
478
|
-
else if (kind === '
|
|
568
|
+
else if (kind === 'sound') sounds.push(payload);
|
|
569
|
+
else if (kind === 'init') initSeen++;
|
|
570
|
+
else if (kind === 'focus') scriptFocus.push(payload);
|
|
571
|
+
else if (kind === 'uncaught') appendDiagnostic(errors, payload); // a timer's exception under the fake clock: uncaught in production
|
|
479
572
|
return;
|
|
480
573
|
}
|
|
481
574
|
if (m.type() !== 'error') return;
|
|
482
575
|
if (text.includes('Content Security Policy')) refused.push(text.slice(0, 120));
|
|
483
|
-
else consoleErrors
|
|
576
|
+
else appendDiagnostic(consoleErrors, text);
|
|
484
577
|
});
|
|
485
578
|
page.on('request', (r) => {
|
|
486
579
|
const u = r.url();
|
|
@@ -495,7 +588,7 @@ function instrument(page) {
|
|
|
495
588
|
}
|
|
496
589
|
if (u.includes('/engine/v1/play')) plays.push(r.postData() || '');
|
|
497
590
|
});
|
|
498
|
-
return { errors, outside, refused, plays, consoleErrors, workers, canvasText, reports: () => reports, rtcCount: () => rtc, dialogs: () => [...dialogs], unshimmed: () => unshimmed };
|
|
591
|
+
return { errors, outside, refused, plays, consoleErrors, workers, canvasText, sounds, scriptFocus, initReceived: () => initSeen > 0, reports: () => reports, rtcCount: () => rtc, dialogs: () => [...dialogs], unshimmed: () => unshimmed };
|
|
499
592
|
}
|
|
500
593
|
|
|
501
594
|
// The site's log of the shell's messages, and the hbtest steps on the game's own pages.
|
|
@@ -521,11 +614,29 @@ function helpers(page) {
|
|
|
521
614
|
else await press(f.locator(step.selector).first());
|
|
522
615
|
};
|
|
523
616
|
// A `then` step (Next, Cash out) is pressed only when its control is there to press.
|
|
524
|
-
|
|
617
|
+
// The round can end between the look and the press (a ladder's Cash out
|
|
618
|
+
// turns off with it): that press is let go and the caller looks again.
|
|
619
|
+
// A control that will not take the click (covered, moving) breaks the
|
|
620
|
+
// run, with its evidence, after `tries` misses of that step in a row: one
|
|
621
|
+
// by default (the frame sizes, the short variants), more for the long
|
|
622
|
+
// session's loop (travel.mjs missedPress). Its control going off or away,
|
|
623
|
+
// or a press that lands, starts the count again.
|
|
624
|
+
const missed = new Map();
|
|
625
|
+
const thenSteps = async (steps, tries = 1) => {
|
|
525
626
|
for (const s of steps || []) {
|
|
627
|
+
const key = s.page + ' ' + s.selector;
|
|
526
628
|
const f = gameFrame(s.page);
|
|
527
629
|
const control = f && f.locator(s.selector).first();
|
|
528
|
-
|
|
630
|
+
const on = async () => !!control && await control.isVisible().catch(() => false) && await control.isEnabled().catch(() => false);
|
|
631
|
+
if (!await on()) { missed.delete(key); continue; }
|
|
632
|
+
try {
|
|
633
|
+
await press(control);
|
|
634
|
+
missed.delete(key);
|
|
635
|
+
} catch (e) {
|
|
636
|
+
const r = missedPress({ on: await on(), covered: /intercepts pointer events/.test(String(e && e.message)), missed: missed.get(key) || 0, tries });
|
|
637
|
+
if (r.verdict === 'break') { missed.delete(key); throw e; }
|
|
638
|
+
if (r.missed) missed.set(key, r.missed); else missed.delete(key);
|
|
639
|
+
}
|
|
529
640
|
}
|
|
530
641
|
};
|
|
531
642
|
const roundEnds = (l) => l.filter((m) => m.d.type === 'housebets-game-round-end').length;
|
|
@@ -550,17 +661,22 @@ function helpers(page) {
|
|
|
550
661
|
// The console errors of each size, for the JSON (`console`): a test bundle's
|
|
551
662
|
// own report of what it saw (travel.browser.test.mjs) is read from here.
|
|
552
663
|
const frameConsole = {};
|
|
664
|
+
const diagnostics = {};
|
|
553
665
|
for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
554
666
|
const { shows, ...display } = DISPLAYS[size];
|
|
555
667
|
const money = moneyFor(walletFrom(JSON.stringify(display), SESSION_CURRENCY));
|
|
556
668
|
const context = await browser.newContext({ viewport: { width, height: 900 } });
|
|
669
|
+
try {
|
|
670
|
+
const started = Date.now();
|
|
557
671
|
const page = await context.newPage();
|
|
672
|
+
const consoleLines = consoleRecorder(page, RUN_TOKEN);
|
|
673
|
+
const evidence = () => failureEvidence(page, { console: consoleLines, runner: runnerInfo(browser), started, shot: `${size}.png`, shotFile: join(opt('shots', process.cwd()), `conformance-${SLUG}-${size}.png`), log: () => page.evaluate(() => window.__log || []), initReceived: initReceived() });
|
|
558
674
|
await page.addInitScript(initScript({ travel: false }));
|
|
559
|
-
const { errors, outside, refused, plays, reports, rtcCount, consoleErrors } = instrument(page);
|
|
675
|
+
const { errors, outside, refused, plays, reports, rtcCount, consoleErrors, sounds, initReceived, scriptFocus } = instrument(page);
|
|
560
676
|
// Kept for the JSON once the size is done: the first distinct lines, bounded
|
|
561
677
|
// (a test bundle's report of what it saw runs to some sixty lines).
|
|
562
678
|
const keepConsole = () => { frameConsole[size] = [...new Set(consoleErrors)].slice(0, 200); };
|
|
563
|
-
const { log, waitFor, act,
|
|
679
|
+
const { log, waitFor, act, gameFrame, roundEnds, thenSteps } = helpers(page);
|
|
564
680
|
await page.goto(ORIGIN.site + '/');
|
|
565
681
|
const { url } = await launch(display);
|
|
566
682
|
await page.evaluate((u) => window.__launch(u), url);
|
|
@@ -568,11 +684,11 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
|
568
684
|
check(size, 'boot', ready, ready ? 'hello answered, ready reported' : 'no ready within 25 s (the game never connected, or never called engine.ready())');
|
|
569
685
|
if (!ready) {
|
|
570
686
|
// Nothing after boot means anything when the game never started.
|
|
571
|
-
for (const rule of ['idle', 'fit', 'bet', 'currency', 'outage']) check(size, rule, false, 'not reached: the game did not boot');
|
|
687
|
+
for (const rule of ['idle', 'focus', 'sound', 'fit', 'bet', 'currency', 'outage']) check(size, rule, false, 'not reached: the game did not boot');
|
|
572
688
|
check(size, 'errors', errors.length === 0, errors.length ? [...new Set(errors)].slice(0, 4).join(' | ') : 'none');
|
|
573
689
|
network(size, outside, refused, rtcCount(), context.serviceWorkers().length, reports());
|
|
574
690
|
keepConsole();
|
|
575
|
-
await
|
|
691
|
+
diagnostics[size] = await evidence();
|
|
576
692
|
continue;
|
|
577
693
|
}
|
|
578
694
|
// Nobody touches the game for a while: it must not bet on its own. A
|
|
@@ -580,6 +696,14 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
|
580
696
|
const idleFrom = plays.length;
|
|
581
697
|
await page.waitForTimeout(Number(opt('idle-ms', '15000')));
|
|
582
698
|
check(size, 'idle', plays.length === idleFrom, plays.length === idleFrom ? 'no bet without the player' : `${plays.length - idleFrom} bet(s) placed while nobody touched the game`);
|
|
699
|
+
// Nothing has been touched yet: any sound so far started before the
|
|
700
|
+
// player's first tap. A mute control is the game's to give (the shell has
|
|
701
|
+
// none); one is looked for by its label, and its absence is a warning.
|
|
702
|
+
{
|
|
703
|
+
const control = (await Promise.all(studioFrames(page).map((f) => f.evaluate(soundControls, SOUND_CONTROL.source).catch(() => [])))).flat()[0];
|
|
704
|
+
const v = soundVerdict(sounds, control);
|
|
705
|
+
check(size, 'sound', v.ok, v.detail, v.status);
|
|
706
|
+
}
|
|
583
707
|
|
|
584
708
|
// fit: every page of the game at its frame size, now and after each page
|
|
585
709
|
// the test opens
|
|
@@ -587,6 +711,9 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
|
587
711
|
// clips scrolls nowhere), unless a scroller holds it; what is not shown
|
|
588
712
|
// (hidden, a closed <details>, aria-hidden, inert) is not there to press.
|
|
589
713
|
const scrolling = new Set();
|
|
714
|
+
// What the short frame alone found (364×440, 1194×582): a warning for now,
|
|
715
|
+
// while studios lay their games out for the site's shorter frames.
|
|
716
|
+
const shortOnly = new Set();
|
|
590
717
|
const measure = async (at = '') => {
|
|
591
718
|
for (const f of studioFrames(page)) {
|
|
592
719
|
const d = await f.evaluate(() => {
|
|
@@ -641,6 +768,13 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
|
641
768
|
};
|
|
642
769
|
await measure();
|
|
643
770
|
|
|
771
|
+
// focus: nothing has been pressed yet (the idle wait, sound and fit press
|
|
772
|
+
// nothing), so every focus said so far was the script's own. Read here, at
|
|
773
|
+
// the last moment before the first click, so a timer's focus counts too.
|
|
774
|
+
// A warning for now, not a fail: some frameworks focus the window at start-up
|
|
775
|
+
// by default (Phaser's autoFocus), and no live game has been measured against it yet.
|
|
776
|
+
{ const v = focusVerdict(scriptFocus); check(size, 'focus', true, v.detail, v.ok ? undefined : 'warning'); }
|
|
777
|
+
|
|
644
778
|
// bet + outage through the studio's own UI
|
|
645
779
|
if (!hbtest) check(size, 'bet', false, 'no hbtest.json: the run cannot place a bet through the game');
|
|
646
780
|
else {
|
|
@@ -654,9 +788,7 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
|
654
788
|
// `then` step is played only when its control is there to press.
|
|
655
789
|
for (const s of hbtest.then || []) {
|
|
656
790
|
await page.waitForTimeout(1500);
|
|
657
|
-
|
|
658
|
-
const control = f && f.locator(s.selector).first();
|
|
659
|
-
if (control && await control.isVisible().catch(() => false) && await control.isEnabled().catch(() => false)) await press(control);
|
|
791
|
+
await thenSteps([s]);
|
|
660
792
|
}
|
|
661
793
|
const revealed = await waitFor((l) => roundEnds(l) > ends, 10000);
|
|
662
794
|
// One press, one bet: a game that sends two plays per press charges the player twice.
|
|
@@ -723,7 +855,7 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
|
723
855
|
// and answers "pending"): the game must not break, and the site
|
|
724
856
|
// must be asked to re-read the wallet.
|
|
725
857
|
if (SKIP_OUTAGE) {
|
|
726
|
-
check(size, 'outage', true, 'skipped: this
|
|
858
|
+
check(size, 'outage', true, 'skipped: this engine has no wallet fault stub; the real round checks completed', 'skipped');
|
|
727
859
|
} else {
|
|
728
860
|
const armed = await fetch(RGS + '/dev/fault', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ count: 1 }) }).then((r) => r.ok).catch(() => false);
|
|
729
861
|
if (!armed) check(size, 'outage', false, 'engine-dev has no /dev/fault: update the sandbox (hbengine) to run the outage check');
|
|
@@ -736,24 +868,36 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
|
736
868
|
}
|
|
737
869
|
}
|
|
738
870
|
} catch (e) {
|
|
871
|
+
diagnostics[size] = await evidence();
|
|
739
872
|
check(size, 'bet', false, 'hbtest.json step failed: ' + e.message);
|
|
740
873
|
for (const rule of ['currency', 'outage']) check(size, rule, false, 'not reached: the bet step failed');
|
|
741
874
|
}
|
|
742
875
|
}
|
|
743
876
|
await measure();
|
|
877
|
+
// The site on a short screen: the same frame, less high.
|
|
878
|
+
const short = SHORT[size];
|
|
879
|
+
await page.evaluate((h) => { document.getElementById('c').style.height = h + 'px'; }, short);
|
|
880
|
+
await page.waitForTimeout(1500);
|
|
881
|
+
const before = new Set(scrolling);
|
|
882
|
+
await measure(`${width}×${short}: `);
|
|
883
|
+
for (const x of scrolling) if (!before.has(x)) { scrolling.delete(x); shortOnly.add(x); }
|
|
744
884
|
// The site's fullscreen button: the frame takes the whole screen.
|
|
745
885
|
const full = FULLSCREEN[size];
|
|
746
886
|
await page.setViewportSize(full);
|
|
747
887
|
await page.evaluate(() => { document.getElementById('c').style.height = innerHeight + 'px'; });
|
|
748
888
|
await page.waitForTimeout(1500);
|
|
749
889
|
await measure('fullscreen: ');
|
|
750
|
-
check(size, 'fit',
|
|
890
|
+
if (scrolling.size) check(size, 'fit', false, 'does not fit: ' + [...scrolling].join('; '));
|
|
891
|
+
else if (shortOnly.size) check(size, 'fit', true, `fits ${width}×720 and fullscreen ${full.width}×${full.height}; not yet the shorter ${width}×${short} frame the site gives on smaller screens: ` + [...shortOnly].join('; '), 'warning');
|
|
892
|
+
else check(size, 'fit', true, `fits ${width}×720, ${width}×${short} and fullscreen ${full.width}×${full.height}`);
|
|
751
893
|
check(size, 'errors', errors.length === 0, errors.length ? [...new Set(errors)].slice(0, 4).join(' | ') : 'none');
|
|
752
894
|
network(size, outside, refused, rtcCount(), context.serviceWorkers().length, reports());
|
|
753
895
|
keepConsole();
|
|
754
896
|
const shot = await page.screenshot().catch(() => null);
|
|
755
897
|
if (shot) {
|
|
756
|
-
|
|
898
|
+
// Keep the first failure's image; the later fullscreen measurement
|
|
899
|
+
// must not replace the state diagnostics[size] describes.
|
|
900
|
+
if (!diagnostics[size]?.screenshot) writeFileSync(`${opt('shots', process.cwd())}/conformance-${SLUG}-${size}.png`, shot);
|
|
757
901
|
// A frame of one flat colour: the game drew nothing the screenshot can see
|
|
758
902
|
// (a canvas the browser could not draw, or a board that never painted).
|
|
759
903
|
// A warning for the reviewer for now: the run's software WebGL is new.
|
|
@@ -761,6 +905,9 @@ for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
|
761
905
|
try { flat = flatShare(shot); } catch { /* not a picture we can read: say nothing */ }
|
|
762
906
|
if (flat !== null) check(size, 'picture', true, flat >= 0.98 ? `the screenshot is one flat colour (${Math.round(flat * 100)}% of it): the game may draw nothing a player can see` : 'the screenshot shows the game', flat >= 0.98 ? 'warning' : undefined);
|
|
763
907
|
}
|
|
908
|
+
} finally {
|
|
909
|
+
await context.close().catch(() => {});
|
|
910
|
+
}
|
|
764
911
|
}
|
|
765
912
|
|
|
766
913
|
// ---- time travel: the matrix of clocks, zones and languages ------------------------------
|
|
@@ -779,7 +926,7 @@ let timeTravel = null;
|
|
|
779
926
|
if (TRAVEL) {
|
|
780
927
|
const now = new Date();
|
|
781
928
|
const matrixStart = Date.now();
|
|
782
|
-
const variants = variantsFor(TRAVEL, now).filter((v) => !TRAVEL_ONLY || TRAVEL_ONLY.includes(v.id)).map((v) => ({ ...v, rounds: v.rounds ? TRAVEL_ROUNDS : undefined, at: clockFor(v, now).toISOString() }));
|
|
929
|
+
const variants = withClocks(variantsFor(TRAVEL, now), TRAVEL_CLOCKS, now).filter((v) => !TRAVEL_ONLY || TRAVEL_ONLY.includes(v.id)).map((v) => ({ ...v, rounds: v.rounds ? TRAVEL_ROUNDS : undefined, at: v.at || clockFor(v, now).toISOString() }));
|
|
783
930
|
const manifest = opt('manifest', '') && existsSync(opt('manifest')) ? JSON.parse(readFileSync(opt('manifest'), 'utf8')) : null;
|
|
784
931
|
const { shows, ...display } = DISPLAYS.desktop; // the frame-size run's pairing at 1194 px
|
|
785
932
|
const shotsDir = opt('shots', process.cwd());
|
|
@@ -961,18 +1108,29 @@ if (TRAVEL) {
|
|
|
961
1108
|
// not counted by its clock while the site's and the shell's run on;
|
|
962
1109
|
// those drift ahead of the game's by that much (clockSkewMs records how
|
|
963
1110
|
// far), which the game cannot see (other origins) and the server does
|
|
964
|
-
// not follow. A frame that did not answer within
|
|
1111
|
+
// not follow. A frame that did not answer within 60 s is a frame whose
|
|
965
1112
|
// clock did not move: the variant breaks rather than judging a game on
|
|
966
|
-
// clocks that disagree.
|
|
1113
|
+
// clocks that disagree. An answer after 10 s is the job's software
|
|
1114
|
+
// graphics behind a game that draws every frame (the answer waits for
|
|
1115
|
+
// the frames queued before it), not the game's clock: the variant goes
|
|
1116
|
+
// on and says how long it waited (slowJumpMs).
|
|
967
1117
|
const advance = async (op, ms) => {
|
|
968
1118
|
let timer;
|
|
969
|
-
const
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1119
|
+
const started = Date.now();
|
|
1120
|
+
const answer = page.evaluate(siteJump, [RUN_TOKEN, op, ms]);
|
|
1121
|
+
const within = (limit) => Promise.race([answer, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), limit); })]).finally(() => clearTimeout(timer));
|
|
1122
|
+
const r = await within(10000).catch(() => within(50000)).catch(async (e) => {
|
|
973
1123
|
const tally = await page.evaluate(() => window.__clock).catch(() => null);
|
|
974
|
-
|
|
1124
|
+
// Every window of the game answered and only ours did not (the
|
|
1125
|
+
// site's page, the shell): the checker's own lag, said first,
|
|
1126
|
+
// since the line is cut at 160 characters. Still a break: a
|
|
1127
|
+
// game must not win a skipped variant by slowing the checker.
|
|
1128
|
+
const ours = tally && checkerOnlySilent(page.frames().map((f) => ({ url: f.url(), game: isGameFrame(f) })), tally.from, tally.posted);
|
|
1129
|
+
if (ours) throw new Error(`the checker's own page did not keep up with the clock (${op} ${ms} ms) within 60 s, while every window of the game did (${tally.from.length} of ${tally.posted} answered, silent: ${ours.join(' ')})`);
|
|
1130
|
+
throw new Error(`the fake clock did not move (${op} ${ms} ms) in every frame within 60 s${tally ? `: ${tally.from.length} of ${tally.posted} window(s) answered (${tally.from.join(', ')})` : ''}: ${e.message}`);
|
|
975
1131
|
});
|
|
1132
|
+
const waited = Date.now() - started;
|
|
1133
|
+
if (waited > 10000) v.slowJumpMs = Math.max(v.slowJumpMs || 0, waited);
|
|
976
1134
|
const far = Math.max(...r.nows), near = Math.min(...r.nows);
|
|
977
1135
|
v.clockSkewMs = Math.max(v.clockSkewMs || 0, far - near);
|
|
978
1136
|
const game = r.nows.filter((_, i) => String(r.from[i] || '').startsWith(ORIGIN.studio));
|
|
@@ -997,6 +1155,7 @@ if (TRAVEL) {
|
|
|
997
1155
|
const pageDialogs = [];
|
|
998
1156
|
page.on('dialog', (d) => { pageDialogs.push(d.type()); d.dismiss().catch(() => {}); });
|
|
999
1157
|
const m = instrument(page);
|
|
1158
|
+
const consoleLines = consoleRecorder(page, RUN_TOKEN);
|
|
1000
1159
|
const h = helpers(page);
|
|
1001
1160
|
// The game's storage as it stood before the walk (at the variant's own
|
|
1002
1161
|
// instant), for a variant whose clock is not past the walk's end.
|
|
@@ -1101,11 +1260,23 @@ if (TRAVEL) {
|
|
|
1101
1260
|
let done = h.roundEnds(await h.log()) > ends;
|
|
1102
1261
|
for (let k = 0; k < ROUND_GAME_MS / STEP_MS && !done; k++) {
|
|
1103
1262
|
await step();
|
|
1104
|
-
if (hbtest.then?.length) await h.thenSteps(hbtest.then);
|
|
1263
|
+
if (hbtest.then?.length) await h.thenSteps(hbtest.then, 3);
|
|
1105
1264
|
done = h.roundEnds(await h.log()) > ends;
|
|
1106
1265
|
}
|
|
1107
1266
|
if (!done) { v.stuck = `round ${i + 1} did not end within ${ROUND_GAME_MS / 1000} s of game time${await screenSays(page)}`; break; }
|
|
1108
1267
|
v.settled++;
|
|
1268
|
+
// The first round of each new forced row (a loss, then each pay
|
|
1269
|
+
// tier): its screen kept, with what the maths pays for it, for
|
|
1270
|
+
// the reviewer's "what the screen shows matches what was paid".
|
|
1271
|
+
if (rows && i >= 1 && (v.outcomes ||= []).length < OUTCOME_SHOTS && !v.outcomes.some((o) => o.row === rows[i])) {
|
|
1272
|
+
const name = outcomeShotName(v.outcomes.length + 1);
|
|
1273
|
+
const png = await page.screenshot().catch(() => null);
|
|
1274
|
+
if (png) {
|
|
1275
|
+
writeFileSync(join(shotsDir, name), png);
|
|
1276
|
+
const played = lastPlayBody && typeof lastPlayBody.mode === 'string' ? { mode: lastPlayBody.mode, bet: lastPlayBody.bet } : null;
|
|
1277
|
+
v.outcomes.push({ row: rows[i], multBp: tierOf(manifest, played, rows[i]), mode: played?.mode || '', bet: played?.bet || '', shot: name });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1109
1280
|
// The two clocks read against each other every so often: the
|
|
1110
1281
|
// server must not fall behind the game over a long session.
|
|
1111
1282
|
if (v.settled % 25 === 0) await syncClock(page);
|
|
@@ -1120,7 +1291,11 @@ if (TRAVEL) {
|
|
|
1120
1291
|
}
|
|
1121
1292
|
}
|
|
1122
1293
|
await record(v, page, m);
|
|
1123
|
-
|
|
1294
|
+
// The picture after the session is the reviewer's, not a verdict:
|
|
1295
|
+
// the session is over, and a capture the job's software renderer
|
|
1296
|
+
// could not make in 30 s is reported as missing (a warning that
|
|
1297
|
+
// names both causes) instead of breaking the session.
|
|
1298
|
+
if (v.rounds) await shotTo(page, shotName(v.id), 'boot', v.id).catch((e) => { v.shotMissing = e.message.split('\n')[0].slice(0, 160); });
|
|
1124
1299
|
}
|
|
1125
1300
|
} catch (e) {
|
|
1126
1301
|
v.broke = e.message.split('\n')[0].slice(0, 160);
|
|
@@ -1133,6 +1308,11 @@ if (TRAVEL) {
|
|
|
1133
1308
|
let bound;
|
|
1134
1309
|
await Promise.race([record(v, page, m), new Promise((r) => { bound = setTimeout(r, 5000); })]).catch(() => {}).finally(() => clearTimeout(bound));
|
|
1135
1310
|
}
|
|
1311
|
+
if (!v.boot || v.broke) {
|
|
1312
|
+
const shot = failureShotName(v.id);
|
|
1313
|
+
v.diagnostics = await failureEvidence(page, { console: consoleLines, runner: runnerInfo(await browserFor(v.tz)), started, shot, shotFile: join(shotsDir, shot), log: h.log, initReceived: m.initReceived() });
|
|
1314
|
+
if (v.diagnostics.screenshot) v.failureShot = shot;
|
|
1315
|
+
}
|
|
1136
1316
|
v.requests = [...new Set(m.outside)];
|
|
1137
1317
|
v.refused = [...new Set(m.refused)];
|
|
1138
1318
|
v.errors = [...new Set([...m.errors, ...v.errors])];
|
|
@@ -1209,9 +1389,16 @@ await Promise.race([Promise.all([...browsers.values()].map((b) => b.close())), n
|
|
|
1209
1389
|
servers.forEach((s) => s.close());
|
|
1210
1390
|
|
|
1211
1391
|
const ok = results.every((r) => r.ok);
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1392
|
+
const incomplete = results.filter((r) => r.status === 'skipped').map((r) => ({ size: r.size, rule: r.rule, detail: r.detail }));
|
|
1393
|
+
for (const v of timeTravel?.variants || []) if (v.rounds && v.boot && !v.skipped && !v.broke && !v.stuck && (v.settled || 0) < v.rounds) incomplete.push({ size: v.id, rule: 'time-travel', detail: `${v.settled || 0} of ${v.rounds} rounds completed` });
|
|
1394
|
+
const coverage = { complete: incomplete.length === 0, incomplete };
|
|
1395
|
+
// What each failure means and what to change (hints.mjs): in the report the
|
|
1396
|
+
// dashboard shows, and under the line here.
|
|
1397
|
+
for (const r of results) { const hint = hintFor(r); if (hint) r.hint = hint; }
|
|
1398
|
+
if (args.includes('--json')) { console.log(JSON.stringify({ ok, slug: SLUG, sandbox: !UNSANDBOXED, softwareWebGL: SOFTWARE_WEBGL.length > 0, checks: results, ...(Object.keys(diagnostics).length ? { diagnostics } : {}), ...(bench ? { bench } : {}), ...(Object.keys(frameConsole).length ? { console: frameConsole } : {}), ...(timeTravel ? { timeTravel } : {}) })); process.exit(ok ? 0 : 1); }
|
|
1399
|
+
if (bench) console.log(`machine: JavaScript ${bench.jsMs} ms, 2D canvas ${bench.canvasMs} ms, WebGL ${bench.webglMs ?? '-'} ms (the upload's job is the yardstick)`);
|
|
1400
|
+
console.log(`${ok ? (coverage.complete ? 'PASS' : 'PASS WITH INCOMPLETE COVERAGE') : 'FAIL'} conformance ${SLUG}${TRAVEL ? ` time travel (${TRAVEL})` : ''}${UNSANDBOXED ? ' (Chrome sandbox OFF)' : ''}`);
|
|
1401
|
+
for (const r of results) console.log(` ${r.status === 'skipped' ? 'skip' : r.status === 'warning' ? 'warn' : r.ok ? 'ok ' : 'FAIL'} ${r.size.padEnd(8)} ${r.rule.padEnd(8)} ${r.detail}${r.hint ? `\n → ${r.hint}` : ''}`);
|
|
1402
|
+
if (timeTravel) for (const v of timeTravel.variants) console.log(` ${v.id.padEnd(8)} ${((v.ms || 0) / 1000).toFixed(1)}s${v.visual ? ` visual ${Math.round(v.visual.changed * 100)}%` : ''}${v.visualLate ? ` late ${Math.round(v.visualLate.changed * 100)}%` : ''}${v.rounds ? ` settled ${v.settled || 0}/${v.rounds}${v.forcedAsked ? ` forced ${v.forcedApplied || 0}/${v.forcedAsked}` : ''}` : ''}${v.betControl ? ` bet ${v.betControl.tag}${v.betControl.id ? '#' + v.betControl.id : ''} ${v.betControl.w}×${v.betControl.h}` : ''}${v.clockSkewMs ? ` skew ${v.clockSkewMs}ms` : ''}${v.slowJumpMs ? ` slow jump ${(v.slowJumpMs / 1000).toFixed(1)}s` : ''}${v.clockDriftMs ? ` drift ${v.clockDriftMs}ms` : ''}${v.unshimmedWorkers ? ` unshimmed ${v.unshimmedWorkers}` : ''}${v.skipped ? ' skipped' : ''}`);
|
|
1216
1403
|
if (timeTravel?.storage) console.log(` storage ${timeTravel.storage.from}: ${timeTravel.storage.keys} key(s) carried in${timeTravel.storage.kept !== undefined ? `, ${timeTravel.storage.kept} kept` : ''}`);
|
|
1217
1404
|
process.exit(ok ? 0 : 1);
|