hbengine 0.2.3
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/README.md +20 -0
- package/engine-cli/connect.mjs +73 -0
- package/engine-cli/hbengine.mjs +565 -0
- package/engine-cli/package.json +1 -0
- package/engine-cli/templates.mjs +14 -0
- package/engine-conformance/analyse-worker.mjs +12 -0
- package/engine-conformance/analyse.mjs +4458 -0
- package/engine-conformance/bundle.mjs +208 -0
- package/engine-conformance/clock.mjs +294 -0
- package/engine-conformance/csp.go +56 -0
- package/engine-conformance/csp.json +6 -0
- package/engine-conformance/csp.mjs +20 -0
- package/engine-conformance/gzip-sizes.mjs +9 -0
- package/engine-conformance/package.json +1 -0
- package/engine-conformance/picture.mjs +55 -0
- package/engine-conformance/report.go +349 -0
- package/engine-conformance/run.mjs +1217 -0
- package/engine-conformance/scan.mjs +476 -0
- package/engine-conformance/testdata/clock-overlay/bundle/assets.json +1 -0
- package/engine-conformance/testdata/clock-overlay/bundle/game.css +17 -0
- package/engine-conformance/testdata/clock-overlay/bundle/game.js +83 -0
- package/engine-conformance/testdata/clock-overlay/bundle/hbtest.json +4 -0
- package/engine-conformance/testdata/clock-overlay/bundle/index.html +34 -0
- package/engine-conformance/testdata/clock-probe/bundle/assets.json +1 -0
- package/engine-conformance/testdata/clock-probe/bundle/game.css +17 -0
- package/engine-conformance/testdata/clock-probe/bundle/game.js +65 -0
- package/engine-conformance/testdata/clock-probe/bundle/hbtest.json +4 -0
- package/engine-conformance/testdata/clock-probe/bundle/index.html +35 -0
- package/engine-conformance/testdata/clock-probe/bundle/probe-worker.js +46 -0
- package/engine-conformance/testdata/clock-probe/bundle/probe.js +210 -0
- package/engine-conformance/testdata/clock-probe/math/dice.tsv +100 -0
- package/engine-conformance/testdata/clock-probe/math/manifest.json +2925 -0
- package/engine-conformance/testdata/clock-worker/bundle/assets.json +1 -0
- package/engine-conformance/testdata/clock-worker/bundle/clock-worker.js +14 -0
- package/engine-conformance/testdata/clock-worker/bundle/game.css +17 -0
- package/engine-conformance/testdata/clock-worker/bundle/game.js +61 -0
- package/engine-conformance/testdata/clock-worker/bundle/hbtest.json +4 -0
- package/engine-conformance/testdata/clock-worker/bundle/index.html +35 -0
- package/engine-conformance/testdata/clock-worker/math/dice.tsv +100 -0
- package/engine-conformance/testdata/clock-worker/math/manifest.json +2925 -0
- package/engine-conformance/travel.mjs +1209 -0
- package/engine-conformance/vendor/acorn/LICENSE +21 -0
- package/engine-conformance/vendor/acorn/acorn.mjs +6266 -0
- package/engine-conformance/wasm-releases.json +168 -0
- package/engine-examples/README.md +23 -0
- package/engine-examples/coin/AGENTS.md +3 -0
- package/engine-examples/coin/README.md +7 -0
- package/engine-examples/coin/bundle/assets.json +1 -0
- package/engine-examples/coin/bundle/game.css +5 -0
- package/engine-examples/coin/bundle/game.js +19 -0
- package/engine-examples/coin/bundle/hbtest.json +5 -0
- package/engine-examples/coin/bundle/index.html +16 -0
- package/engine-examples/coin/math/coin.tsv +2 -0
- package/engine-examples/coin/math/manifest.json +17 -0
- package/engine-examples/crash/AGENTS.md +3 -0
- package/engine-examples/crash/README.md +14 -0
- package/engine-examples/crash/bundle/assets.json +1 -0
- package/engine-examples/crash/bundle/game.css +8 -0
- package/engine-examples/crash/bundle/game.js +90 -0
- package/engine-examples/crash/bundle/hbtest.json +5 -0
- package/engine-examples/crash/bundle/index.html +20 -0
- package/engine-examples/crash/math/crash-01.tsv +2 -0
- package/engine-examples/crash/math/crash-02.tsv +2 -0
- package/engine-examples/crash/math/crash-03.tsv +2 -0
- package/engine-examples/crash/math/crash-04.tsv +2 -0
- package/engine-examples/crash/math/crash-05.tsv +2 -0
- package/engine-examples/crash/math/crash-06.tsv +2 -0
- package/engine-examples/crash/math/crash-07.tsv +2 -0
- package/engine-examples/crash/math/crash-08.tsv +2 -0
- package/engine-examples/crash/math/crash-09.tsv +2 -0
- package/engine-examples/crash/math/crash-10.tsv +2 -0
- package/engine-examples/crash/math/crash-11.tsv +2 -0
- package/engine-examples/crash/math/crash-12.tsv +2 -0
- package/engine-examples/crash/math/crash-13.tsv +2 -0
- package/engine-examples/crash/math/crash-14.tsv +2 -0
- package/engine-examples/crash/math/crash-15.tsv +2 -0
- package/engine-examples/crash/math/crash-16.tsv +2 -0
- package/engine-examples/crash/math/crash-17.tsv +2 -0
- package/engine-examples/crash/math/crash-18.tsv +2 -0
- package/engine-examples/crash/math/crash-19.tsv +2 -0
- package/engine-examples/crash/math/crash-20.tsv +2 -0
- package/engine-examples/crash/math/crash-21.tsv +2 -0
- package/engine-examples/crash/math/crash-22.tsv +2 -0
- package/engine-examples/crash/math/crash-23.tsv +2 -0
- package/engine-examples/crash/math/crash-24.tsv +2 -0
- package/engine-examples/crash/math/crash-25.tsv +2 -0
- package/engine-examples/crash/math/crash-26.tsv +2 -0
- package/engine-examples/crash/math/crash-27.tsv +2 -0
- package/engine-examples/crash/math/crash-28.tsv +2 -0
- package/engine-examples/crash/math/crash-29.tsv +2 -0
- package/engine-examples/crash/math/crash-30.tsv +2 -0
- package/engine-examples/crash/math/crash-31.tsv +2 -0
- package/engine-examples/crash/math/crash-32.tsv +2 -0
- package/engine-examples/crash/math/crash-33.tsv +2 -0
- package/engine-examples/crash/math/crash-34.tsv +2 -0
- package/engine-examples/crash/math/crash-35.tsv +2 -0
- package/engine-examples/crash/math/crash-36.tsv +2 -0
- package/engine-examples/crash/math/crash-37.tsv +2 -0
- package/engine-examples/crash/math/crash-38.tsv +2 -0
- package/engine-examples/crash/math/crash-39.tsv +2 -0
- package/engine-examples/crash/math/crash-40.tsv +2 -0
- package/engine-examples/crash/math/crash-41.tsv +2 -0
- package/engine-examples/crash/math/crash-42.tsv +2 -0
- package/engine-examples/crash/math/crash-43.tsv +2 -0
- package/engine-examples/crash/math/crash-44.tsv +2 -0
- package/engine-examples/crash/math/crash-45.tsv +2 -0
- package/engine-examples/crash/math/crash-46.tsv +2 -0
- package/engine-examples/crash/math/crash-47.tsv +2 -0
- package/engine-examples/crash/math/crash-48.tsv +2 -0
- package/engine-examples/crash/math/crash-49.tsv +2 -0
- package/engine-examples/crash/math/crash-50.tsv +2 -0
- package/engine-examples/crash/math/crash-51.tsv +2 -0
- package/engine-examples/crash/math/crash-52.tsv +2 -0
- package/engine-examples/crash/math/crash-53.tsv +2 -0
- package/engine-examples/crash/math/crash-54.tsv +2 -0
- package/engine-examples/crash/math/crash-55.tsv +2 -0
- package/engine-examples/crash/math/crash-56.tsv +2 -0
- package/engine-examples/crash/math/crash-57.tsv +2 -0
- package/engine-examples/crash/math/crash-58.tsv +2 -0
- package/engine-examples/crash/math/crash-59.tsv +2 -0
- package/engine-examples/crash/math/crash-60.tsv +2 -0
- package/engine-examples/crash/math/crash-61.tsv +2 -0
- package/engine-examples/crash/math/crash-62.tsv +2 -0
- package/engine-examples/crash/math/crash-63.tsv +2 -0
- package/engine-examples/crash/math/crash-64.tsv +2 -0
- package/engine-examples/crash/math/gen.mjs +31 -0
- package/engine-examples/crash/math/manifest.json +1 -0
- package/engine-examples/dice/AGENTS.md +3 -0
- package/engine-examples/dice/README.md +25 -0
- package/engine-examples/dice/bundle/assets.json +1 -0
- package/engine-examples/dice/bundle/game.css +17 -0
- package/engine-examples/dice/bundle/game.js +61 -0
- package/engine-examples/dice/bundle/hbtest.json +4 -0
- package/engine-examples/dice/bundle/index.html +34 -0
- package/engine-examples/dice/math/dice.tsv +100 -0
- package/engine-examples/dice/math/manifest.json +2925 -0
- package/engine-examples/keno/AGENTS.md +3 -0
- package/engine-examples/keno/README.md +11 -0
- package/engine-examples/keno/bundle/assets.json +1 -0
- package/engine-examples/keno/bundle/game.css +14 -0
- package/engine-examples/keno/bundle/game.js +86 -0
- package/engine-examples/keno/bundle/hbtest.json +4 -0
- package/engine-examples/keno/bundle/i18n.json +342 -0
- package/engine-examples/keno/bundle/index.html +20 -0
- package/engine-examples/keno/bundle/model.json +1 -0
- package/engine-examples/keno/math/gen.mjs +35 -0
- package/engine-examples/keno/math/keno-picks-1.tsv +2 -0
- package/engine-examples/keno/math/keno-picks-10.tsv +11 -0
- package/engine-examples/keno/math/keno-picks-2.tsv +3 -0
- package/engine-examples/keno/math/keno-picks-3.tsv +4 -0
- package/engine-examples/keno/math/keno-picks-4.tsv +5 -0
- package/engine-examples/keno/math/keno-picks-5.tsv +6 -0
- package/engine-examples/keno/math/keno-picks-6.tsv +7 -0
- package/engine-examples/keno/math/keno-picks-7.tsv +8 -0
- package/engine-examples/keno/math/keno-picks-8.tsv +9 -0
- package/engine-examples/keno/math/keno-picks-9.tsv +10 -0
- package/engine-examples/keno/math/manifest.json +1 -0
- package/engine-examples/keno/math-kit/pack.mjs +165 -0
- package/engine-examples/keno/math-kit/package.json +8 -0
- package/engine-examples/keno/math-kit/slot.mjs +94 -0
- package/engine-examples/keno/math-kit/tumble.mjs +94 -0
- package/engine-examples/lines/AGENTS.md +3 -0
- package/engine-examples/lines/README.md +20 -0
- package/engine-examples/lines/bundle/assets.json +1 -0
- package/engine-examples/lines/bundle/game.css +16 -0
- package/engine-examples/lines/bundle/game.js +125 -0
- package/engine-examples/lines/bundle/hbtest.json +4 -0
- package/engine-examples/lines/bundle/i18n.json +242 -0
- package/engine-examples/lines/bundle/index.html +23 -0
- package/engine-examples/lines/bundle/model.json +1 -0
- package/engine-examples/lines/math/gen.mjs +54 -0
- package/engine-examples/lines/math-kit/pack.mjs +165 -0
- package/engine-examples/lines/math-kit/package.json +8 -0
- package/engine-examples/lines/math-kit/slot.mjs +94 -0
- package/engine-examples/lines/math-kit/tumble.mjs +94 -0
- package/engine-examples/mines/AGENTS.md +3 -0
- package/engine-examples/mines/README.md +13 -0
- package/engine-examples/mines/bundle/assets.json +1 -0
- package/engine-examples/mines/bundle/game.css +12 -0
- package/engine-examples/mines/bundle/game.js +58 -0
- package/engine-examples/mines/bundle/hbtest.json +8 -0
- package/engine-examples/mines/bundle/index.html +16 -0
- package/engine-examples/mines/math/gen.mjs +40 -0
- package/engine-examples/mines/math/manifest.json +1 -0
- package/engine-examples/mines/math/mines-1-1.tsv +2 -0
- package/engine-examples/mines/math/mines-1-2.tsv +2 -0
- package/engine-examples/mines/math/mines-1-22.tsv +2 -0
- package/engine-examples/mines/math/mines-1-23.tsv +2 -0
- package/engine-examples/mines/math/mines-1-24.tsv +2 -0
- package/engine-examples/mines/math/mines-1-3.tsv +2 -0
- package/engine-examples/mines/math/mines-10-1.tsv +2 -0
- package/engine-examples/mines/math/mines-10-10.tsv +2 -0
- package/engine-examples/mines/math/mines-10-11.tsv +2 -0
- package/engine-examples/mines/math/mines-10-12.tsv +2 -0
- package/engine-examples/mines/math/mines-10-13.tsv +2 -0
- package/engine-examples/mines/math/mines-10-14.tsv +2 -0
- package/engine-examples/mines/math/mines-10-15.tsv +2 -0
- package/engine-examples/mines/math/mines-10-2.tsv +2 -0
- package/engine-examples/mines/math/mines-10-3.tsv +2 -0
- package/engine-examples/mines/math/mines-10-4.tsv +2 -0
- package/engine-examples/mines/math/mines-10-5.tsv +2 -0
- package/engine-examples/mines/math/mines-10-6.tsv +2 -0
- package/engine-examples/mines/math/mines-10-7.tsv +2 -0
- package/engine-examples/mines/math/mines-10-8.tsv +2 -0
- package/engine-examples/mines/math/mines-10-9.tsv +2 -0
- package/engine-examples/mines/math/mines-11-1.tsv +2 -0
- package/engine-examples/mines/math/mines-11-10.tsv +2 -0
- package/engine-examples/mines/math/mines-11-11.tsv +2 -0
- package/engine-examples/mines/math/mines-11-12.tsv +2 -0
- package/engine-examples/mines/math/mines-11-13.tsv +2 -0
- package/engine-examples/mines/math/mines-11-14.tsv +2 -0
- package/engine-examples/mines/math/mines-11-2.tsv +2 -0
- package/engine-examples/mines/math/mines-11-3.tsv +2 -0
- package/engine-examples/mines/math/mines-11-4.tsv +2 -0
- package/engine-examples/mines/math/mines-11-5.tsv +2 -0
- package/engine-examples/mines/math/mines-11-6.tsv +2 -0
- package/engine-examples/mines/math/mines-11-7.tsv +2 -0
- package/engine-examples/mines/math/mines-11-8.tsv +2 -0
- package/engine-examples/mines/math/mines-11-9.tsv +2 -0
- package/engine-examples/mines/math/mines-12-1.tsv +2 -0
- package/engine-examples/mines/math/mines-12-10.tsv +2 -0
- package/engine-examples/mines/math/mines-12-11.tsv +2 -0
- package/engine-examples/mines/math/mines-12-12.tsv +2 -0
- package/engine-examples/mines/math/mines-12-13.tsv +2 -0
- package/engine-examples/mines/math/mines-12-2.tsv +2 -0
- package/engine-examples/mines/math/mines-12-3.tsv +2 -0
- package/engine-examples/mines/math/mines-12-4.tsv +2 -0
- package/engine-examples/mines/math/mines-12-5.tsv +2 -0
- package/engine-examples/mines/math/mines-12-6.tsv +2 -0
- package/engine-examples/mines/math/mines-12-7.tsv +2 -0
- package/engine-examples/mines/math/mines-12-8.tsv +2 -0
- package/engine-examples/mines/math/mines-12-9.tsv +2 -0
- package/engine-examples/mines/math/mines-13-1.tsv +2 -0
- package/engine-examples/mines/math/mines-13-10.tsv +2 -0
- package/engine-examples/mines/math/mines-13-11.tsv +2 -0
- package/engine-examples/mines/math/mines-13-12.tsv +2 -0
- package/engine-examples/mines/math/mines-13-2.tsv +2 -0
- package/engine-examples/mines/math/mines-13-3.tsv +2 -0
- package/engine-examples/mines/math/mines-13-4.tsv +2 -0
- package/engine-examples/mines/math/mines-13-5.tsv +2 -0
- package/engine-examples/mines/math/mines-13-6.tsv +2 -0
- package/engine-examples/mines/math/mines-13-7.tsv +2 -0
- package/engine-examples/mines/math/mines-13-8.tsv +2 -0
- package/engine-examples/mines/math/mines-13-9.tsv +2 -0
- package/engine-examples/mines/math/mines-14-1.tsv +2 -0
- package/engine-examples/mines/math/mines-14-10.tsv +2 -0
- package/engine-examples/mines/math/mines-14-11.tsv +2 -0
- package/engine-examples/mines/math/mines-14-2.tsv +2 -0
- package/engine-examples/mines/math/mines-14-3.tsv +2 -0
- package/engine-examples/mines/math/mines-14-4.tsv +2 -0
- package/engine-examples/mines/math/mines-14-5.tsv +2 -0
- package/engine-examples/mines/math/mines-14-6.tsv +2 -0
- package/engine-examples/mines/math/mines-14-7.tsv +2 -0
- package/engine-examples/mines/math/mines-14-8.tsv +2 -0
- package/engine-examples/mines/math/mines-14-9.tsv +2 -0
- package/engine-examples/mines/math/mines-15-1.tsv +2 -0
- package/engine-examples/mines/math/mines-15-10.tsv +2 -0
- package/engine-examples/mines/math/mines-15-2.tsv +2 -0
- package/engine-examples/mines/math/mines-15-3.tsv +2 -0
- package/engine-examples/mines/math/mines-15-4.tsv +2 -0
- package/engine-examples/mines/math/mines-15-5.tsv +2 -0
- package/engine-examples/mines/math/mines-15-6.tsv +2 -0
- package/engine-examples/mines/math/mines-15-7.tsv +2 -0
- package/engine-examples/mines/math/mines-15-8.tsv +2 -0
- package/engine-examples/mines/math/mines-15-9.tsv +2 -0
- package/engine-examples/mines/math/mines-16-1.tsv +2 -0
- package/engine-examples/mines/math/mines-16-2.tsv +2 -0
- package/engine-examples/mines/math/mines-16-3.tsv +2 -0
- package/engine-examples/mines/math/mines-16-4.tsv +2 -0
- package/engine-examples/mines/math/mines-16-5.tsv +2 -0
- package/engine-examples/mines/math/mines-16-6.tsv +2 -0
- package/engine-examples/mines/math/mines-16-7.tsv +2 -0
- package/engine-examples/mines/math/mines-16-8.tsv +2 -0
- package/engine-examples/mines/math/mines-16-9.tsv +2 -0
- package/engine-examples/mines/math/mines-17-1.tsv +2 -0
- package/engine-examples/mines/math/mines-17-2.tsv +2 -0
- package/engine-examples/mines/math/mines-17-3.tsv +2 -0
- package/engine-examples/mines/math/mines-17-4.tsv +2 -0
- package/engine-examples/mines/math/mines-17-5.tsv +2 -0
- package/engine-examples/mines/math/mines-17-6.tsv +2 -0
- package/engine-examples/mines/math/mines-17-7.tsv +2 -0
- package/engine-examples/mines/math/mines-17-8.tsv +2 -0
- package/engine-examples/mines/math/mines-18-1.tsv +2 -0
- package/engine-examples/mines/math/mines-18-2.tsv +2 -0
- package/engine-examples/mines/math/mines-18-3.tsv +2 -0
- package/engine-examples/mines/math/mines-18-4.tsv +2 -0
- package/engine-examples/mines/math/mines-18-5.tsv +2 -0
- package/engine-examples/mines/math/mines-18-6.tsv +2 -0
- package/engine-examples/mines/math/mines-18-7.tsv +2 -0
- package/engine-examples/mines/math/mines-19-1.tsv +2 -0
- package/engine-examples/mines/math/mines-19-2.tsv +2 -0
- package/engine-examples/mines/math/mines-19-3.tsv +2 -0
- package/engine-examples/mines/math/mines-19-4.tsv +2 -0
- package/engine-examples/mines/math/mines-19-5.tsv +2 -0
- package/engine-examples/mines/math/mines-19-6.tsv +2 -0
- package/engine-examples/mines/math/mines-2-1.tsv +2 -0
- package/engine-examples/mines/math/mines-2-19.tsv +2 -0
- package/engine-examples/mines/math/mines-2-2.tsv +2 -0
- package/engine-examples/mines/math/mines-2-20.tsv +2 -0
- package/engine-examples/mines/math/mines-2-21.tsv +2 -0
- package/engine-examples/mines/math/mines-2-22.tsv +2 -0
- package/engine-examples/mines/math/mines-2-23.tsv +2 -0
- package/engine-examples/mines/math/mines-2-3.tsv +2 -0
- package/engine-examples/mines/math/mines-2-4.tsv +2 -0
- package/engine-examples/mines/math/mines-2-5.tsv +2 -0
- package/engine-examples/mines/math/mines-20-1.tsv +2 -0
- package/engine-examples/mines/math/mines-20-2.tsv +2 -0
- package/engine-examples/mines/math/mines-20-3.tsv +2 -0
- package/engine-examples/mines/math/mines-20-4.tsv +2 -0
- package/engine-examples/mines/math/mines-20-5.tsv +2 -0
- package/engine-examples/mines/math/mines-21-1.tsv +2 -0
- package/engine-examples/mines/math/mines-21-2.tsv +2 -0
- package/engine-examples/mines/math/mines-21-3.tsv +2 -0
- package/engine-examples/mines/math/mines-21-4.tsv +2 -0
- package/engine-examples/mines/math/mines-22-1.tsv +2 -0
- package/engine-examples/mines/math/mines-22-2.tsv +2 -0
- package/engine-examples/mines/math/mines-22-3.tsv +2 -0
- package/engine-examples/mines/math/mines-23-1.tsv +2 -0
- package/engine-examples/mines/math/mines-23-2.tsv +2 -0
- package/engine-examples/mines/math/mines-24-1.tsv +2 -0
- package/engine-examples/mines/math/mines-3-1.tsv +2 -0
- package/engine-examples/mines/math/mines-3-17.tsv +2 -0
- package/engine-examples/mines/math/mines-3-18.tsv +2 -0
- package/engine-examples/mines/math/mines-3-19.tsv +2 -0
- package/engine-examples/mines/math/mines-3-2.tsv +2 -0
- package/engine-examples/mines/math/mines-3-20.tsv +2 -0
- package/engine-examples/mines/math/mines-3-21.tsv +2 -0
- package/engine-examples/mines/math/mines-3-22.tsv +2 -0
- package/engine-examples/mines/math/mines-3-3.tsv +2 -0
- package/engine-examples/mines/math/mines-3-4.tsv +2 -0
- package/engine-examples/mines/math/mines-3-5.tsv +2 -0
- package/engine-examples/mines/math/mines-3-6.tsv +2 -0
- package/engine-examples/mines/math/mines-4-1.tsv +2 -0
- package/engine-examples/mines/math/mines-4-13.tsv +2 -0
- package/engine-examples/mines/math/mines-4-14.tsv +2 -0
- package/engine-examples/mines/math/mines-4-15.tsv +2 -0
- package/engine-examples/mines/math/mines-4-16.tsv +2 -0
- package/engine-examples/mines/math/mines-4-17.tsv +2 -0
- package/engine-examples/mines/math/mines-4-18.tsv +2 -0
- package/engine-examples/mines/math/mines-4-19.tsv +2 -0
- package/engine-examples/mines/math/mines-4-2.tsv +2 -0
- package/engine-examples/mines/math/mines-4-20.tsv +2 -0
- package/engine-examples/mines/math/mines-4-21.tsv +2 -0
- package/engine-examples/mines/math/mines-4-3.tsv +2 -0
- package/engine-examples/mines/math/mines-4-4.tsv +2 -0
- package/engine-examples/mines/math/mines-4-5.tsv +2 -0
- package/engine-examples/mines/math/mines-4-6.tsv +2 -0
- package/engine-examples/mines/math/mines-4-7.tsv +2 -0
- package/engine-examples/mines/math/mines-4-8.tsv +2 -0
- package/engine-examples/mines/math/mines-4-9.tsv +2 -0
- package/engine-examples/mines/math/mines-5-1.tsv +2 -0
- package/engine-examples/mines/math/mines-5-10.tsv +2 -0
- package/engine-examples/mines/math/mines-5-11.tsv +2 -0
- package/engine-examples/mines/math/mines-5-12.tsv +2 -0
- package/engine-examples/mines/math/mines-5-13.tsv +2 -0
- package/engine-examples/mines/math/mines-5-14.tsv +2 -0
- package/engine-examples/mines/math/mines-5-15.tsv +2 -0
- package/engine-examples/mines/math/mines-5-16.tsv +2 -0
- package/engine-examples/mines/math/mines-5-17.tsv +2 -0
- package/engine-examples/mines/math/mines-5-18.tsv +2 -0
- package/engine-examples/mines/math/mines-5-19.tsv +2 -0
- package/engine-examples/mines/math/mines-5-2.tsv +2 -0
- package/engine-examples/mines/math/mines-5-20.tsv +2 -0
- package/engine-examples/mines/math/mines-5-3.tsv +2 -0
- package/engine-examples/mines/math/mines-5-4.tsv +2 -0
- package/engine-examples/mines/math/mines-5-5.tsv +2 -0
- package/engine-examples/mines/math/mines-5-6.tsv +2 -0
- package/engine-examples/mines/math/mines-5-7.tsv +2 -0
- package/engine-examples/mines/math/mines-5-8.tsv +2 -0
- package/engine-examples/mines/math/mines-5-9.tsv +2 -0
- package/engine-examples/mines/math/mines-6-1.tsv +2 -0
- package/engine-examples/mines/math/mines-6-10.tsv +2 -0
- package/engine-examples/mines/math/mines-6-11.tsv +2 -0
- package/engine-examples/mines/math/mines-6-12.tsv +2 -0
- package/engine-examples/mines/math/mines-6-13.tsv +2 -0
- package/engine-examples/mines/math/mines-6-14.tsv +2 -0
- package/engine-examples/mines/math/mines-6-15.tsv +2 -0
- package/engine-examples/mines/math/mines-6-16.tsv +2 -0
- package/engine-examples/mines/math/mines-6-17.tsv +2 -0
- package/engine-examples/mines/math/mines-6-18.tsv +2 -0
- package/engine-examples/mines/math/mines-6-19.tsv +2 -0
- package/engine-examples/mines/math/mines-6-2.tsv +2 -0
- package/engine-examples/mines/math/mines-6-3.tsv +2 -0
- package/engine-examples/mines/math/mines-6-4.tsv +2 -0
- package/engine-examples/mines/math/mines-6-5.tsv +2 -0
- package/engine-examples/mines/math/mines-6-6.tsv +2 -0
- package/engine-examples/mines/math/mines-6-7.tsv +2 -0
- package/engine-examples/mines/math/mines-6-8.tsv +2 -0
- package/engine-examples/mines/math/mines-6-9.tsv +2 -0
- package/engine-examples/mines/math/mines-7-1.tsv +2 -0
- package/engine-examples/mines/math/mines-7-10.tsv +2 -0
- package/engine-examples/mines/math/mines-7-11.tsv +2 -0
- package/engine-examples/mines/math/mines-7-12.tsv +2 -0
- package/engine-examples/mines/math/mines-7-13.tsv +2 -0
- package/engine-examples/mines/math/mines-7-14.tsv +2 -0
- package/engine-examples/mines/math/mines-7-15.tsv +2 -0
- package/engine-examples/mines/math/mines-7-16.tsv +2 -0
- package/engine-examples/mines/math/mines-7-17.tsv +2 -0
- package/engine-examples/mines/math/mines-7-18.tsv +2 -0
- package/engine-examples/mines/math/mines-7-2.tsv +2 -0
- package/engine-examples/mines/math/mines-7-3.tsv +2 -0
- package/engine-examples/mines/math/mines-7-4.tsv +2 -0
- package/engine-examples/mines/math/mines-7-5.tsv +2 -0
- package/engine-examples/mines/math/mines-7-6.tsv +2 -0
- package/engine-examples/mines/math/mines-7-7.tsv +2 -0
- package/engine-examples/mines/math/mines-7-8.tsv +2 -0
- package/engine-examples/mines/math/mines-7-9.tsv +2 -0
- package/engine-examples/mines/math/mines-8-1.tsv +2 -0
- package/engine-examples/mines/math/mines-8-10.tsv +2 -0
- package/engine-examples/mines/math/mines-8-11.tsv +2 -0
- package/engine-examples/mines/math/mines-8-12.tsv +2 -0
- package/engine-examples/mines/math/mines-8-13.tsv +2 -0
- package/engine-examples/mines/math/mines-8-14.tsv +2 -0
- package/engine-examples/mines/math/mines-8-15.tsv +2 -0
- package/engine-examples/mines/math/mines-8-16.tsv +2 -0
- package/engine-examples/mines/math/mines-8-17.tsv +2 -0
- package/engine-examples/mines/math/mines-8-2.tsv +2 -0
- package/engine-examples/mines/math/mines-8-3.tsv +2 -0
- package/engine-examples/mines/math/mines-8-4.tsv +2 -0
- package/engine-examples/mines/math/mines-8-5.tsv +2 -0
- package/engine-examples/mines/math/mines-8-6.tsv +2 -0
- package/engine-examples/mines/math/mines-8-7.tsv +2 -0
- package/engine-examples/mines/math/mines-8-8.tsv +2 -0
- package/engine-examples/mines/math/mines-8-9.tsv +2 -0
- package/engine-examples/mines/math/mines-9-1.tsv +2 -0
- package/engine-examples/mines/math/mines-9-10.tsv +2 -0
- package/engine-examples/mines/math/mines-9-11.tsv +2 -0
- package/engine-examples/mines/math/mines-9-12.tsv +2 -0
- package/engine-examples/mines/math/mines-9-13.tsv +2 -0
- package/engine-examples/mines/math/mines-9-14.tsv +2 -0
- package/engine-examples/mines/math/mines-9-15.tsv +2 -0
- package/engine-examples/mines/math/mines-9-16.tsv +2 -0
- package/engine-examples/mines/math/mines-9-2.tsv +2 -0
- package/engine-examples/mines/math/mines-9-3.tsv +2 -0
- package/engine-examples/mines/math/mines-9-4.tsv +2 -0
- package/engine-examples/mines/math/mines-9-5.tsv +2 -0
- package/engine-examples/mines/math/mines-9-6.tsv +2 -0
- package/engine-examples/mines/math/mines-9-7.tsv +2 -0
- package/engine-examples/mines/math/mines-9-8.tsv +2 -0
- package/engine-examples/mines/math/mines-9-9.tsv +2 -0
- package/engine-examples/plinko/AGENTS.md +3 -0
- package/engine-examples/plinko/README.md +9 -0
- package/engine-examples/plinko/bundle/assets.json +1 -0
- package/engine-examples/plinko/bundle/game.css +5 -0
- package/engine-examples/plinko/bundle/game.js +92 -0
- package/engine-examples/plinko/bundle/hbtest.json +4 -0
- package/engine-examples/plinko/bundle/index.html +16 -0
- package/engine-examples/plinko/bundle/model.json +1 -0
- package/engine-examples/plinko/math/gen.mjs +40 -0
- package/engine-examples/plinko/math/manifest.json +1 -0
- package/engine-examples/plinko/math/plinko-12-high.tsv +13 -0
- package/engine-examples/plinko/math/plinko-12-low.tsv +13 -0
- package/engine-examples/plinko/math/plinko-12-medium.tsv +13 -0
- package/engine-examples/plinko/math/plinko-16-high.tsv +17 -0
- package/engine-examples/plinko/math/plinko-16-low.tsv +17 -0
- package/engine-examples/plinko/math/plinko-16-medium.tsv +17 -0
- package/engine-examples/plinko/math/plinko-8-high.tsv +9 -0
- package/engine-examples/plinko/math/plinko-8-low.tsv +9 -0
- package/engine-examples/plinko/math/plinko-8-medium.tsv +9 -0
- package/engine-examples/plinko/math-kit/pack.mjs +165 -0
- package/engine-examples/plinko/math-kit/package.json +8 -0
- package/engine-examples/plinko/math-kit/slot.mjs +94 -0
- package/engine-examples/plinko/math-kit/tumble.mjs +94 -0
- package/engine-examples/slot/AGENTS.md +3 -0
- package/engine-examples/slot/README.md +26 -0
- package/engine-examples/slot/bundle/assets.json +1 -0
- package/engine-examples/slot/bundle/game.css +19 -0
- package/engine-examples/slot/bundle/game.js +42 -0
- package/engine-examples/slot/bundle/hbtest.json +4 -0
- package/engine-examples/slot/bundle/index.html +23 -0
- package/engine-examples/slot/math/gen.mjs +79 -0
- package/engine-examples/slot/math/manifest.json +1 -0
- package/engine-examples/slot/math/slot.tsv +110592 -0
- package/engine-examples/tower/AGENTS.md +3 -0
- package/engine-examples/tower/README.md +21 -0
- package/engine-examples/tower/bundle/assets.json +1 -0
- package/engine-examples/tower/bundle/game.css +8 -0
- package/engine-examples/tower/bundle/game.js +37 -0
- package/engine-examples/tower/bundle/hbtest.json +5 -0
- package/engine-examples/tower/bundle/index.html +16 -0
- package/engine-examples/tower/math/floor.tsv +4 -0
- package/engine-examples/tower/math/manifest.json +61 -0
- package/engine-examples/tumble/AGENTS.md +3 -0
- package/engine-examples/tumble/README.md +18 -0
- package/engine-examples/tumble/bundle/assets.json +1 -0
- package/engine-examples/tumble/bundle/game.css +13 -0
- package/engine-examples/tumble/bundle/game.js +119 -0
- package/engine-examples/tumble/bundle/hbtest.json +4 -0
- package/engine-examples/tumble/bundle/i18n.json +122 -0
- package/engine-examples/tumble/bundle/index.html +20 -0
- package/engine-examples/tumble/bundle/model.json +1 -0
- package/engine-examples/tumble/math/gen.mjs +38 -0
- package/engine-examples/tumble/math-kit/pack.mjs +165 -0
- package/engine-examples/tumble/math-kit/package.json +8 -0
- package/engine-examples/tumble/math-kit/slot.mjs +94 -0
- package/engine-examples/tumble/math-kit/tumble.mjs +94 -0
- package/engine-examples/wheel/README.md +14 -0
- package/engine-examples/wheel/bundle/assets.json +1 -0
- package/engine-examples/wheel/bundle/game.css +7 -0
- package/engine-examples/wheel/bundle/game.js +51 -0
- package/engine-examples/wheel/bundle/hbtest.json +4 -0
- package/engine-examples/wheel/bundle/index.html +19 -0
- package/engine-examples/wheel/math/high.tsv +24 -0
- package/engine-examples/wheel/math/low.tsv +24 -0
- package/engine-examples/wheel/math/manifest.json +48 -0
- package/engine-examples/wheel/math/medium.tsv +24 -0
- package/engine-math/pack.mjs +165 -0
- package/engine-math/package.json +1 -0
- package/engine-math/slot.mjs +94 -0
- package/engine-math/tumble.mjs +94 -0
- package/engine-mcp/package.json +1 -0
- package/engine-mcp/server.mjs +151 -0
- package/engine-sdk/GUIDE.md +855 -0
- package/engine-sdk/assets.js +92 -0
- package/engine-sdk/autoplay.js +82 -0
- package/engine-sdk/bet-field.js +391 -0
- package/engine-sdk/kit/fiat-coins.js +51 -0
- package/engine-sdk/kit/numeric-input.js +116 -0
- package/engine-sdk/kit/wallet-format.js +423 -0
- package/engine-sdk/marks.js +22 -0
- package/engine-sdk/messages.js +289 -0
- package/engine-sdk/money.js +89 -0
- package/engine-sdk/package.json +1 -0
- package/engine-sdk/protocol.js +263 -0
- package/engine-sdk/sdk.d.ts +138 -0
- package/engine-sdk/sdk.js +330 -0
- package/engine-sdk/stake.js +68 -0
- package/engine-sdk/ui.d.ts +36 -0
- package/engine-sdk/ui.js +340 -0
- package/engine-shell/core/faults.js +127 -0
- package/engine-shell/core/rgs.js +193 -0
- package/engine-shell/core/router.js +917 -0
- package/engine-shell/host/boot.js +408 -0
- package/engine-shell/host/engine-v1.js +438 -0
- package/engine-shell/host/fair.js +285 -0
- package/engine-shell/host/fonts/SOURCE.json +11 -0
- package/engine-shell/host/fonts/figtree-400.woff2 +0 -0
- package/engine-shell/host/fonts/figtree-500.woff2 +0 -0
- package/engine-shell/host/fonts/figtree-600.woff2 +0 -0
- package/engine-shell/host/fonts/red-hat-display-700.woff2 +0 -0
- package/engine-shell/host/index.html +230 -0
- package/engine-shell/host/page.js +809 -0
- package/engine-shell/host/sandbox-panel.js +681 -0
- package/engine-shell/host/sandbox-scenarios.js +554 -0
- package/engine-shell/host/site-bridge.js +87 -0
- package/engine-shell/host/strings.js +916 -0
- package/engine-shell/package.json +1 -0
- package/engine-shell/verifiers/badcat/coin.tsv +2 -0
- package/engine-shell/verifiers/badcat/dice-2.tsv +11 -0
- package/engine-shell/verifiers/badcat/dice-5.tsv +26 -0
- package/engine-shell/verifiers/badcat/dice-9.tsv +46 -0
- package/engine-shell/verifiers/badcat/manifest.json +3344 -0
- package/engine-shell/verifiers/badcat/rps.tsv +3 -0
- package/engine-shell/verifiers/badcat/triple.tsv +66 -0
- package/engine-shell/verifiers/engine-verify.js +358 -0
- package/engine-shell/verifiers/package.json +5 -0
- package/engine-shell/verifiers/parity/manifest.json +27 -0
- package/engine-shell/verifiers/parity/wide.tsv +5 -0
- package/engine-shell/verifiers/parity-vectors.json +244 -0
- package/engine-shell/verifiers/vectors.json +100 -0
- package/package.json +10 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The conformance run: a studio bundle played headless inside our real shell
|
|
4
|
+
* against the sandbox engine, at the site's frame sizes, before any person
|
|
5
|
+
* looks at it. Same code in `hbengine test` and in the upload pipeline.
|
|
6
|
+
*
|
|
7
|
+
* node run.mjs <bundle-dir> [--slug bad-cat] [--game-id N] [--rgs http://127.0.0.1:8090] [--json] [--dev]
|
|
8
|
+
*
|
|
9
|
+
* --json prints the checks as JSON (the portal reads it); --dev runs no
|
|
10
|
+
* checks: it serves the bundle inside the real shell and prints a URL to open
|
|
11
|
+
* and play against the sandbox, until stopped.
|
|
12
|
+
*
|
|
13
|
+
* The harness plays the site's part (the frame: column width × 720, as the
|
|
14
|
+
* site's frame for a House game, and the housegames bridge), serves the shell from its own origin
|
|
15
|
+
* and the bundle from another with the sandbox header, and launches a
|
|
16
|
+
* play-money player through the engine's real merchant flow.
|
|
17
|
+
*
|
|
18
|
+
* Checks, per frame size (desktop 1194×720, phone 364×720):
|
|
19
|
+
* boot the game asks for its config (hello) and says ready in time
|
|
20
|
+
* idle left untouched for 15 s, the game places no bet
|
|
21
|
+
* errors no uncaught error in any of the game's pages
|
|
22
|
+
* network no request leaves the bundle's own origin, the policy
|
|
23
|
+
* refuses nothing (a refused worker, script or style is a code
|
|
24
|
+
* path that does not work in production), and nothing is posted
|
|
25
|
+
* to the policy's report path (only the browser reports there)
|
|
26
|
+
* fit the game fits the frame it is given, as a House Original does:
|
|
27
|
+
* no page scrolls inside it and every visible control lies
|
|
28
|
+
* within it, at 720 high and again in fullscreen (desktop
|
|
29
|
+
* 1920×1080, phone 390×844). The site never grows the frame.
|
|
30
|
+
* bet with the studio's hbtest.json: one bet placed through the
|
|
31
|
+
* game's own UI reaches engine.play and is followed by
|
|
32
|
+
* `revealed` (the site's round-end) within the reveal deadline.
|
|
33
|
+
* The press is a real Playwright click: the shell takes a play
|
|
34
|
+
* only on the player's activation (its click gate), so a game
|
|
35
|
+
* whose Bet does not bet from the click handler fails here
|
|
36
|
+
* currency the bet above, under the player's display (desktop: EUR at a
|
|
37
|
+
* rate, phone: the bare coin): the game sends exactly the stake
|
|
38
|
+
* the Originals would for what was typed (engine.money /
|
|
39
|
+
* <hb-bet-amount>), and the screen shows that currency
|
|
40
|
+
* outage the next bet meets an ambiguous wallet answer (engine-dev's
|
|
41
|
+
* /dev/fault): the game does not break, the site is asked to
|
|
42
|
+
* re-read the wallet
|
|
43
|
+
*
|
|
44
|
+
* The run plays against engine-dev (the offline sandbox, `--rgs`), never a
|
|
45
|
+
* platform: it needs no credential, and holds none. Chrome runs with its own
|
|
46
|
+
* sandbox on; ENGINE_RUN_UNSANDBOXED=1 turns it off on a machine that cannot
|
|
47
|
+
* give one, and the verdict says so. `--skip-outage` is for job-mode runs
|
|
48
|
+
* whose engine-dev has no wallet fault stub; it reports the outage check as
|
|
49
|
+
* skipped after the real round checks.
|
|
50
|
+
*
|
|
51
|
+
* Time travel (`--time-travel all|canary`, travel.mjs): instead of the two
|
|
52
|
+
* frame sizes, the matrix of variants, each a fresh context whose clock
|
|
53
|
+
* (Playwright's, installed before the page loads), time zone (on the context
|
|
54
|
+
* and on a Chrome process of its own, so Blink's own date formatting agrees)
|
|
55
|
+
* and language are set; one plays a long session of fast rounds (`--travel-rounds`, 300) with
|
|
56
|
+
* the outcomes forced through every pay tier of the mode and bet the game
|
|
57
|
+
* plays (`--manifest`), the game's own bet control pressed each time (and the
|
|
58
|
+
* SDK panel's Bet every other time when the game has one). A short variant
|
|
59
|
+
* plays one round, then walks the clock on: half an hour, then a day in
|
|
60
|
+
* hourly jumps, the page scanned after each. Each variant records the
|
|
61
|
+
* requests that left the bundle, the policy's refusals, errors, dialog
|
|
62
|
+
* attempts, workers and child frames started, inputs, text that reads like
|
|
63
|
+
* a credential prompt (in the DOM, in CSS generated content, drawn on a
|
|
64
|
+
* canvas), and screenshots (`travel-<id>.png` after boot and
|
|
65
|
+
* `travel-<id>-late.png` after the walk, in `--shots`), and is judged
|
|
66
|
+
* against the baseline variant: one check line per variant, rule
|
|
67
|
+
* `time-travel`. Every variant starts from the storage the one before it
|
|
68
|
+
* left (the long session right after the baseline, the dated variants in the
|
|
69
|
+
* order of their clocks, and never a storage written at a clock past the
|
|
70
|
+
* variant's own: travel.mjs handedStorage), so a counter kept in
|
|
71
|
+
* localStorage or IndexedDB carries over and a last-seen date never reads
|
|
72
|
+
* from the future; and from the storage an earlier run left when
|
|
73
|
+
* `--storage <file>` names one (written back from the last variant on the
|
|
74
|
+
* real clock: the daily canary carries it from one day to the next). The canary
|
|
75
|
+
* set compares each variant with the approved version's screenshot of the
|
|
76
|
+
* same variant too (`--baselines <dir>` holding `travel-<id>.png`), plays
|
|
77
|
+
* its baseline on the real clock, and a hidden copy of it under the fake
|
|
78
|
+
* clock right after, walked a day on, whose late frame stands as the
|
|
79
|
+
* baseline's for the dated variants' late frames. `--travel-total <s>` is
|
|
80
|
+
* the whole matrix's real time: a variant that no longer fits is skipped and
|
|
81
|
+
* says so, the ones still to come estimated from the short variants already
|
|
82
|
+
* played. The job runs this after the frame-size run.
|
|
83
|
+
*
|
|
84
|
+
* What the game is shown of the run is a player's browser as far as the run
|
|
85
|
+
* can make it, and the same browser on the frame sizes as in the matrix:
|
|
86
|
+
* Chrome without the automation flag (navigator.webdriver is false); the
|
|
87
|
+
* desktop frame under the desktop display, as the frame-size run plays it;
|
|
88
|
+
* every clock API behind a Proxy with the native's name and length in every
|
|
89
|
+
* run (travel.mjs clockShim: Date, the timers and their ids, performance and
|
|
90
|
+
* its entries, Intl, Event.timeStamp, document.timeline and the animations'
|
|
91
|
+
* start times, File.lastModified, Temporal, scheduler.postTask's delay,
|
|
92
|
+
* AbortSignal.timeout, Atomics.waitAsync's timeout), reading the fake clock
|
|
93
|
+
* when the variant travels and the real one otherwise, a timestamp taken
|
|
94
|
+
* before a jump keeping its time; the fake clock itself (Playwright's
|
|
95
|
+
* implementation, clock.mjs) installed from the init script with no global,
|
|
96
|
+
* started where the native monotonic clock stands, calling a timer's callback
|
|
97
|
+
* from a native task so its stack is production's, handing an animation
|
|
98
|
+
* frame the timeline's own reading, moving every running animation on with
|
|
99
|
+
* each jump, and driven through a message the frame hears before the game
|
|
100
|
+
* does; the instrumentation behind native-looking functions with nothing
|
|
101
|
+
* added to window; every file served with a Last-Modified of its own, so
|
|
102
|
+
* document.lastModified is the header and not the real clock; the sandbox's
|
|
103
|
+
* `forced` flag and zero draws rewritten and the server's timestamps and Date
|
|
104
|
+
* headers moved to the travelled clock and moved on with every jump of it;
|
|
105
|
+
* and every worker given a fake clock of its own with the same shim, moved
|
|
106
|
+
* with the page's and running its timers due in each jump (a same-origin
|
|
107
|
+
* script from the server, a blob from its remembered text), so its
|
|
108
|
+
* timeOrigin stands for its life as a browser's does. What it cannot move
|
|
109
|
+
* (the audio clock and media playback, which run on real time) is said in
|
|
110
|
+
* SECURITY-UPLOADS.md §6, with the daily canary's real-clock baseline as the
|
|
111
|
+
* answer to it. A variant whose run broke is still scanned once more, so a
|
|
112
|
+
* credential prompt shown before the break is in the report beside it.
|
|
113
|
+
*
|
|
114
|
+
* hbtest.json (shipped by the studio, sandbox only):
|
|
115
|
+
* { "visit": [{"page", "selector"}, …], "open": [{"page", "selector"}, …],
|
|
116
|
+
* "stake": {"page", "selector", "value"}, "bet": {"page", "selector"},
|
|
117
|
+
* "then": [{"page", "selector"}, …], "shows": {"page", "selector", "path"} }
|
|
118
|
+
* `visit` opens every other screen of the game before the bet (a bundle of
|
|
119
|
+
* several games: each card, then back); an error or a scroll there fails
|
|
120
|
+
* `errors` or `fit` as anywhere else. `open` reaches the page the bet is
|
|
121
|
+
* on (a game behind a menu); time travel plays it before every variant.
|
|
122
|
+
* `then` closes a ladder round the bet opened (Next, Cash out — each pressed
|
|
123
|
+
* only if it is there: a bust can end the round first); `shows`
|
|
124
|
+
* names an element whose text must carry a value of the round (data.0.side).
|
|
125
|
+
*/
|
|
126
|
+
import http from 'node:http';
|
|
127
|
+
import { randomBytes } from 'node:crypto';
|
|
128
|
+
import { readFileSync, existsSync, statSync, writeFileSync } from 'node:fs';
|
|
129
|
+
import { flatShare } from './picture.mjs';
|
|
130
|
+
import { join, extname, normalize } from 'node:path';
|
|
131
|
+
import { moneyFor } from '../engine-sdk/money.js';
|
|
132
|
+
import { walletFrom } from '../engine-shell/host/boot.js';
|
|
133
|
+
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';
|
|
135
|
+
import { clockSource, siteJump, startClock } from './clock.mjs';
|
|
136
|
+
|
|
137
|
+
const args = process.argv.slice(2);
|
|
138
|
+
const BUNDLE = args[0];
|
|
139
|
+
const opt = (k, d) => { const i = args.indexOf('--' + k); return i >= 0 ? args[i + 1] : d; };
|
|
140
|
+
const SLUG = opt('slug', 'bad-cat');
|
|
141
|
+
const GAME_ID = Number(opt('game-id', '9974899'));
|
|
142
|
+
const RGS = opt('rgs', 'http://127.0.0.1:8090');
|
|
143
|
+
const SKIP_OUTAGE = args.includes('--skip-outage');
|
|
144
|
+
// The time-travel matrix instead of the frame sizes: 'all' (the check job) or
|
|
145
|
+
// 'canary' (the daily replay of a live version: the real date and the near future).
|
|
146
|
+
const TRAVEL = opt('time-travel', '');
|
|
147
|
+
const TRAVEL_ROUNDS = Math.max(1, Number(opt('travel-rounds', '300')) || 300);
|
|
148
|
+
// The long session's share of real time (seconds): a game whose rounds are
|
|
149
|
+
// long (a feature of free spins) plays fewer rounds inside it, and says so.
|
|
150
|
+
const TRAVEL_BUDGET_MS = Math.max(10, Number(opt('travel-budget', '240')) || 240) * 1000;
|
|
151
|
+
// The whole matrix's real time (seconds), when the caller has a deadline: a
|
|
152
|
+
// variant that no longer fits is skipped with the reason, the long session
|
|
153
|
+
// shortened to what is left for it. 0: no limit but the long session's own.
|
|
154
|
+
const TRAVEL_TOTAL_MS = Math.max(0, Number(opt('travel-total', '0')) || 0) * 1000;
|
|
155
|
+
// `--variants base,long`: a part of the matrix, for working on one variant (never the job's).
|
|
156
|
+
const TRAVEL_ONLY = opt('variants', '') ? opt('variants').split(',') : null;
|
|
157
|
+
// `--storage <file>`: the game's storage as an earlier run left it, read before
|
|
158
|
+
// the first variant and written after the last (the daily canary carries it
|
|
159
|
+
// from one day to the next, so a counter kept across visits counts the
|
|
160
|
+
// canary's days as a player's). Missing or not the run's own: empty storage.
|
|
161
|
+
const STORAGE_FILE = opt('storage', '');
|
|
162
|
+
const UNSANDBOXED = process.env.ENGINE_RUN_UNSANDBOXED === '1';
|
|
163
|
+
// Chrome: --chrome, CHROME_PATH, the Mac install; otherwise playwright-core's
|
|
164
|
+
// own Chromium (`npx playwright-core install chromium`).
|
|
165
|
+
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).pathname; // packages/
|
|
167
|
+
// Three origins on free ports, so any number of runs play at once: the
|
|
168
|
+
// site on localhost, the shell and the studio on 127.0.0.1 (another site).
|
|
169
|
+
const ORIGIN = { site: '', shell: '', studio: '' };
|
|
170
|
+
const listen = (server, host) => new Promise((r) => server.listen(0, '127.0.0.1', () => r(`http://${host}:${server.address().port}`)));
|
|
171
|
+
// The current variant's clock against the real one: the server's timestamps
|
|
172
|
+
// and every Date header move with it, so nothing the game can read from the
|
|
173
|
+
// network says the browser's clock is travelled. 0 outside time travel.
|
|
174
|
+
let clockOffsetMs = 0;
|
|
175
|
+
// Whether the current variant runs under the fake clock: a worker script
|
|
176
|
+
// served meanwhile is given a fake clock of its own (travel.mjs workerShim).
|
|
177
|
+
let travelling = false;
|
|
178
|
+
// The run's token: what the pages' instrumentation reports under, and the
|
|
179
|
+
// channel the fake clocks are driven through (clock.mjs). Minted here, never
|
|
180
|
+
// in anything a page can read.
|
|
181
|
+
const RUN_TOKEN = randomBytes(12).toString('hex');
|
|
182
|
+
|
|
183
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.woff2': 'font/woff2', '.txt': 'text/plain', '.wasm': 'application/wasm', '.ktx2': 'image/ktx2', '.basis': 'application/octet-stream' };
|
|
184
|
+
const send = (res, code, body, headers = {}) => {
|
|
185
|
+
res.sendDate = false;
|
|
186
|
+
res.writeHead(code, { 'Cache-Control': 'no-store', Date: new Date(Date.now() + clockOffsetMs).toUTCString(), ...headers });
|
|
187
|
+
res.end(body);
|
|
188
|
+
};
|
|
189
|
+
// When the run started: no file is said to be newer than that.
|
|
190
|
+
const RUN_START = Date.now();
|
|
191
|
+
// A file is served with its own Last-Modified (its mtime: the archive's, the
|
|
192
|
+
// same in the frame-size run and the matrix, never after the run began), as a
|
|
193
|
+
// static server would. Without one the browser dates the document itself by
|
|
194
|
+
// the real clock (document.lastModified), which would say the page's clock is
|
|
195
|
+
// travelled; with one, document.lastModified is this header.
|
|
196
|
+
function file(res, root, rel, headers, prelude = '') {
|
|
197
|
+
const p = normalize(join(root, rel));
|
|
198
|
+
// The extension in lower case, as the CDN reads it (bundles.go): main.JS is JavaScript there and here.
|
|
199
|
+
if (!p.startsWith(normalize(root)) || !existsSync(p)) return send(res, 404, 'not found');
|
|
200
|
+
const st = statSync(p);
|
|
201
|
+
if (!st.isFile()) return send(res, 404, 'not found');
|
|
202
|
+
const body = readFileSync(p);
|
|
203
|
+
send(res, 200, prelude && /\.m?js$/.test(p) ? Buffer.concat([Buffer.from(prelude), body]) : body, { 'Content-Type': MIME[extname(p).toLowerCase()] || 'application/octet-stream', 'Last-Modified': new Date(Math.min(st.mtimeMs, RUN_START)).toUTCString(), ...headers });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- the site: one frame, the house-game contract ---------------------------------
|
|
207
|
+
const SITE_PAGE = `<!doctype html><meta charset="utf-8"><style>html,body{margin:0;background:#041015}#c{height:720px;width:100%}iframe{border:0;width:100%;height:100%;display:block}</style>
|
|
208
|
+
<div id="c"><iframe id="g" allow="autoplay; fullscreen"></iframe></div><script>
|
|
209
|
+
window.__log = []; const f = document.getElementById('g');
|
|
210
|
+
addEventListener('message', (e) => { if (e.source !== f.contentWindow) return; const d = e.data || {};
|
|
211
|
+
window.__log.push({ at: Date.now(), d });
|
|
212
|
+
});
|
|
213
|
+
window.__launch = (u) => { f.src = u; };
|
|
214
|
+
if (location.hash.length > 1) __launch(decodeURIComponent(location.hash.slice(1)));
|
|
215
|
+
</script>`;
|
|
216
|
+
|
|
217
|
+
// ---- the shell origin: our shell + SDK, /housegames proxied to the RGS ------------------
|
|
218
|
+
// The long time-travel session forces each round's row (travel.mjs forcedRows)
|
|
219
|
+
// the way the studio's sandbox launcher does: the sandbox RGS takes the row for
|
|
220
|
+
// the session's next play. The shell alone holds the session, so the proxy,
|
|
221
|
+
// which sees its Authorization header on the play, asks for the row just
|
|
222
|
+
// before forwarding the play; the game knows nothing of it. The force's
|
|
223
|
+
// answer is checked, and the play's answer read for whether the round was
|
|
224
|
+
// forced, so a refused or ignored force shows in the report (forceLog).
|
|
225
|
+
let forceNext = null;
|
|
226
|
+
let forceLog = null; // { asked, applied, refused } while a long session runs
|
|
227
|
+
let lastPlayBody = null; // the newest play as the game sent it: its mode and bet
|
|
228
|
+
// Engine answers the game may read (through the shell) and that would betray
|
|
229
|
+
// the run: a round's `forced`, a forced draw's raw 0, the server's real time.
|
|
230
|
+
// travelAnswer rewrites them; the game's own state and telemetry are not touched.
|
|
231
|
+
const rewritten = (path) => path.startsWith('/engine/v1/') && !path.startsWith('/engine/v1/state') && !path.startsWith('/engine/v1/telemetry');
|
|
232
|
+
function proxy(req, res) {
|
|
233
|
+
const url = RGS + req.url.slice('/housegames'.length);
|
|
234
|
+
const path = req.url.slice('/housegames'.length).split('?')[0];
|
|
235
|
+
const chunks = [];
|
|
236
|
+
req.on('data', (c) => chunks.push(c));
|
|
237
|
+
req.on('end', async () => {
|
|
238
|
+
try {
|
|
239
|
+
const headers = {};
|
|
240
|
+
for (const k of ['content-type', 'authorization', 'x-token']) if (req.headers[k]) headers[k] = req.headers[k];
|
|
241
|
+
const isPlay = req.method === 'POST' && path === '/engine/v1/play';
|
|
242
|
+
if (isPlay && chunks.length) { try { lastPlayBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* not the game's play */ } }
|
|
243
|
+
if (forceNext !== null && isPlay && headers.authorization) {
|
|
244
|
+
const row = forceNext;
|
|
245
|
+
forceNext = null;
|
|
246
|
+
if (forceLog) forceLog.asked++;
|
|
247
|
+
const f = await fetch(RGS + '/engine/v1/sandbox/force', { method: 'POST', headers: { 'Content-Type': 'application/json', authorization: headers.authorization }, body: JSON.stringify({ indices: [row] }) }).catch(() => null);
|
|
248
|
+
if (forceLog && !(f && f.ok)) forceLog.refused++;
|
|
249
|
+
}
|
|
250
|
+
const r = await fetch(url, { method: req.method, headers, body: chunks.length ? Buffer.concat(chunks) : undefined });
|
|
251
|
+
const h = { 'Content-Type': r.headers.get('content-type') || 'application/json' };
|
|
252
|
+
if (r.headers.get('retry-after')) h['Retry-After'] = r.headers.get('retry-after');
|
|
253
|
+
let body = Buffer.from(await r.arrayBuffer());
|
|
254
|
+
if (rewritten(path) && /json/.test(h['Content-Type'])) {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(body.toString('utf8'));
|
|
257
|
+
if (isPlay && forceLog && r.ok && parsed?.round?.forced) forceLog.applied++;
|
|
258
|
+
body = Buffer.from(JSON.stringify(travelAnswer(parsed, clockOffsetMs)));
|
|
259
|
+
} catch { /* not JSON after all: passed as it came */ }
|
|
260
|
+
}
|
|
261
|
+
send(res, r.status, body, h);
|
|
262
|
+
} catch (e) { send(res, 502, JSON.stringify({ error: String(e) })); }
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// The two origins' policies, as production serves them (SPEC §4), from the one
|
|
266
|
+
// source (csp.json). Each origin takes its policy's violation reports as
|
|
267
|
+
// production does: answered 204 and dropped, since the run reads refusals from
|
|
268
|
+
// the console; a game's own request to the path is counted against it.
|
|
269
|
+
const HARDEN = { 'Referrer-Policy': 'no-referrer', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Resource-Policy': 'same-origin', 'X-Content-Type-Options': 'nosniff', 'X-DNS-Prefetch-Control': 'off', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), display-capture=()' };
|
|
270
|
+
const SHELL_REPORT = '/housegames/engine/play/csp-report', STUDIO_REPORT = '/csp-report';
|
|
271
|
+
const SHELL_HEADERS = () => ({ ...HARDEN, 'Content-Security-Policy': shellCSP({ frames: ORIGIN.studio, ancestors: ORIGIN.site, report: SHELL_REPORT }), 'Reporting-Endpoints': reportingEndpoints(SHELL_REPORT) });
|
|
272
|
+
const STUDIO_HEADERS = () => ({ ...HARDEN, 'Content-Security-Policy': bundleCSP({ origin: ORIGIN.studio, ancestors: `'self' ${ORIGIN.shell} ${ORIGIN.site}`, report: STUDIO_REPORT }), 'Reporting-Endpoints': reportingEndpoints(STUDIO_REPORT) });
|
|
273
|
+
|
|
274
|
+
const servers = [
|
|
275
|
+
http.createServer((req, res) => send(res, 200, SITE_PAGE, { 'Content-Type': 'text/html; charset=utf-8' })),
|
|
276
|
+
http.createServer((req, res) => {
|
|
277
|
+
const p = new URL(req.url, ORIGIN.shell).pathname;
|
|
278
|
+
const play = '/housegames/engine/play/';
|
|
279
|
+
if (p === SHELL_REPORT) return send(res, 204, '');
|
|
280
|
+
if (p.startsWith(play + 'core/')) return file(res, join(PKG, 'engine-shell'), p.slice(play.length), SHELL_HEADERS());
|
|
281
|
+
if (p.startsWith(play)) {
|
|
282
|
+
const rest = p.slice(play.length).split('/').slice(1).join('/') || 'index.html';
|
|
283
|
+
if (rest === 'descriptor.json') return send(res, 200, JSON.stringify({ catalogSlug: 'housebets-engine-' + SLUG, bundleUrl: ORIGIN.studio + '/index.html' }), { 'Content-Type': 'application/json' });
|
|
284
|
+
return file(res, join(PKG, 'engine-shell/host'), rest, SHELL_HEADERS());
|
|
285
|
+
}
|
|
286
|
+
if (p.startsWith('/housegames/engine/engine-sdk/')) return file(res, join(PKG, 'engine-sdk'), p.slice('/housegames/engine/engine-sdk/'.length));
|
|
287
|
+
if (p.startsWith('/housegames/')) return proxy(req, res);
|
|
288
|
+
send(res, 404, 'not found');
|
|
289
|
+
}),
|
|
290
|
+
http.createServer((req, res) => {
|
|
291
|
+
let p = decodeURIComponent(new URL(req.url, ORIGIN.studio).pathname).replace(/^\/+/, '') || 'index.html';
|
|
292
|
+
if ('/' + p === STUDIO_REPORT) return send(res, 204, '');
|
|
293
|
+
const headers = STUDIO_HEADERS();
|
|
294
|
+
// A script the browser fetches as a worker's own (Sec-Fetch-Dest, which
|
|
295
|
+
// no page can forge) is served with the worker prelude first (travel.mjs
|
|
296
|
+
// workerShim): its clocks at the run's instant, the trap for its own
|
|
297
|
+
// workers; its location and its relative imports stay its own. The same
|
|
298
|
+
// file fetched by a page, a <script> or importScripts comes as it is.
|
|
299
|
+
const dest = req.headers['sec-fetch-dest'];
|
|
300
|
+
const prelude = dest === 'worker' || dest === 'sharedworker' ? workerShim(Date.now() + clockOffsetMs, RUN_TOKEN, travelling ? CLOCK : null) : '';
|
|
301
|
+
if (p.startsWith('engine-sdk/')) return file(res, join(PKG, 'engine-sdk'), p.slice('engine-sdk/'.length), headers, prelude);
|
|
302
|
+
file(res, BUNDLE, p, headers, prelude);
|
|
303
|
+
}),
|
|
304
|
+
];
|
|
305
|
+
[ORIGIN.site, ORIGIN.shell, ORIGIN.studio] = await Promise.all([listen(servers[0], 'localhost'), listen(servers[1], '127.0.0.1'), listen(servers[2], '127.0.0.1')]);
|
|
306
|
+
|
|
307
|
+
// ---- launch: engine-dev's play-money player ----------------------------------------------
|
|
308
|
+
async function launch(display, lang = '') {
|
|
309
|
+
const dev = await fetch(RGS + '/dev/launch').then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
310
|
+
if (!dev) throw new Error(`${RGS} is not an engine-dev sandbox (no /dev/launch): the run plays against engine-dev only`);
|
|
311
|
+
// The player's language goes on the launch as the site puts it (the shell reads `lang`).
|
|
312
|
+
return { url: `${ORIGIN.shell}/housegames/engine/play/${SLUG}/?otp=${dev.otp}¤cy=1¤cyPayload=${encodeURIComponent(JSON.stringify({ code: 1, ...display }))}${lang ? '&lang=' + encodeURIComponent(lang) : ''}` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- the run ------------------------------------------------------------------------------
|
|
316
|
+
if (args.includes('--dev')) {
|
|
317
|
+
const { url } = await launch({ symbol: 'USDT', name: 'USDT', isFiat: false, rate: 1, precision: 8 });
|
|
318
|
+
console.log(`playing ${SLUG} from ${BUNDLE}\nopen ${ORIGIN.site}/#${encodeURIComponent(url)}\n(edit the bundle and reload; Ctrl-C to stop)`);
|
|
319
|
+
await new Promise(() => {});
|
|
320
|
+
}
|
|
321
|
+
const hbtest = existsSync(join(BUNDLE, 'hbtest.json')) ? JSON.parse(readFileSync(join(BUNDLE, 'hbtest.json'), 'utf8')) : null;
|
|
322
|
+
const results = [];
|
|
323
|
+
const check = (size, rule, ok, detail, status) => results.push({ size, rule, ok, detail, ...(status ? { status } : {}) });
|
|
324
|
+
const { chromium } = await import('playwright-core');
|
|
325
|
+
// --disable-dev-shm-usage: in a container /dev/shm is small, and a big canvas (BAD CAT at
|
|
326
|
+
// 1920×1080) hung Chrome there until the run's time ran out; it passes in 72 s on a Mac.
|
|
327
|
+
// Chrome's own sandbox stays on (Playwright turns it off unless told): studio
|
|
328
|
+
// code runs in a renderer the kernel confines. A machine that cannot give one
|
|
329
|
+
// says so with ENGINE_RUN_UNSANDBOXED=1, and the verdict carries the fact.
|
|
330
|
+
// Without --enable-automation (Playwright's default) and with the blink
|
|
331
|
+
// AutomationControlled feature off, navigator.webdriver is false: a bundle
|
|
332
|
+
// that behaves only for a browser nobody drives sees a player's.
|
|
333
|
+
const launchEnv = { PATH: process.env.PATH, HOME: process.env.HOME || '/tmp', LANG: process.env.LANG || 'C.UTF-8', TMPDIR: process.env.TMPDIR || '/tmp' };
|
|
334
|
+
// A variant's time zone is set two ways: on the context (timezoneId: what
|
|
335
|
+
// Date and Intl say) and on the Chrome process itself (TZ), because Blink
|
|
336
|
+
// formats some dates in the process's own zone (document.lastModified) and a
|
|
337
|
+
// game comparing the two would see the emulation. So Chrome is launched once
|
|
338
|
+
// per zone the variants name; the frame sizes run in the machine's own.
|
|
339
|
+
// WebGL on the CPU: the run has no GPU, and since Chrome 137 WebGL no longer falls back to
|
|
340
|
+
// SwiftShader on its own, so a WebGL game (Pixi, three.js) drew a black frame here. SwiftShader
|
|
341
|
+
// is more code within reach of the bundle's WebGL calls, so it is on only with Chrome's own
|
|
342
|
+
// sandbox (the job: one upload, no role, no egress); a run without the sandbox (a stand on a
|
|
343
|
+
// machine that cannot give one) keeps WebGL off.
|
|
344
|
+
const SOFTWARE_WEBGL = UNSANDBOXED ? [] : ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'];
|
|
345
|
+
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', ...SOFTWARE_WEBGL] });
|
|
346
|
+
const browsers = new Map();
|
|
347
|
+
const browserFor = async (tz) => { if (!browsers.has(tz)) browsers.set(tz, await launchChrome(tz)); return browsers.get(tz); };
|
|
348
|
+
let browser;
|
|
349
|
+
try {
|
|
350
|
+
browser = await launchChrome('');
|
|
351
|
+
browsers.set('', browser);
|
|
352
|
+
} catch (e) {
|
|
353
|
+
console.error(`Chrome did not start${UNSANDBOXED ? '' : ' with its sandbox on (ENGINE_RUN_UNSANDBOXED=1 turns it off, knowingly)'}: ${e.message.split('\n')[0]}`);
|
|
354
|
+
process.exit(3);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The currency matrix: each size plays under another of the player's displays,
|
|
358
|
+
// a fiat at a rate and the bare coin. A game must show that currency and send
|
|
359
|
+
// the stake the Originals would (engine-sdk money.js on the shell's wallet).
|
|
360
|
+
const DISPLAYS = {
|
|
361
|
+
desktop: { symbol: 'EUR', name: 'USDT', isFiat: true, currentFiat: 'EUR', rate: 0.92, precision: 8, shows: '€' },
|
|
362
|
+
phone: { symbol: 'USDT', name: 'USDT', isFiat: false, rate: 1, precision: 8, shows: 'USDT' },
|
|
363
|
+
};
|
|
364
|
+
const FULLSCREEN = { desktop: { width: 1920, height: 1080 }, phone: { width: 390, height: 844 } };
|
|
365
|
+
const SESSION_CURRENCY = { code: 1, symbol: 'USDT', decimals: 8 };
|
|
366
|
+
|
|
367
|
+
const network = (size, outside, refused, rtc = 0, workers = 0, reports = 0) => check(size, 'network', !outside.length && !refused.length && !rtc && !workers && !reports,
|
|
368
|
+
outside.length ? 'left the bundle: ' + [...new Set(outside)].slice(0, 3).join(', ')
|
|
369
|
+
: refused.length ? 'the policy refused: ' + [...new Set(refused)].slice(0, 2).join(' | ')
|
|
370
|
+
: rtc ? `opened ${rtc} peer connection(s) (WebRTC): the game has no network of its own`
|
|
371
|
+
: workers ? `registered ${workers} service worker(s)`
|
|
372
|
+
: reports ? `posted ${reports} request(s) to the policy's report path: the policy refused something, or the game posts there itself`
|
|
373
|
+
: 'only its own origin, nothing refused');
|
|
374
|
+
|
|
375
|
+
// What every frame learns before its own code runs, from one init script
|
|
376
|
+
// (initScript), in this order:
|
|
377
|
+
// natives the frame's own clock functions and descriptors, before anything
|
|
378
|
+
// replaces them (travel.mjs clockShim with `capture`)
|
|
379
|
+
// clock under time travel, the fake clock (clock.mjs: Playwright's own
|
|
380
|
+
// implementation, installed with no global and driven through the
|
|
381
|
+
// run's channel), set to the variant's instant
|
|
382
|
+
// shim every clock API behind a native-shaped Proxy, the same in every
|
|
383
|
+
// run (clockShim): what the game sees of the clocks has one shape
|
|
384
|
+
// on the frame sizes, in the matrix and in the canary
|
|
385
|
+
// page the instrumentation below (INSTRUMENT_PAGE), with nothing added
|
|
386
|
+
// to window and every replaced function behind a Proxy of the
|
|
387
|
+
// native one (so Function.prototype.toString says native code):
|
|
388
|
+
// what it records goes out through console.debug, bound before the
|
|
389
|
+
// game can replace it, under a token the page never sees in the DOM
|
|
390
|
+
// dialogs alert / confirm / prompt / print / window.open attempts: the
|
|
391
|
+
// frame has no allow-modals, so a dialog never shows and Chrome
|
|
392
|
+
// never says it was asked for; the page counts the attempts itself
|
|
393
|
+
// rtc a peer connection is a network of the game's own that no CSP
|
|
394
|
+
// directive but `webrtc` governs: the attempts are counted and refused
|
|
395
|
+
// text every string drawn on a 2D canvas (a Pixi text is one): a prompt
|
|
396
|
+
// drawn, not typed, is read like the DOM's words
|
|
397
|
+
// trap the worker trap and the clock channel (travel.mjs clockTrap):
|
|
398
|
+
// every Worker and SharedWorker started is reported; a blob script
|
|
399
|
+
// is started from a wrapper carrying the worker prelude, a
|
|
400
|
+
// same-origin script gets it from the run's server; a jump of the
|
|
401
|
+
// clock reaches the frame here and goes on to its workers
|
|
402
|
+
// The script is built as text from the functions travel.mjs and clock.mjs
|
|
403
|
+
// export and test, not from copies of them.
|
|
404
|
+
const INSTRUMENT_PAGE = (scope, token) => {
|
|
405
|
+
const say = scope.console.debug.bind(scope.console);
|
|
406
|
+
const fix = (obj, name, value) => { try { Object.defineProperty(obj, name, { value, configurable: false, writable: false }); } catch { /* already fixed */ } };
|
|
407
|
+
const answer = { alert: undefined, confirm: false, prompt: null, print: undefined, open: null };
|
|
408
|
+
for (const name of Object.keys(answer)) {
|
|
409
|
+
const orig = scope[name];
|
|
410
|
+
if (typeof orig !== 'function') continue;
|
|
411
|
+
fix(scope, name, new Proxy(orig, { apply() { say(token + ':dialog:' + name); return answer[name]; } }));
|
|
412
|
+
}
|
|
413
|
+
for (const name of ['RTCPeerConnection', 'webkitRTCPeerConnection', 'mozRTCPeerConnection']) {
|
|
414
|
+
const orig = scope[name];
|
|
415
|
+
if (typeof orig !== 'function') continue;
|
|
416
|
+
const deny = () => { say(token + ':rtc:' + name); throw new DOMException('WebRTC is not available to a studio game', 'NotSupportedError'); };
|
|
417
|
+
fix(scope, name, new Proxy(orig, { apply: deny, construct: deny }));
|
|
418
|
+
}
|
|
419
|
+
const drawn = new Set();
|
|
420
|
+
for (const C of [scope.CanvasRenderingContext2D, scope.OffscreenCanvasRenderingContext2D]) {
|
|
421
|
+
for (const m of ['fillText', 'strokeText']) {
|
|
422
|
+
const orig = C && C.prototype && C.prototype[m];
|
|
423
|
+
if (typeof orig !== 'function') continue;
|
|
424
|
+
C.prototype[m] = new Proxy(orig, { apply(t, self, a) { const s = String(a[0] ?? '').trim(); if (s && drawn.size < 2000 && !drawn.has(s)) { drawn.add(s); say(token + ':text:' + s.slice(0, 200)); } return Reflect.apply(t, self, a); } });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
// Playwright's clock source, read once; only a travelling run needs it, and
|
|
429
|
+
// refuses to travel without it (clock.mjs). With startClock as text, it is
|
|
430
|
+
// what a travelling page's workers are started with too (trapOptions).
|
|
431
|
+
const CLOCK_SOURCE = TRAVEL ? clockSource() : null;
|
|
432
|
+
const CLOCK = CLOCK_SOURCE ? { source: CLOCK_SOURCE, start: String(startClock) } : null;
|
|
433
|
+
// The init script of every frame: the natives, the fake clock when the
|
|
434
|
+
// variant travels (`at`: its instant), the shim, the instrumentation, the trap
|
|
435
|
+
// (given the fake clock's text when the variant travels, for the workers).
|
|
436
|
+
const initScript = ({ travel, at }) => `(() => {
|
|
437
|
+
const scope = globalThis;
|
|
438
|
+
const natives = (${String(clockShim)})(scope, { capture: true });
|
|
439
|
+
let fake = null, jump = null;
|
|
440
|
+
${travel ? `{
|
|
441
|
+
const module = { exports: {} };
|
|
442
|
+
${CLOCK_SOURCE}
|
|
443
|
+
const started = (${String(startClock)})(scope, ${JSON.stringify({ at: new Date(at).getTime(), wall: Date.now() })}, module.exports, natives);
|
|
444
|
+
fake = started.fake; jump = started.jump;
|
|
445
|
+
}` : ''}
|
|
446
|
+
const setAt = (${String(clockShim)})(scope, { natives, fake });
|
|
447
|
+
(${String(INSTRUMENT_PAGE)})(scope, ${JSON.stringify(RUN_TOKEN)});
|
|
448
|
+
(${String(clockTrap)})(scope, ${JSON.stringify(trapOptions(RUN_TOKEN, travel ? CLOCK : null))}, { setAt, jump, realNow: natives.Date.now });
|
|
449
|
+
})();\n`;
|
|
450
|
+
// A frame of the game's: the bundle's own pages, and every frame they make,
|
|
451
|
+
// whatever its URL (srcdoc, about:blank, a data URL): same-origin to the game,
|
|
452
|
+
// under its sandbox, and able to show anything the game can.
|
|
453
|
+
const isGameFrame = (f) => { for (let x = f; x; x = x.parentFrame()) if (x.url().startsWith(ORIGIN.studio)) return true; return false; };
|
|
454
|
+
const studioFrames = (page) => page.frames().filter(isGameFrame);
|
|
455
|
+
const sumIn = async (page, expr) => (await Promise.all(studioFrames(page).map((f) => f.evaluate(expr).catch(() => 0)))).reduce((a, b) => a + b, 0);
|
|
456
|
+
const listIn = async (page, expr) => (await Promise.all(studioFrames(page).map((f) => f.evaluate(expr).catch(() => [])))).flat();
|
|
457
|
+
|
|
458
|
+
// What a page records while it plays: uncaught errors, requests that left
|
|
459
|
+
// the bundle, the policy's refusals, the plays that reached the engine, posts
|
|
460
|
+
// to the policy's report path, and what the pages' instrumentation says
|
|
461
|
+
// through the console (dialogs, peer connections, workers, canvas text). The
|
|
462
|
+
// same for every run.
|
|
463
|
+
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.push(e.message.slice(0, 160)));
|
|
467
|
+
page.on('console', (m) => {
|
|
468
|
+
const text = m.text();
|
|
469
|
+
if (text.startsWith(RUN_TOKEN + ':')) {
|
|
470
|
+
const rest = text.slice(RUN_TOKEN.length + 1);
|
|
471
|
+
const i = rest.indexOf(':');
|
|
472
|
+
const kind = rest.slice(0, i), payload = rest.slice(i + 1);
|
|
473
|
+
if (kind === 'dialog') dialogs.push(payload);
|
|
474
|
+
else if (kind === 'rtc') rtc++;
|
|
475
|
+
else if (kind === 'worker') workers.push(payload);
|
|
476
|
+
else if (kind === 'unshimmed') unshimmed++;
|
|
477
|
+
else if (kind === 'text') canvasText.push(payload);
|
|
478
|
+
else if (kind === 'uncaught') errors.push(payload.slice(0, 160)); // a timer's exception under the fake clock: uncaught in production
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (m.type() !== 'error') return;
|
|
482
|
+
if (text.includes('Content Security Policy')) refused.push(text.slice(0, 120));
|
|
483
|
+
else consoleErrors.push(text.slice(0, 160));
|
|
484
|
+
});
|
|
485
|
+
page.on('request', (r) => {
|
|
486
|
+
const u = r.url();
|
|
487
|
+
// A request no page of the game made (a worker's) names no frame.
|
|
488
|
+
let f = null;
|
|
489
|
+
try { f = r.frame(); } catch { f = null; }
|
|
490
|
+
if (f && isGameFrame(f)) {
|
|
491
|
+
// The report path is the browser's to post to, on a refusal; a game
|
|
492
|
+
// posting there itself is a game spending its player's report allowance.
|
|
493
|
+
if (u.startsWith(ORIGIN.studio) && new URL(u).pathname === STUDIO_REPORT) reports++;
|
|
494
|
+
else if (!u.startsWith(ORIGIN.studio) && !u.startsWith('data:') && !u.startsWith('blob:')) outside.push(u.slice(0, 120));
|
|
495
|
+
}
|
|
496
|
+
if (u.includes('/engine/v1/play')) plays.push(r.postData() || '');
|
|
497
|
+
});
|
|
498
|
+
return { errors, outside, refused, plays, consoleErrors, workers, canvasText, reports: () => reports, rtcCount: () => rtc, dialogs: () => [...dialogs], unshimmed: () => unshimmed };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// The site's log of the shell's messages, and the hbtest steps on the game's own pages.
|
|
502
|
+
function helpers(page) {
|
|
503
|
+
const log = () => page.evaluate(() => window.__log);
|
|
504
|
+
const waitFor = async (pred, ms) => { for (let t = 0; t < ms; t += 250) { if (pred(await log())) return true; await page.waitForTimeout(250); } return false; };
|
|
505
|
+
// A control below the window is scrolled to first: Playwright's own scroll
|
|
506
|
+
// through the site's and the shell's frames (two sites) can miss it.
|
|
507
|
+
// 'nearest' moves nothing for a control already in view.
|
|
508
|
+
const press = async (control) => {
|
|
509
|
+
await control.evaluate((e) => e.scrollIntoView({ block: 'nearest' }));
|
|
510
|
+
await control.click({ timeout: 5000 });
|
|
511
|
+
};
|
|
512
|
+
// A step names one of the game's own pages, never the shell's or the site's.
|
|
513
|
+
const gameFrame = (name) => page.frames().find((x) => x.url().startsWith(ORIGIN.studio) && x.url().includes(name));
|
|
514
|
+
const act = async (step) => {
|
|
515
|
+
const f = gameFrame(step.page);
|
|
516
|
+
if (!f) throw new Error('no page ' + step.page);
|
|
517
|
+
// Selectors reach into shadow roots (the engine's panel is one); an
|
|
518
|
+
// open dialog of the game's is closed first so it cannot cover the click.
|
|
519
|
+
await f.evaluate(() => document.querySelectorAll('dialog[open]').forEach((d) => d.close()));
|
|
520
|
+
if (step.value !== undefined) await f.locator(step.selector).first().fill(String(step.value));
|
|
521
|
+
else await press(f.locator(step.selector).first());
|
|
522
|
+
};
|
|
523
|
+
// A `then` step (Next, Cash out) is pressed only when its control is there to press.
|
|
524
|
+
const thenSteps = async (steps) => {
|
|
525
|
+
for (const s of steps || []) {
|
|
526
|
+
const f = gameFrame(s.page);
|
|
527
|
+
const control = f && f.locator(s.selector).first();
|
|
528
|
+
if (control && await control.isVisible().catch(() => false) && await control.isEnabled().catch(() => false)) await press(control);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const roundEnds = (l) => l.filter((m) => m.d.type === 'housebets-game-round-end').length;
|
|
532
|
+
// A player's hand: the pointer wanders over the game and the wheel turns a
|
|
533
|
+
// little, so a path armed by pointer activity (a game that waits for a
|
|
534
|
+
// human's moves) is armed here too, and hover states are the same in every
|
|
535
|
+
// screenshot. The frame is 1194×720 at the top left of the page.
|
|
536
|
+
const wander = async () => {
|
|
537
|
+
let x = 300 + Math.random() * 500, y = 150 + Math.random() * 350;
|
|
538
|
+
for (let i = 0; i < 60; i++) {
|
|
539
|
+
x = Math.max(20, Math.min(1170, x + (Math.random() - 0.5) * 140));
|
|
540
|
+
y = Math.max(20, Math.min(700, y + (Math.random() - 0.5) * 100));
|
|
541
|
+
await page.mouse.move(x, y, { steps: 2 }).catch(() => {});
|
|
542
|
+
}
|
|
543
|
+
await page.mouse.wheel(0, 40).catch(() => {});
|
|
544
|
+
await page.mouse.wheel(0, -40).catch(() => {});
|
|
545
|
+
};
|
|
546
|
+
return { log, waitFor, press, gameFrame, act, thenSteps, roundEnds, wander };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---- the frame sizes: desktop and phone -------------------------------------------------
|
|
550
|
+
// The console errors of each size, for the JSON (`console`): a test bundle's
|
|
551
|
+
// own report of what it saw (travel.browser.test.mjs) is read from here.
|
|
552
|
+
const frameConsole = {};
|
|
553
|
+
for (const [size, width] of TRAVEL ? [] : [['desktop', 1194], ['phone', 364]]) {
|
|
554
|
+
const { shows, ...display } = DISPLAYS[size];
|
|
555
|
+
const money = moneyFor(walletFrom(JSON.stringify(display), SESSION_CURRENCY));
|
|
556
|
+
const context = await browser.newContext({ viewport: { width, height: 900 } });
|
|
557
|
+
const page = await context.newPage();
|
|
558
|
+
await page.addInitScript(initScript({ travel: false }));
|
|
559
|
+
const { errors, outside, refused, plays, reports, rtcCount, consoleErrors } = instrument(page);
|
|
560
|
+
// Kept for the JSON once the size is done: the first distinct lines, bounded
|
|
561
|
+
// (a test bundle's report of what it saw runs to some sixty lines).
|
|
562
|
+
const keepConsole = () => { frameConsole[size] = [...new Set(consoleErrors)].slice(0, 200); };
|
|
563
|
+
const { log, waitFor, act, press, gameFrame, roundEnds } = helpers(page);
|
|
564
|
+
await page.goto(ORIGIN.site + '/');
|
|
565
|
+
const { url } = await launch(display);
|
|
566
|
+
await page.evaluate((u) => window.__launch(u), url);
|
|
567
|
+
const ready = await waitFor((l) => l.some((m) => m.d.message === 'gameLoaded'), 25000);
|
|
568
|
+
check(size, 'boot', ready, ready ? 'hello answered, ready reported' : 'no ready within 25 s (the game never connected, or never called engine.ready())');
|
|
569
|
+
if (!ready) {
|
|
570
|
+
// 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');
|
|
572
|
+
check(size, 'errors', errors.length === 0, errors.length ? [...new Set(errors)].slice(0, 4).join(' | ') : 'none');
|
|
573
|
+
network(size, outside, refused, rtcCount(), context.serviceWorkers().length, reports());
|
|
574
|
+
keepConsole();
|
|
575
|
+
await page.close();
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
// Nobody touches the game for a while: it must not bet on its own. A
|
|
579
|
+
// bet is the player's act; a bundle that plays unprompted is refused.
|
|
580
|
+
const idleFrom = plays.length;
|
|
581
|
+
await page.waitForTimeout(Number(opt('idle-ms', '15000')));
|
|
582
|
+
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`);
|
|
583
|
+
|
|
584
|
+
// fit: every page of the game at its frame size, now and after each page
|
|
585
|
+
// the test opens
|
|
586
|
+
// A control outside the frame cannot be pressed (an overflow the layout
|
|
587
|
+
// clips scrolls nowhere), unless a scroller holds it; what is not shown
|
|
588
|
+
// (hidden, a closed <details>, aria-hidden, inert) is not there to press.
|
|
589
|
+
const scrolling = new Set();
|
|
590
|
+
const measure = async (at = '') => {
|
|
591
|
+
for (const f of studioFrames(page)) {
|
|
592
|
+
const d = await f.evaluate(() => {
|
|
593
|
+
const cut = [];
|
|
594
|
+
const scroller = (el) => { for (let e = el.parentElement; e && e !== document.documentElement && e !== document.body; e = e.parentElement) { const o = getComputedStyle(e); if (/auto|scroll/.test(o.overflowX + o.overflowY)) return true; } return false; };
|
|
595
|
+
const walk = (root) => {
|
|
596
|
+
for (const el of root.querySelectorAll('button, input, select, a[href]')) {
|
|
597
|
+
const r = el.getBoundingClientRect();
|
|
598
|
+
if (r.width && r.height && (r.right > innerWidth + 1 || r.left < -1 || r.bottom > innerHeight + 1 || r.top < -1) && !scroller(el) && el.checkVisibility({ visibilityProperty: true }) && !el.closest('[aria-hidden="true"], [inert]')) cut.push((el.id ? '#' + el.id : el.tagName.toLowerCase()) + ' ' + (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 16) + ` at ${Math.round(r.left)},${Math.round(r.top)}`);
|
|
599
|
+
}
|
|
600
|
+
for (const host of root.querySelectorAll('*')) if (host.shadowRoot) walk(host.shadowRoot);
|
|
601
|
+
};
|
|
602
|
+
walk(document);
|
|
603
|
+
// Content (text, canvas, images) the frame's edge cuts through. A clip
|
|
604
|
+
// inside the frame (a reel window, a ticker) is the game's own; a clip
|
|
605
|
+
// that reaches the frame's edge is the frame's, so it does not count.
|
|
606
|
+
const art = [];
|
|
607
|
+
const up = (e) => e.assignedSlot || e.parentElement || e.getRootNode().host;
|
|
608
|
+
const shown = (el) => {
|
|
609
|
+
const r = el.getBoundingClientRect();
|
|
610
|
+
let [L, T, R, B] = [r.left, r.top, r.right, r.bottom];
|
|
611
|
+
for (let e = up(el); e && e !== document.body && e !== document.documentElement; e = up(e)) {
|
|
612
|
+
const o = getComputedStyle(e);
|
|
613
|
+
if (o.overflowX === 'visible' && o.overflowY === 'visible') continue;
|
|
614
|
+
const q = e.getBoundingClientRect();
|
|
615
|
+
if (q.left <= 1 || q.top <= 1 || q.right >= innerWidth - 1 || q.bottom >= innerHeight - 1) continue;
|
|
616
|
+
if (o.overflowX !== 'visible') { L = Math.max(L, q.left); R = Math.min(R, q.right); }
|
|
617
|
+
if (o.overflowY !== 'visible') { T = Math.max(T, q.top); B = Math.min(B, q.bottom); }
|
|
618
|
+
}
|
|
619
|
+
return { L, T, R, B };
|
|
620
|
+
};
|
|
621
|
+
const content = (root) => {
|
|
622
|
+
for (const el of root.querySelectorAll('*')) {
|
|
623
|
+
if (el.shadowRoot) content(el.shadowRoot);
|
|
624
|
+
const media = /^(CANVAS|IMG|VIDEO)$/.test(el.tagName) || (el.tagName === 'svg' && !el.parentElement?.closest('svg'));
|
|
625
|
+
if (!media && ![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue;
|
|
626
|
+
const { L, T, R, B } = shown(el);
|
|
627
|
+
if (R - L < 2 || B - T < 2 || R <= 0 || B <= 0 || L >= innerWidth || T >= innerHeight) continue; // nothing of it on screen
|
|
628
|
+
// Text may not be cut at all; a canvas or picture may lose a margin (a tenth of it).
|
|
629
|
+
const sx = media ? (R - L) / 10 : 1, sy = media ? (B - T) / 10 : 1;
|
|
630
|
+
if ((R > innerWidth + sx || L < -sx || B > innerHeight + sy || T < -sy) && !scroller(el) && el.checkVisibility({ visibilityProperty: true, opacityProperty: true }) && !el.closest('[aria-hidden="true"], [inert]'))
|
|
631
|
+
art.push(el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (media ? '' : ' "' + el.textContent.trim().slice(0, 12) + '"') + ` at ${Math.round(L)},${Math.round(T)}`);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
content(document);
|
|
635
|
+
return { h: innerHeight, w: innerWidth, cut, art, content: document.documentElement.scrollHeight, path: location.pathname, clip: getComputedStyle(document.documentElement).overflowY === 'hidden' && getComputedStyle(document.body).overflowY === 'hidden' };
|
|
636
|
+
}).catch(() => null);
|
|
637
|
+
if (d && !d.clip && d.content > d.h + 1) scrolling.add(`${at}${d.path} is ${d.content}px in a ${d.h}px frame`);
|
|
638
|
+
if (d && d.cut.length) scrolling.add(`${at}${d.path} controls outside the ${d.w}×${d.h} frame: ${d.cut.slice(0, 4).join(', ')}`);
|
|
639
|
+
if (d && d.art.length) scrolling.add(`${at}${d.path} content cut by the ${d.w}×${d.h} frame's edge (mark decoration aria-hidden): ${d.art.slice(0, 4).join(', ')}`);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
await measure();
|
|
643
|
+
|
|
644
|
+
// bet + outage through the studio's own UI
|
|
645
|
+
if (!hbtest) check(size, 'bet', false, 'no hbtest.json: the run cannot place a bet through the game');
|
|
646
|
+
else {
|
|
647
|
+
try {
|
|
648
|
+
for (const s of [...(hbtest.visit || []), ...(hbtest.open || [])]) { await act(s); await page.waitForTimeout(2500); await measure(); }
|
|
649
|
+
if (hbtest.stake) await act(hbtest.stake);
|
|
650
|
+
const before = plays.length, ends = roundEnds(await log());
|
|
651
|
+
await act(hbtest.bet);
|
|
652
|
+
// A ladder's bet opens a round: the test's `then` steps close it.
|
|
653
|
+
// A ladder is random — a bust ends it before Cash out exists — so a
|
|
654
|
+
// `then` step is played only when its control is there to press.
|
|
655
|
+
for (const s of hbtest.then || []) {
|
|
656
|
+
await page.waitForTimeout(1500);
|
|
657
|
+
const f = gameFrame(s.page);
|
|
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);
|
|
660
|
+
}
|
|
661
|
+
const revealed = await waitFor((l) => roundEnds(l) > ends, 10000);
|
|
662
|
+
// One press, one bet: a game that sends two plays per press charges the player twice.
|
|
663
|
+
const placed = plays.length - before;
|
|
664
|
+
check(size, 'bet', placed === 1 && revealed, placed === 0 ? 'the bet never reached engine.play' : placed > 1 ? `${placed} bets reached engine.play for one press` : revealed ? 'engine.play, then revealed → site round-end' : 'engine.play, but no revealed within 10 s');
|
|
665
|
+
// And nothing after it on its own: the next 10 s untouched bring no bet.
|
|
666
|
+
if (revealed) {
|
|
667
|
+
const settled = plays.length;
|
|
668
|
+
await page.waitForTimeout(10000);
|
|
669
|
+
if (plays.length !== settled) check(size, 'idle', false, `${plays.length - settled} bet(s) placed on their own after the round`);
|
|
670
|
+
}
|
|
671
|
+
// What the game shows is the row the server drew: `shows` names an
|
|
672
|
+
// element and a path into the round (data.0.side); the text must carry it.
|
|
673
|
+
if (hbtest.shows && revealed) {
|
|
674
|
+
const round = await page.frames().find((x) => x.url().includes('/housegames/engine/play/')).evaluate(() => (window.__rounds || []).at(-1)?.round);
|
|
675
|
+
const value = hbtest.shows.path.split('.').reduce((v, k) => (v == null ? v : v[k]), round);
|
|
676
|
+
const f = gameFrame(hbtest.shows.page);
|
|
677
|
+
const text = f ? await f.locator(hbtest.shows.selector).first().innerText().catch(() => '') : '';
|
|
678
|
+
const ok = value != null && text.toLowerCase().includes(String(value).toLowerCase());
|
|
679
|
+
check(size, 'shows', ok, ok ? `${hbtest.shows.selector} shows ${hbtest.shows.path} = ${value}` : `${hbtest.shows.selector} shows "${text.slice(0, 40)}", the round's ${hbtest.shows.path} is ${value}`);
|
|
680
|
+
}
|
|
681
|
+
// draws: after the round the game's play area shows something — its
|
|
682
|
+
// visible leaves and painted boxes (canvases, pictures, anything with a
|
|
683
|
+
// background), backdrops the size of the frame aside, cover at least
|
|
684
|
+
// 2% of the frame. A board with no size fails.
|
|
685
|
+
if (revealed) {
|
|
686
|
+
let area = 0, frameArea = 1;
|
|
687
|
+
for (const f of studioFrames(page)) {
|
|
688
|
+
const d = await f.evaluate(() => {
|
|
689
|
+
const root = document.querySelector('hb-game [slot="zone"]') || document.body;
|
|
690
|
+
let a = 0;
|
|
691
|
+
for (const el of [root, ...root.querySelectorAll('*')]) { // the zone itself may be the board (a coin)
|
|
692
|
+
if (el.closest('svg') && el.tagName !== 'svg') continue;
|
|
693
|
+
const st = getComputedStyle(el);
|
|
694
|
+
const painted = st.backgroundImage !== 'none' || !/rgba\(.*, 0\)|transparent/.test(st.backgroundColor);
|
|
695
|
+
if (el.children.length && !painted && !/^(CANVAS|IMG|VIDEO|svg)$/.test(el.tagName)) continue;
|
|
696
|
+
if (!el.checkVisibility({ visibilityProperty: true, opacityProperty: true })) continue;
|
|
697
|
+
const r = el.getBoundingClientRect();
|
|
698
|
+
const s = Math.max(0, Math.min(r.right, innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
|
|
699
|
+
if (s < innerWidth * innerHeight * 0.9) a += s; // a backdrop the size of the frame is not the game
|
|
700
|
+
}
|
|
701
|
+
return { a, f: innerWidth * innerHeight };
|
|
702
|
+
}).catch(() => ({ a: 0, f: 1 }));
|
|
703
|
+
area += d.a; frameArea = Math.max(frameArea, d.f);
|
|
704
|
+
}
|
|
705
|
+
const share = area / frameArea;
|
|
706
|
+
check(size, 'draws', share >= 0.02, share >= 0.02 ? `the play area shows content (${Math.round(share * 100)}% of the frame)` : `the play area shows nothing (${(share * 100).toFixed(1)}% of the frame): check the board has a size`);
|
|
707
|
+
}
|
|
708
|
+
if (plays.length > before && hbtest.stake) {
|
|
709
|
+
let sent = null;
|
|
710
|
+
try { sent = JSON.parse(plays[before]).amount; } catch { sent = null; }
|
|
711
|
+
const want = money.parse(String(hbtest.stake.value));
|
|
712
|
+
// The currency on screen: in the page's text, or in the engine
|
|
713
|
+
// field (its sign, or the unit its input is labelled with).
|
|
714
|
+
const seen = await Promise.all(studioFrames(page).map((x) => x.evaluate((unit) => {
|
|
715
|
+
const f = document.querySelector('hb-bet-amount') || [...document.querySelectorAll('*')].map((e) => e.shadowRoot && e.shadowRoot.querySelector('hb-bet-amount')).find(Boolean);
|
|
716
|
+
const inField = f && f.shadowRoot && (f.shadowRoot.querySelector('.sign').textContent + ' ' + f.shadowRoot.querySelector('input').getAttribute('aria-label'));
|
|
717
|
+
return document.body.innerText + ' ' + (inField || '');
|
|
718
|
+
}, shows).catch(() => '')));
|
|
719
|
+
const visible = seen.some((t) => t.includes(shows));
|
|
720
|
+
check(size, 'currency', sent === want && visible, `${display.isFiat ? display.currentFiat + ' at ' + display.rate : display.symbol}: typed ${hbtest.stake.value}, sent ${sent}, the Originals send ${want}` + (visible ? '' : `; nothing on screen says ${shows}`));
|
|
721
|
+
}
|
|
722
|
+
// The next bet meets an ambiguous wallet answer (engine-dev debits
|
|
723
|
+
// and answers "pending"): the game must not break, and the site
|
|
724
|
+
// must be asked to re-read the wallet.
|
|
725
|
+
if (SKIP_OUTAGE) {
|
|
726
|
+
check(size, 'outage', true, 'skipped: this job has no wallet fault stub; the real round checks completed', 'skipped');
|
|
727
|
+
} else {
|
|
728
|
+
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
|
+
if (!armed) check(size, 'outage', false, 'engine-dev has no /dev/fault: update the sandbox (hbengine) to run the outage check');
|
|
730
|
+
else {
|
|
731
|
+
const errs = errors.length;
|
|
732
|
+
await page.waitForTimeout(1200);
|
|
733
|
+
await act(hbtest.bet);
|
|
734
|
+
const reread = await waitFor((l) => l.some((m) => m.d.type === 'housebets-game-balance-reconciliation-request'), 10000);
|
|
735
|
+
check(size, 'outage', reread && errors.length === errs, reread ? (errors.length === errs ? 'ambiguous bet: the site was asked to re-read the wallet; the game kept running' : 'the game threw: ' + errors.slice(errs).join('; ')) : 'no wallet re-read requested after an ambiguous bet');
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
} catch (e) {
|
|
739
|
+
check(size, 'bet', false, 'hbtest.json step failed: ' + e.message);
|
|
740
|
+
for (const rule of ['currency', 'outage']) check(size, rule, false, 'not reached: the bet step failed');
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
await measure();
|
|
744
|
+
// The site's fullscreen button: the frame takes the whole screen.
|
|
745
|
+
const full = FULLSCREEN[size];
|
|
746
|
+
await page.setViewportSize(full);
|
|
747
|
+
await page.evaluate(() => { document.getElementById('c').style.height = innerHeight + 'px'; });
|
|
748
|
+
await page.waitForTimeout(1500);
|
|
749
|
+
await measure('fullscreen: ');
|
|
750
|
+
check(size, 'fit', scrolling.size === 0, scrolling.size ? 'does not fit: ' + [...scrolling].join('; ') : `fits ${width}×720 and fullscreen ${full.width}×${full.height}`);
|
|
751
|
+
check(size, 'errors', errors.length === 0, errors.length ? [...new Set(errors)].slice(0, 4).join(' | ') : 'none');
|
|
752
|
+
network(size, outside, refused, rtcCount(), context.serviceWorkers().length, reports());
|
|
753
|
+
keepConsole();
|
|
754
|
+
const shot = await page.screenshot().catch(() => null);
|
|
755
|
+
if (shot) {
|
|
756
|
+
writeFileSync(`${opt('shots', process.cwd())}/conformance-${SLUG}-${size}.png`, shot);
|
|
757
|
+
// A frame of one flat colour: the game drew nothing the screenshot can see
|
|
758
|
+
// (a canvas the browser could not draw, or a board that never painted).
|
|
759
|
+
// A warning for the reviewer for now: the run's software WebGL is new.
|
|
760
|
+
let flat = null;
|
|
761
|
+
try { flat = flatShare(shot); } catch { /* not a picture we can read: say nothing */ }
|
|
762
|
+
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
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ---- time travel: the matrix of clocks, zones and languages ------------------------------
|
|
767
|
+
// Every variant is a fresh context at the desktop frame under the desktop
|
|
768
|
+
// display (EUR at a rate: the very pairing the frame-size run plays at this
|
|
769
|
+
// width, so a game keying on the frame's width and the currency finds nothing
|
|
770
|
+
// the frame-size run does not show it), with Playwright's clock installed
|
|
771
|
+
// before the page loads (so the game's first read of the date is the
|
|
772
|
+
// travelled one), the zone and the language set on the context and the
|
|
773
|
+
// language on the launch, and the game's storage as the variant before left
|
|
774
|
+
// it (travel.mjs handedStorage: never written at a later clock than the
|
|
775
|
+
// variant's own). What it records is judged against the baseline variant
|
|
776
|
+
// (travel.mjs compareVariant); the screenshots are kept so a reviewer can
|
|
777
|
+
// look at a warning.
|
|
778
|
+
let timeTravel = null;
|
|
779
|
+
if (TRAVEL) {
|
|
780
|
+
const now = new Date();
|
|
781
|
+
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() }));
|
|
783
|
+
const manifest = opt('manifest', '') && existsSync(opt('manifest')) ? JSON.parse(readFileSync(opt('manifest'), 'utf8')) : null;
|
|
784
|
+
const { shows, ...display } = DISPLAYS.desktop; // the frame-size run's pairing at 1194 px
|
|
785
|
+
const shotsDir = opt('shots', process.cwd());
|
|
786
|
+
const grids = { boot: {}, late: {} };
|
|
787
|
+
// The frame as a grid of grey cells (travel.mjs GRID), drawn by the
|
|
788
|
+
// browser from the PNG it just took: no image library in the run.
|
|
789
|
+
const toGrid = (page, png) => page.evaluate(async ([b64, w, h]) => {
|
|
790
|
+
const img = new Image();
|
|
791
|
+
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = 'data:image/png;base64,' + b64; });
|
|
792
|
+
const c = document.createElement('canvas');
|
|
793
|
+
c.width = w; c.height = h;
|
|
794
|
+
const ctx = c.getContext('2d');
|
|
795
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
796
|
+
const d = ctx.getImageData(0, 0, w, h).data;
|
|
797
|
+
const out = [];
|
|
798
|
+
for (let i = 0; i < d.length; i += 4) out.push(Math.round(0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]));
|
|
799
|
+
return out;
|
|
800
|
+
}, [png.toString('base64'), GRID.w, GRID.h]).catch(() => null);
|
|
801
|
+
// The game's pages: their inputs (by signature, never a value; a masked or
|
|
802
|
+
// password-autocompleting text field, a contenteditable, a role=textbox
|
|
803
|
+
// among them) and their words (the rendered text, text nodes, placeholders,
|
|
804
|
+
// labels, titles, alt text, CSS generated content, shadow roots included),
|
|
805
|
+
// for the credential-prompt patterns.
|
|
806
|
+
const scanPages = async (page) => listIn(page, () => {
|
|
807
|
+
const inputs = [], texts = [];
|
|
808
|
+
let looked = 0;
|
|
809
|
+
const walk = (root, hosts) => {
|
|
810
|
+
const sdk = hosts.some((t) => t.startsWith('HB-'));
|
|
811
|
+
for (const el of root.querySelectorAll('input, textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
|
|
812
|
+
let type = el.tagName === 'TEXTAREA' ? 'textarea' : el.tagName === 'INPUT' ? (el.getAttribute('type') || 'text').toLowerCase() : 'text';
|
|
813
|
+
if (!['password', 'email', 'text', 'tel', 'url', 'search', 'textarea'].includes(type)) continue;
|
|
814
|
+
const st = getComputedStyle(el);
|
|
815
|
+
const masked = st.webkitTextSecurity && st.webkitTextSecurity !== 'none';
|
|
816
|
+
inputs.push({ type, id: el.id, name: el.getAttribute('name') || '', autocomplete: el.getAttribute('autocomplete') || '', masked, sdk });
|
|
817
|
+
}
|
|
818
|
+
const it = document.createNodeIterator(root, NodeFilter.SHOW_TEXT);
|
|
819
|
+
for (let n = it.nextNode(); n; n = it.nextNode()) {
|
|
820
|
+
const p = n.parentElement;
|
|
821
|
+
if (p && !p.closest('script, style, template, noscript')) texts.push(n.textContent);
|
|
822
|
+
}
|
|
823
|
+
// The text as rendered: inline boundaries kept, so a word split over
|
|
824
|
+
// two elements reads as one word.
|
|
825
|
+
const body = root === document ? document.body : root.host;
|
|
826
|
+
if (body && body.innerText) texts.push(body.innerText);
|
|
827
|
+
for (const el of root.querySelectorAll('[placeholder], [aria-label], [title], [alt]')) texts.push(el.getAttribute('placeholder'), el.getAttribute('aria-label'), el.getAttribute('title'), el.getAttribute('alt'));
|
|
828
|
+
for (const el of root.querySelectorAll('*')) {
|
|
829
|
+
if (looked++ > 4000) break;
|
|
830
|
+
for (const pseudo of ['::before', '::after']) {
|
|
831
|
+
const c = getComputedStyle(el, pseudo).content;
|
|
832
|
+
if (c && c !== 'none' && c !== 'normal' && c.length > 2) texts.push(c.replace(/^["']|["']$/g, ''));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
for (const host of root.querySelectorAll('*')) if (host.shadowRoot) walk(host.shadowRoot, [...hosts, host.tagName]);
|
|
836
|
+
};
|
|
837
|
+
walk(document, []);
|
|
838
|
+
return [{ inputs, text: texts.filter(Boolean).join(' ') }];
|
|
839
|
+
});
|
|
840
|
+
// The game's child frames with something in them (a placeholder about:blank
|
|
841
|
+
// is nothing): where the game's pages are, by path or by kind.
|
|
842
|
+
const childFrames = async (page) => {
|
|
843
|
+
const out = [];
|
|
844
|
+
for (const f of page.frames()) {
|
|
845
|
+
if (!isGameFrame(f) || !f.parentFrame() || !isGameFrame(f.parentFrame())) continue;
|
|
846
|
+
const has = await f.evaluate(() => !!document.body && (document.body.children.length > 0 || document.body.textContent.trim().length > 0)).catch(() => false);
|
|
847
|
+
if (!has) continue;
|
|
848
|
+
const u = f.url();
|
|
849
|
+
out.push(u.startsWith(ORIGIN.studio) ? new URL(u).pathname : u.split('?')[0].slice(0, 80));
|
|
850
|
+
}
|
|
851
|
+
return out;
|
|
852
|
+
};
|
|
853
|
+
// The long session's animations: timers and frames run on the clock, and a
|
|
854
|
+
// CSS or Web Animations API animation, which runs on the compositor's real
|
|
855
|
+
// time, is moved on by every jump of the clock (clock.mjs startClock), so
|
|
856
|
+
// a round's reveal costs no waiting and the screen agrees with the clock.
|
|
857
|
+
// What the game's screen says when a round is stuck, for the report.
|
|
858
|
+
const screenSays = async (page) => {
|
|
859
|
+
const words = (await listIn(page, () => [document.body.innerText.replace(/\s+/g, ' ').trim().slice(0, 160)])).filter(Boolean).join(' / ');
|
|
860
|
+
const animations = (await listIn(page, () => document.getAnimations().filter((a) => a.playState === 'running').map((a) => `${a.effect?.target?.tagName || '?'}${a.effect?.target?.id ? '#' + a.effect.target.id : ''} at ${Math.round(a.currentTime || 0)} of ${a.effect?.getTiming?.().duration ?? '?'} ms`))).slice(0, 4);
|
|
861
|
+
return (words ? ` (the screen says: "${words}")` : '') + (animations.length ? ` (animations still running: ${animations.join(', ')})` : '');
|
|
862
|
+
};
|
|
863
|
+
const record = async (v, page, m) => {
|
|
864
|
+
for (const p of await scanPages(page)) {
|
|
865
|
+
for (const i of p.inputs) { const s = inputSignature(i); if (!v.inputs.includes(s)) v.inputs.push(s); }
|
|
866
|
+
for (const hit of credentialHits(p.text)) if (!v.phishing.includes(hit)) v.phishing.push(hit);
|
|
867
|
+
}
|
|
868
|
+
for (const hit of credentialHits(m.canvasText.join(' '))) if (!v.phishing.includes(hit)) v.phishing.push(hit);
|
|
869
|
+
for (const f of await childFrames(page)) if (!v.frames.includes(f)) v.frames.push(f);
|
|
870
|
+
};
|
|
871
|
+
// The bet control as the browser measures it: a player's button, or a decoy
|
|
872
|
+
// (travel.mjs controlProblem). What was pressed goes in the report.
|
|
873
|
+
const measureControl = async (control) => control.evaluate((el) => {
|
|
874
|
+
const r = el.getBoundingClientRect();
|
|
875
|
+
let opacity = 1;
|
|
876
|
+
for (let e = el; e; e = e.parentElement || (e.getRootNode && e.getRootNode().host)) { const o = Number(getComputedStyle(e).opacity); if (!Number.isNaN(o)) opacity *= o; if (e === document.documentElement) break; }
|
|
877
|
+
const st = getComputedStyle(el);
|
|
878
|
+
return { w: r.width, h: r.height, left: r.left, top: r.top, vw: innerWidth, vh: innerHeight, opacity, pointerEvents: st.pointerEvents, visibility: st.visibility, disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true', tag: el.tagName.toLowerCase(), id: el.id || '', text: (el.textContent || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim().slice(0, 24) };
|
|
879
|
+
}).catch(() => null);
|
|
880
|
+
const checkControl = async (v, h) => {
|
|
881
|
+
const f = h.gameFrame(hbtest.bet.page);
|
|
882
|
+
const control = f && f.locator(hbtest.bet.selector).first();
|
|
883
|
+
const box = control && await control.count().catch(() => 0) ? await measureControl(control) : null;
|
|
884
|
+
if (box) v.betControl = { tag: box.tag, id: box.id, text: box.text, w: Math.round(box.w), h: Math.round(box.h) };
|
|
885
|
+
v.controlProblem = controlProblem(box);
|
|
886
|
+
return !v.controlProblem;
|
|
887
|
+
};
|
|
888
|
+
// The game's storage as a variant left it, for the next one: its own origin
|
|
889
|
+
// only (the shell's session is never carried over), IndexedDB included.
|
|
890
|
+
const keepStorage = async (context) => {
|
|
891
|
+
try {
|
|
892
|
+
const st = await context.storageState({ indexedDB: true });
|
|
893
|
+
const origins = (st.origins || []).filter((o) => o.origin === ORIGIN.studio);
|
|
894
|
+
return { cookies: [], origins, keys: (origins[0]?.localStorage || []).map((e) => e.name).sort().slice(0, 50) };
|
|
895
|
+
} catch { return null; }
|
|
896
|
+
};
|
|
897
|
+
const shotTo = async (page, name, where, id) => {
|
|
898
|
+
// Three captures a second apart, the middle one kept, their grid the
|
|
899
|
+
// cell-by-cell median: a reel that happened to spin in one capture does
|
|
900
|
+
// not make the difference.
|
|
901
|
+
const pngs = [], cells = [];
|
|
902
|
+
for (let k = 0; k < 3; k++) {
|
|
903
|
+
if (k) await page.waitForTimeout(1000);
|
|
904
|
+
const png = await page.screenshot();
|
|
905
|
+
pngs.push(png);
|
|
906
|
+
cells.push(await toGrid(page, png));
|
|
907
|
+
}
|
|
908
|
+
writeFileSync(join(shotsDir, name), pngs[1]);
|
|
909
|
+
grids[where][id] = medianGrid(cells);
|
|
910
|
+
};
|
|
911
|
+
// The storage the first variant starts from: what an earlier run left
|
|
912
|
+
// (`--storage`), at today's origin, or nothing. `storage` is what the next
|
|
913
|
+
// variant is handed (handedStorage: never a storage written at a clock
|
|
914
|
+
// past the variant's own); `carriedOut` is what the next run is handed
|
|
915
|
+
// (the file): the storage as the last variant on the real clock left it,
|
|
916
|
+
// before any walk, so tomorrow's run finds yesterday's date in it and not
|
|
917
|
+
// a day in its own future.
|
|
918
|
+
let storage = null, carriedOut = null;
|
|
919
|
+
const carried = { from: 'none', keys: 0 };
|
|
920
|
+
if (STORAGE_FILE && existsSync(STORAGE_FILE)) {
|
|
921
|
+
const raw = statSync(STORAGE_FILE).size <= STORAGE_FILE_MAX ? readFileSync(STORAGE_FILE, 'utf8') : '';
|
|
922
|
+
storage = carriedStorage(raw, ORIGIN.studio);
|
|
923
|
+
carried.from = storage ? 'file' : 'unreadable';
|
|
924
|
+
carried.keys = storage ? storage.keys.length : 0;
|
|
925
|
+
}
|
|
926
|
+
let variantsLeft = variants.length;
|
|
927
|
+
for (const [index, v] of variants.entries()) {
|
|
928
|
+
const started = Date.now();
|
|
929
|
+
variantsLeft--;
|
|
930
|
+
v.boot = false; v.inputs = []; v.phishing = []; v.requests = []; v.refused = []; v.errors = []; v.consoleErrors = []; v.dialogs = 0; v.dialogKinds = []; v.workers = []; v.frames = [];
|
|
931
|
+
// The matrix's real time: a variant that no longer fits is skipped and
|
|
932
|
+
// says so. The variants still to come are estimated from the short ones
|
|
933
|
+
// already played (the baseline first), not from the pessimistic constant,
|
|
934
|
+
// which would leave the long session no room in any job.
|
|
935
|
+
const fit = fitsBudget({ elapsedMs: Date.now() - matrixStart, totalMs: TRAVEL_TOTAL_MS, rounds: v.rounds, longBudgetMs: TRAVEL_BUDGET_MS, after: variantsLeft, perVariantMs: shortVariantEstimate(variants) });
|
|
936
|
+
if (!fit.fits) { v.skipped = `out of time: ${Math.round((Date.now() - matrixStart) / 1000)} s into the matrix's ${Math.round(TRAVEL_TOTAL_MS / 1000)} s`; continue; }
|
|
937
|
+
// A variant at the real time starts at the real time of its own start,
|
|
938
|
+
// not the matrix's (travel.mjs startInstant): the storage it is handed
|
|
939
|
+
// was written by the variant before it at the real time (the baseline's,
|
|
940
|
+
// before its walk, seconds into its play), and a clock behind that
|
|
941
|
+
// storage is a clock that ran backwards between visits. The dated
|
|
942
|
+
// variants keep the matrix's instants.
|
|
943
|
+
v.at = startInstant(v, v.at);
|
|
944
|
+
// The canary's baseline runs on the real clock: at the real date the
|
|
945
|
+
// point is to be a player's browser, which has no fake Date to find.
|
|
946
|
+
const fakeClock = !(TRAVEL === 'canary' && v.id === 'base');
|
|
947
|
+
clockOffsetMs = fakeClock ? new Date(v.at).getTime() - Date.now() : 0;
|
|
948
|
+
travelling = fakeClock;
|
|
949
|
+
// Chrome for the variant's zone (TZ on the process, timezoneId on the context: both say the zone).
|
|
950
|
+
const chrome = await browserFor(v.tz).catch((e) => { v.broke = `Chrome did not start for ${v.tz}: ${String(e.message || e).split('\n')[0].slice(0, 120)}`; return null; });
|
|
951
|
+
if (!chrome) { v.ms = Date.now() - started; continue; }
|
|
952
|
+
const context = await chrome.newContext({ viewport: { width: 1194, height: 900 }, timezoneId: v.tz, locale: v.locale, ...(storage ? { storageState: { cookies: storage.cookies, origins: storage.origins } } : {}) });
|
|
953
|
+
// The fake clock is installed by the init script (clock.mjs) and driven
|
|
954
|
+
// through the run's channel from the site page: every frame moves its
|
|
955
|
+
// clock and answers with its now, and the server's clock (clockOffsetMs:
|
|
956
|
+
// the proxy's timestamps, the Date headers, a worker prelude's instant)
|
|
957
|
+
// follows the game's own frame, so after a day of game time, or 300 fast
|
|
958
|
+
// rounds, a round's settledAt still agrees with the game's Date.now().
|
|
959
|
+
// The game's frame is the one to follow: a fake clock stands still while
|
|
960
|
+
// it jumps, so the real time a heavy frame spends inside its jumps is
|
|
961
|
+
// not counted by its clock while the site's and the shell's run on;
|
|
962
|
+
// those drift ahead of the game's by that much (clockSkewMs records how
|
|
963
|
+
// far), which the game cannot see (other origins) and the server does
|
|
964
|
+
// not follow. A frame that did not answer within 10 s is a frame whose
|
|
965
|
+
// clock did not move: the variant breaks rather than judging a game on
|
|
966
|
+
// clocks that disagree.
|
|
967
|
+
const advance = async (op, ms) => {
|
|
968
|
+
let timer;
|
|
969
|
+
const r = await Promise.race([
|
|
970
|
+
page.evaluate(siteJump, [RUN_TOKEN, op, ms]),
|
|
971
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 10000); }),
|
|
972
|
+
]).finally(() => clearTimeout(timer)).catch(async (e) => {
|
|
973
|
+
const tally = await page.evaluate(() => window.__clock).catch(() => null);
|
|
974
|
+
throw new Error(`the fake clock did not move (${op} ${ms} ms) in every frame within 10 s${tally ? `: ${tally.from.length} of ${tally.posted} window(s) answered (${tally.from.join(', ')})` : ''}: ${e.message}`);
|
|
975
|
+
});
|
|
976
|
+
const far = Math.max(...r.nows), near = Math.min(...r.nows);
|
|
977
|
+
v.clockSkewMs = Math.max(v.clockSkewMs || 0, far - near);
|
|
978
|
+
const game = r.nows.filter((_, i) => String(r.from[i] || '').startsWith(ORIGIN.studio));
|
|
979
|
+
clockOffsetMs = (game.length ? Math.min(...game) : near) - Date.now();
|
|
980
|
+
};
|
|
981
|
+
const jump = (ms) => advance('runFor', ms);
|
|
982
|
+
const leap = (ms) => advance('fastForward', ms);
|
|
983
|
+
// The game's clock read back now and then against the server's: a drift
|
|
984
|
+
// would show here (clockDriftMs) instead of in a round's timestamps.
|
|
985
|
+
const syncClock = async (page) => {
|
|
986
|
+
if (!fakeClock) return;
|
|
987
|
+
const frame = studioFrames(page)[0];
|
|
988
|
+
if (!frame) return;
|
|
989
|
+
const gameNow = await frame.evaluate(() => Date.now()).catch(() => null);
|
|
990
|
+
if (typeof gameNow !== 'number') return;
|
|
991
|
+
const measured = gameNow - Date.now();
|
|
992
|
+
v.clockDriftMs = Math.max(v.clockDriftMs || 0, Math.abs(measured - clockOffsetMs));
|
|
993
|
+
clockOffsetMs = measured;
|
|
994
|
+
};
|
|
995
|
+
const page = await context.newPage();
|
|
996
|
+
await page.addInitScript(initScript({ travel: fakeClock, at: v.at }));
|
|
997
|
+
const pageDialogs = [];
|
|
998
|
+
page.on('dialog', (d) => { pageDialogs.push(d.type()); d.dismiss().catch(() => {}); });
|
|
999
|
+
const m = instrument(page);
|
|
1000
|
+
const h = helpers(page);
|
|
1001
|
+
// The game's storage as it stood before the walk (at the variant's own
|
|
1002
|
+
// instant), for a variant whose clock is not past the walk's end.
|
|
1003
|
+
let beforeWalk = null;
|
|
1004
|
+
try {
|
|
1005
|
+
await page.goto(ORIGIN.site + '/');
|
|
1006
|
+
const { url } = await launch(display, v.locale);
|
|
1007
|
+
await page.evaluate((u) => window.__launch(u), url);
|
|
1008
|
+
v.boot = await h.waitFor((l) => l.some((x) => x.d.message === 'gameLoaded'), 25000);
|
|
1009
|
+
if (v.boot) {
|
|
1010
|
+
await page.waitForTimeout(2000);
|
|
1011
|
+
await h.wander();
|
|
1012
|
+
// The page the bet is on (a game behind a menu): opened as the frame-size run opens it.
|
|
1013
|
+
for (const s of hbtest?.open || []) { await h.act(s); await page.waitForTimeout(1500); }
|
|
1014
|
+
await record(v, page, m);
|
|
1015
|
+
if (!v.rounds && !v.hidden) await shotTo(page, shotName(v.id), 'boot', v.id);
|
|
1016
|
+
if (hbtest && !v.rounds) {
|
|
1017
|
+
// One round as a player plays it, at this clock (the stake first:
|
|
1018
|
+
// the SDK panel's Bet is disabled and dimmed until there is one);
|
|
1019
|
+
// then the clock walked on through a day, the page scanned after
|
|
1020
|
+
// each jump.
|
|
1021
|
+
if (hbtest.stake) await h.act(hbtest.stake);
|
|
1022
|
+
if (await checkControl(v, h)) {
|
|
1023
|
+
const ends = h.roundEnds(await h.log());
|
|
1024
|
+
await h.act(hbtest.bet);
|
|
1025
|
+
for (let k = 0; k < (hbtest.then || []).length; k++) { await page.waitForTimeout(1500); await h.thenSteps(hbtest.then); }
|
|
1026
|
+
v.played = m.plays.length;
|
|
1027
|
+
v.settledOne = await h.waitFor((l) => h.roundEnds(l) > ends, 10000);
|
|
1028
|
+
if (!v.settledOne) v.errors.push('the round did not end within 10 s of the bet (no round-end from the game)');
|
|
1029
|
+
}
|
|
1030
|
+
if (fakeClock) {
|
|
1031
|
+
beforeWalk = await keepStorage(context);
|
|
1032
|
+
await leap(WALK.firstMs);
|
|
1033
|
+
await record(v, page, m);
|
|
1034
|
+
for (let k = 0; k < WALK.steps; k++) {
|
|
1035
|
+
await leap(WALK.stepMs);
|
|
1036
|
+
await record(v, page, m);
|
|
1037
|
+
}
|
|
1038
|
+
await syncClock(page);
|
|
1039
|
+
v.walked = true;
|
|
1040
|
+
// The hidden canary variant's late frame stands as the baseline's (lateFor).
|
|
1041
|
+
await shotTo(page, lateShotName(v.lateFor || v.id), 'late', v.lateFor || v.id);
|
|
1042
|
+
}
|
|
1043
|
+
} else if (hbtest && v.rounds) {
|
|
1044
|
+
// The long session: fast rounds, the first as drawn (its play
|
|
1045
|
+
// names the mode and bet the game plays), then each forced to the
|
|
1046
|
+
// next row of that mode's table, game time driven by the clock so
|
|
1047
|
+
// an animation costs no waiting. A round that never ends, or a
|
|
1048
|
+
// bet control that never comes back, ends the session and is
|
|
1049
|
+
// reported. The SDK panel's Bet, when the game has one and the
|
|
1050
|
+
// test names another control, is pressed every other round: the
|
|
1051
|
+
// path players use is exercised too.
|
|
1052
|
+
v.settled = 0;
|
|
1053
|
+
if (hbtest.stake) await h.act(hbtest.stake);
|
|
1054
|
+
if (await checkControl(v, h)) {
|
|
1055
|
+
const f0 = h.gameFrame(hbtest.bet.page);
|
|
1056
|
+
const panel = f0 && hbtest.bet.selector !== 'hb-game [data-main]' && await f0.locator('hb-game [data-main]').count().catch(() => 0) ? { page: hbtest.bet.page, selector: 'hb-game [data-main]' } : null;
|
|
1057
|
+
v.pressed = { control: 0, panel: 0 };
|
|
1058
|
+
// A round (a feature with free spins, a big win's presentation)
|
|
1059
|
+
// may run this long in game time before it counts as stuck.
|
|
1060
|
+
// ENGINE_TRAVEL_REALTIME=1 lets game time run at real time
|
|
1061
|
+
// instead (for telling a game that hangs from a clock it
|
|
1062
|
+
// dislikes while working on the run; never the job's).
|
|
1063
|
+
const ROUND_GAME_MS = 180_000, STEP_MS = 1000, sessionStart = Date.now();
|
|
1064
|
+
v.budgetMs = fit.longBudgetMs;
|
|
1065
|
+
// In real time both clocks flow together and the offset stands; a jump moves it.
|
|
1066
|
+
const step = () => (process.env.ENGINE_TRAVEL_REALTIME === '1' ? page.waitForTimeout(STEP_MS) : jump(STEP_MS));
|
|
1067
|
+
let rows = null;
|
|
1068
|
+
forceLog = { asked: 0, applied: 0, refused: 0 };
|
|
1069
|
+
lastPlayBody = null;
|
|
1070
|
+
for (let i = 0; i < v.rounds; i++) {
|
|
1071
|
+
if (Date.now() - sessionStart > v.budgetMs) { v.outOfTime = true; break; }
|
|
1072
|
+
if (i === 1) {
|
|
1073
|
+
const played = lastPlayBody && typeof lastPlayBody.mode === 'string' ? { mode: lastPlayBody.mode, bet: lastPlayBody.bet } : null;
|
|
1074
|
+
rows = forcedRows(manifest, v.rounds, played);
|
|
1075
|
+
v.forced = rows !== null;
|
|
1076
|
+
v.forcedMode = played?.mode;
|
|
1077
|
+
}
|
|
1078
|
+
if (i && i % 20 === 0) await h.wander();
|
|
1079
|
+
forceNext = rows ? rows[i] : null;
|
|
1080
|
+
const usePanel = panel && i % 2 === 1;
|
|
1081
|
+
const which = usePanel ? panel : hbtest.bet;
|
|
1082
|
+
const f = h.gameFrame(which.page);
|
|
1083
|
+
const control = f && f.locator(which.selector).first();
|
|
1084
|
+
const ends = h.roundEnds(await h.log()), plays = m.plays.length;
|
|
1085
|
+
// Press until a bet leaves. The site's round-end can come before
|
|
1086
|
+
// the game is done showing the round (the shell's reveal deadline),
|
|
1087
|
+
// and a press meanwhile is the game's own hurry, so a press that
|
|
1088
|
+
// sends no bet is followed by another once the game is ready.
|
|
1089
|
+
let left = false;
|
|
1090
|
+
for (let k = 0; k < ROUND_GAME_MS / STEP_MS && !left; k++) {
|
|
1091
|
+
if (!!control && await control.isVisible().catch(() => false) && await control.isEnabled().catch(() => false)) {
|
|
1092
|
+
try { await h.act(which); v.pressed[usePanel ? 'panel' : 'control']++; } catch { /* covered or moving: next step */ }
|
|
1093
|
+
} else if (k % 20 === 10 && f) {
|
|
1094
|
+
// A screen that waits for a tap (a feature's intro, a win): tap the game.
|
|
1095
|
+
await f.locator('body').click({ position: { x: 300, y: 300 }, timeout: 2000, force: true }).catch(() => {});
|
|
1096
|
+
}
|
|
1097
|
+
await step();
|
|
1098
|
+
left = m.plays.length > plays;
|
|
1099
|
+
}
|
|
1100
|
+
if (!left) { v.stuck = `round ${i + 1}: no bet left the game within ${ROUND_GAME_MS / 1000} s of game time${await screenSays(page)}`; break; }
|
|
1101
|
+
let done = h.roundEnds(await h.log()) > ends;
|
|
1102
|
+
for (let k = 0; k < ROUND_GAME_MS / STEP_MS && !done; k++) {
|
|
1103
|
+
await step();
|
|
1104
|
+
if (hbtest.then?.length) await h.thenSteps(hbtest.then);
|
|
1105
|
+
done = h.roundEnds(await h.log()) > ends;
|
|
1106
|
+
}
|
|
1107
|
+
if (!done) { v.stuck = `round ${i + 1} did not end within ${ROUND_GAME_MS / 1000} s of game time${await screenSays(page)}`; break; }
|
|
1108
|
+
v.settled++;
|
|
1109
|
+
// The two clocks read against each other every so often: the
|
|
1110
|
+
// server must not fall behind the game over a long session.
|
|
1111
|
+
if (v.settled % 25 === 0) await syncClock(page);
|
|
1112
|
+
}
|
|
1113
|
+
forceNext = null;
|
|
1114
|
+
v.forcedAsked = forceLog.asked; v.forcedApplied = forceLog.applied; v.forcedRefused = forceLog.refused;
|
|
1115
|
+
forceLog = null;
|
|
1116
|
+
v.played = m.plays.length;
|
|
1117
|
+
v.sessionMs = Date.now() - sessionStart;
|
|
1118
|
+
await jump(2000);
|
|
1119
|
+
await syncClock(page);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
await record(v, page, m);
|
|
1123
|
+
if (v.rounds) await shotTo(page, shotName(v.id), 'boot', v.id);
|
|
1124
|
+
}
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
v.broke = e.message.split('\n')[0].slice(0, 160);
|
|
1127
|
+
// What is on the page now that the run broke: a password field, a
|
|
1128
|
+
// credential prompt or a new frame shown just before the harness
|
|
1129
|
+
// jammed (a click that could not land because an overlay took it)
|
|
1130
|
+
// is the game's, and lands in the report beside the break, so the
|
|
1131
|
+
// break is not the only thing the verdict sees. Bounded: a page
|
|
1132
|
+
// that no longer answers does not hold the matrix.
|
|
1133
|
+
let bound;
|
|
1134
|
+
await Promise.race([record(v, page, m), new Promise((r) => { bound = setTimeout(r, 5000); })]).catch(() => {}).finally(() => clearTimeout(bound));
|
|
1135
|
+
}
|
|
1136
|
+
v.requests = [...new Set(m.outside)];
|
|
1137
|
+
v.refused = [...new Set(m.refused)];
|
|
1138
|
+
v.errors = [...new Set([...m.errors, ...v.errors])];
|
|
1139
|
+
v.consoleErrors = [...new Set(m.consoleErrors)];
|
|
1140
|
+
v.workers = [...new Set(m.workers)];
|
|
1141
|
+
if (m.unshimmed()) v.unshimmedWorkers = m.unshimmed();
|
|
1142
|
+
const dialogs = [...pageDialogs, ...m.dialogs()];
|
|
1143
|
+
v.dialogs = dialogs.length;
|
|
1144
|
+
v.dialogKinds = [...new Set(dialogs)];
|
|
1145
|
+
v.rtc = m.rtcCount();
|
|
1146
|
+
if (v.rtc) v.requests.push(`webrtc:${v.rtc} peer connection(s)`);
|
|
1147
|
+
const kept = await keepStorage(context);
|
|
1148
|
+
if (kept) v.storage = kept.keys;
|
|
1149
|
+
// What this variant hands on: to the next variant, by the next one's
|
|
1150
|
+
// clock (the storage before the walk unless that clock is past the
|
|
1151
|
+
// walk's end); to the next run, what the last variant on the real
|
|
1152
|
+
// clock left before any walk.
|
|
1153
|
+
const left = kept || beforeWalk ? { at: v.at, before: beforeWalk || kept, walked: v.walked ? kept : null } : null;
|
|
1154
|
+
const following = variants.slice(index + 1).find((x) => !x.skipped);
|
|
1155
|
+
if (left) storage = handedStorage(left, following ? following.at : null) || storage;
|
|
1156
|
+
if (left && v.clock === 'now') carriedOut = left.before;
|
|
1157
|
+
v.ms = Date.now() - started;
|
|
1158
|
+
await context.close().catch(() => {});
|
|
1159
|
+
}
|
|
1160
|
+
clockOffsetMs = 0;
|
|
1161
|
+
travelling = false;
|
|
1162
|
+
// What the last real-clock variant left, for the next run (the canary's next day).
|
|
1163
|
+
if (STORAGE_FILE && carriedOut) {
|
|
1164
|
+
try { writeFileSync(STORAGE_FILE, JSON.stringify({ origins: carriedOut.origins, keys: carriedOut.keys })); carried.kept = carriedOut.keys.length; } catch { carried.kept = -1; }
|
|
1165
|
+
}
|
|
1166
|
+
// The hidden variant (the canary's walked baseline) gave its late frame and is not reported.
|
|
1167
|
+
const reported = variants.filter((v) => !v.hidden);
|
|
1168
|
+
const baseline = reported.find((v) => v.id === 'base');
|
|
1169
|
+
for (const v of reported) {
|
|
1170
|
+
if (v === baseline) continue;
|
|
1171
|
+
if (grids.boot[v.id] && grids.boot.base) v.visual = visualDifference(grids.boot[v.id], grids.boot.base);
|
|
1172
|
+
if (grids.late[v.id] && grids.late.base) {
|
|
1173
|
+
v.visualLate = visualDifference(grids.late[v.id], grids.late.base);
|
|
1174
|
+
if (v.visualLate?.strong) v.lateShot = lateShotName(v.id);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
// A late frame kept for a variant is kept for the baseline too: a reviewer
|
|
1178
|
+
// asked to compare the late screenshots has both.
|
|
1179
|
+
if (baseline && grids.late.base && reported.some((v) => v.visualLate?.strong)) baseline.lateShot = lateShotName('base');
|
|
1180
|
+
// The canary's hidden walked baseline: when it did not play, the late frames
|
|
1181
|
+
// were compared with nothing, and the report says so (the studio treats a
|
|
1182
|
+
// comparison it asked for and did not get as a canary that did not run).
|
|
1183
|
+
const walked = variants.find((v) => v.hidden && v.lateFor === 'base');
|
|
1184
|
+
const walkedNot = walked && (walked.skipped || walked.broke || (!walked.boot ? 'the game did not boot' : '') || (!grids.late.base ? 'no late frame was taken' : ''));
|
|
1185
|
+
// The canary: each variant against the screenshot of the same variant as
|
|
1186
|
+
// the version was approved, so a change at the real date, which every
|
|
1187
|
+
// variant of today shares, is still seen, and a variant whose approved
|
|
1188
|
+
// look a reviewer accepted is still held to that look.
|
|
1189
|
+
const baselines = opt('baselines', '');
|
|
1190
|
+
if (baselines && existsSync(baselines)) {
|
|
1191
|
+
const context = await browser.newContext({ viewport: { width: 1194, height: 900 } });
|
|
1192
|
+
const page = await context.newPage();
|
|
1193
|
+
await page.goto(ORIGIN.site + '/');
|
|
1194
|
+
for (const v of reported) {
|
|
1195
|
+
const file = join(baselines, shotName(v.id));
|
|
1196
|
+
if (!grids.boot[v.id] || !existsSync(file)) continue;
|
|
1197
|
+
const approved = await toGrid(page, readFileSync(file));
|
|
1198
|
+
if (approved) v.visualVsApproved = visualDifference(grids.boot[v.id], approved);
|
|
1199
|
+
}
|
|
1200
|
+
await context.close().catch(() => {});
|
|
1201
|
+
}
|
|
1202
|
+
results.push(...checksFor(reported, baseline));
|
|
1203
|
+
if (walkedNot) results.push({ size: 'baseday', rule: 'time-travel', ok: true, status: 'skipped', detail: `the walked baseline did not play (${walkedNot}): the late frames were not compared` });
|
|
1204
|
+
timeTravel = { at: now.toISOString(), set: TRAVEL, rounds: TRAVEL_ROUNDS, variants: reported, ...(STORAGE_FILE ? { storage: carried } : {}) };
|
|
1205
|
+
}
|
|
1206
|
+
// Chrome's crash reporter can outlive Chrome and hold its pipe open, and then
|
|
1207
|
+
// close() never returns: the verdict does not wait on it.
|
|
1208
|
+
await Promise.race([Promise.all([...browsers.values()].map((b) => b.close())), new Promise((r) => setTimeout(r, 5000))]);
|
|
1209
|
+
servers.forEach((s) => s.close());
|
|
1210
|
+
|
|
1211
|
+
const ok = results.every((r) => r.ok);
|
|
1212
|
+
if (args.includes('--json')) { console.log(JSON.stringify({ ok, slug: SLUG, sandbox: !UNSANDBOXED, softwareWebGL: SOFTWARE_WEBGL.length > 0, checks: results, ...(Object.keys(frameConsole).length ? { console: frameConsole } : {}), ...(timeTravel ? { timeTravel } : {}) })); process.exit(ok ? 0 : 1); }
|
|
1213
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} conformance ${SLUG}${TRAVEL ? ` time travel (${TRAVEL})` : ''}${UNSANDBOXED ? ' (Chrome sandbox OFF)' : ''}`);
|
|
1214
|
+
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}`);
|
|
1215
|
+
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.clockDriftMs ? ` drift ${v.clockDriftMs}ms` : ''}${v.unshimmedWorkers ? ` unshimmed ${v.unshimmedWorkers}` : ''}${v.skipped ? ' skipped' : ''}`);
|
|
1216
|
+
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
|
+
process.exit(ok ? 0 : 1);
|