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.
Files changed (561) hide show
  1. package/README.md +20 -0
  2. package/engine-cli/connect.mjs +73 -0
  3. package/engine-cli/hbengine.mjs +565 -0
  4. package/engine-cli/package.json +1 -0
  5. package/engine-cli/templates.mjs +14 -0
  6. package/engine-conformance/analyse-worker.mjs +12 -0
  7. package/engine-conformance/analyse.mjs +4458 -0
  8. package/engine-conformance/bundle.mjs +208 -0
  9. package/engine-conformance/clock.mjs +294 -0
  10. package/engine-conformance/csp.go +56 -0
  11. package/engine-conformance/csp.json +6 -0
  12. package/engine-conformance/csp.mjs +20 -0
  13. package/engine-conformance/gzip-sizes.mjs +9 -0
  14. package/engine-conformance/package.json +1 -0
  15. package/engine-conformance/picture.mjs +55 -0
  16. package/engine-conformance/report.go +349 -0
  17. package/engine-conformance/run.mjs +1217 -0
  18. package/engine-conformance/scan.mjs +476 -0
  19. package/engine-conformance/testdata/clock-overlay/bundle/assets.json +1 -0
  20. package/engine-conformance/testdata/clock-overlay/bundle/game.css +17 -0
  21. package/engine-conformance/testdata/clock-overlay/bundle/game.js +83 -0
  22. package/engine-conformance/testdata/clock-overlay/bundle/hbtest.json +4 -0
  23. package/engine-conformance/testdata/clock-overlay/bundle/index.html +34 -0
  24. package/engine-conformance/testdata/clock-probe/bundle/assets.json +1 -0
  25. package/engine-conformance/testdata/clock-probe/bundle/game.css +17 -0
  26. package/engine-conformance/testdata/clock-probe/bundle/game.js +65 -0
  27. package/engine-conformance/testdata/clock-probe/bundle/hbtest.json +4 -0
  28. package/engine-conformance/testdata/clock-probe/bundle/index.html +35 -0
  29. package/engine-conformance/testdata/clock-probe/bundle/probe-worker.js +46 -0
  30. package/engine-conformance/testdata/clock-probe/bundle/probe.js +210 -0
  31. package/engine-conformance/testdata/clock-probe/math/dice.tsv +100 -0
  32. package/engine-conformance/testdata/clock-probe/math/manifest.json +2925 -0
  33. package/engine-conformance/testdata/clock-worker/bundle/assets.json +1 -0
  34. package/engine-conformance/testdata/clock-worker/bundle/clock-worker.js +14 -0
  35. package/engine-conformance/testdata/clock-worker/bundle/game.css +17 -0
  36. package/engine-conformance/testdata/clock-worker/bundle/game.js +61 -0
  37. package/engine-conformance/testdata/clock-worker/bundle/hbtest.json +4 -0
  38. package/engine-conformance/testdata/clock-worker/bundle/index.html +35 -0
  39. package/engine-conformance/testdata/clock-worker/math/dice.tsv +100 -0
  40. package/engine-conformance/testdata/clock-worker/math/manifest.json +2925 -0
  41. package/engine-conformance/travel.mjs +1209 -0
  42. package/engine-conformance/vendor/acorn/LICENSE +21 -0
  43. package/engine-conformance/vendor/acorn/acorn.mjs +6266 -0
  44. package/engine-conformance/wasm-releases.json +168 -0
  45. package/engine-examples/README.md +23 -0
  46. package/engine-examples/coin/AGENTS.md +3 -0
  47. package/engine-examples/coin/README.md +7 -0
  48. package/engine-examples/coin/bundle/assets.json +1 -0
  49. package/engine-examples/coin/bundle/game.css +5 -0
  50. package/engine-examples/coin/bundle/game.js +19 -0
  51. package/engine-examples/coin/bundle/hbtest.json +5 -0
  52. package/engine-examples/coin/bundle/index.html +16 -0
  53. package/engine-examples/coin/math/coin.tsv +2 -0
  54. package/engine-examples/coin/math/manifest.json +17 -0
  55. package/engine-examples/crash/AGENTS.md +3 -0
  56. package/engine-examples/crash/README.md +14 -0
  57. package/engine-examples/crash/bundle/assets.json +1 -0
  58. package/engine-examples/crash/bundle/game.css +8 -0
  59. package/engine-examples/crash/bundle/game.js +90 -0
  60. package/engine-examples/crash/bundle/hbtest.json +5 -0
  61. package/engine-examples/crash/bundle/index.html +20 -0
  62. package/engine-examples/crash/math/crash-01.tsv +2 -0
  63. package/engine-examples/crash/math/crash-02.tsv +2 -0
  64. package/engine-examples/crash/math/crash-03.tsv +2 -0
  65. package/engine-examples/crash/math/crash-04.tsv +2 -0
  66. package/engine-examples/crash/math/crash-05.tsv +2 -0
  67. package/engine-examples/crash/math/crash-06.tsv +2 -0
  68. package/engine-examples/crash/math/crash-07.tsv +2 -0
  69. package/engine-examples/crash/math/crash-08.tsv +2 -0
  70. package/engine-examples/crash/math/crash-09.tsv +2 -0
  71. package/engine-examples/crash/math/crash-10.tsv +2 -0
  72. package/engine-examples/crash/math/crash-11.tsv +2 -0
  73. package/engine-examples/crash/math/crash-12.tsv +2 -0
  74. package/engine-examples/crash/math/crash-13.tsv +2 -0
  75. package/engine-examples/crash/math/crash-14.tsv +2 -0
  76. package/engine-examples/crash/math/crash-15.tsv +2 -0
  77. package/engine-examples/crash/math/crash-16.tsv +2 -0
  78. package/engine-examples/crash/math/crash-17.tsv +2 -0
  79. package/engine-examples/crash/math/crash-18.tsv +2 -0
  80. package/engine-examples/crash/math/crash-19.tsv +2 -0
  81. package/engine-examples/crash/math/crash-20.tsv +2 -0
  82. package/engine-examples/crash/math/crash-21.tsv +2 -0
  83. package/engine-examples/crash/math/crash-22.tsv +2 -0
  84. package/engine-examples/crash/math/crash-23.tsv +2 -0
  85. package/engine-examples/crash/math/crash-24.tsv +2 -0
  86. package/engine-examples/crash/math/crash-25.tsv +2 -0
  87. package/engine-examples/crash/math/crash-26.tsv +2 -0
  88. package/engine-examples/crash/math/crash-27.tsv +2 -0
  89. package/engine-examples/crash/math/crash-28.tsv +2 -0
  90. package/engine-examples/crash/math/crash-29.tsv +2 -0
  91. package/engine-examples/crash/math/crash-30.tsv +2 -0
  92. package/engine-examples/crash/math/crash-31.tsv +2 -0
  93. package/engine-examples/crash/math/crash-32.tsv +2 -0
  94. package/engine-examples/crash/math/crash-33.tsv +2 -0
  95. package/engine-examples/crash/math/crash-34.tsv +2 -0
  96. package/engine-examples/crash/math/crash-35.tsv +2 -0
  97. package/engine-examples/crash/math/crash-36.tsv +2 -0
  98. package/engine-examples/crash/math/crash-37.tsv +2 -0
  99. package/engine-examples/crash/math/crash-38.tsv +2 -0
  100. package/engine-examples/crash/math/crash-39.tsv +2 -0
  101. package/engine-examples/crash/math/crash-40.tsv +2 -0
  102. package/engine-examples/crash/math/crash-41.tsv +2 -0
  103. package/engine-examples/crash/math/crash-42.tsv +2 -0
  104. package/engine-examples/crash/math/crash-43.tsv +2 -0
  105. package/engine-examples/crash/math/crash-44.tsv +2 -0
  106. package/engine-examples/crash/math/crash-45.tsv +2 -0
  107. package/engine-examples/crash/math/crash-46.tsv +2 -0
  108. package/engine-examples/crash/math/crash-47.tsv +2 -0
  109. package/engine-examples/crash/math/crash-48.tsv +2 -0
  110. package/engine-examples/crash/math/crash-49.tsv +2 -0
  111. package/engine-examples/crash/math/crash-50.tsv +2 -0
  112. package/engine-examples/crash/math/crash-51.tsv +2 -0
  113. package/engine-examples/crash/math/crash-52.tsv +2 -0
  114. package/engine-examples/crash/math/crash-53.tsv +2 -0
  115. package/engine-examples/crash/math/crash-54.tsv +2 -0
  116. package/engine-examples/crash/math/crash-55.tsv +2 -0
  117. package/engine-examples/crash/math/crash-56.tsv +2 -0
  118. package/engine-examples/crash/math/crash-57.tsv +2 -0
  119. package/engine-examples/crash/math/crash-58.tsv +2 -0
  120. package/engine-examples/crash/math/crash-59.tsv +2 -0
  121. package/engine-examples/crash/math/crash-60.tsv +2 -0
  122. package/engine-examples/crash/math/crash-61.tsv +2 -0
  123. package/engine-examples/crash/math/crash-62.tsv +2 -0
  124. package/engine-examples/crash/math/crash-63.tsv +2 -0
  125. package/engine-examples/crash/math/crash-64.tsv +2 -0
  126. package/engine-examples/crash/math/gen.mjs +31 -0
  127. package/engine-examples/crash/math/manifest.json +1 -0
  128. package/engine-examples/dice/AGENTS.md +3 -0
  129. package/engine-examples/dice/README.md +25 -0
  130. package/engine-examples/dice/bundle/assets.json +1 -0
  131. package/engine-examples/dice/bundle/game.css +17 -0
  132. package/engine-examples/dice/bundle/game.js +61 -0
  133. package/engine-examples/dice/bundle/hbtest.json +4 -0
  134. package/engine-examples/dice/bundle/index.html +34 -0
  135. package/engine-examples/dice/math/dice.tsv +100 -0
  136. package/engine-examples/dice/math/manifest.json +2925 -0
  137. package/engine-examples/keno/AGENTS.md +3 -0
  138. package/engine-examples/keno/README.md +11 -0
  139. package/engine-examples/keno/bundle/assets.json +1 -0
  140. package/engine-examples/keno/bundle/game.css +14 -0
  141. package/engine-examples/keno/bundle/game.js +86 -0
  142. package/engine-examples/keno/bundle/hbtest.json +4 -0
  143. package/engine-examples/keno/bundle/i18n.json +342 -0
  144. package/engine-examples/keno/bundle/index.html +20 -0
  145. package/engine-examples/keno/bundle/model.json +1 -0
  146. package/engine-examples/keno/math/gen.mjs +35 -0
  147. package/engine-examples/keno/math/keno-picks-1.tsv +2 -0
  148. package/engine-examples/keno/math/keno-picks-10.tsv +11 -0
  149. package/engine-examples/keno/math/keno-picks-2.tsv +3 -0
  150. package/engine-examples/keno/math/keno-picks-3.tsv +4 -0
  151. package/engine-examples/keno/math/keno-picks-4.tsv +5 -0
  152. package/engine-examples/keno/math/keno-picks-5.tsv +6 -0
  153. package/engine-examples/keno/math/keno-picks-6.tsv +7 -0
  154. package/engine-examples/keno/math/keno-picks-7.tsv +8 -0
  155. package/engine-examples/keno/math/keno-picks-8.tsv +9 -0
  156. package/engine-examples/keno/math/keno-picks-9.tsv +10 -0
  157. package/engine-examples/keno/math/manifest.json +1 -0
  158. package/engine-examples/keno/math-kit/pack.mjs +165 -0
  159. package/engine-examples/keno/math-kit/package.json +8 -0
  160. package/engine-examples/keno/math-kit/slot.mjs +94 -0
  161. package/engine-examples/keno/math-kit/tumble.mjs +94 -0
  162. package/engine-examples/lines/AGENTS.md +3 -0
  163. package/engine-examples/lines/README.md +20 -0
  164. package/engine-examples/lines/bundle/assets.json +1 -0
  165. package/engine-examples/lines/bundle/game.css +16 -0
  166. package/engine-examples/lines/bundle/game.js +125 -0
  167. package/engine-examples/lines/bundle/hbtest.json +4 -0
  168. package/engine-examples/lines/bundle/i18n.json +242 -0
  169. package/engine-examples/lines/bundle/index.html +23 -0
  170. package/engine-examples/lines/bundle/model.json +1 -0
  171. package/engine-examples/lines/math/gen.mjs +54 -0
  172. package/engine-examples/lines/math-kit/pack.mjs +165 -0
  173. package/engine-examples/lines/math-kit/package.json +8 -0
  174. package/engine-examples/lines/math-kit/slot.mjs +94 -0
  175. package/engine-examples/lines/math-kit/tumble.mjs +94 -0
  176. package/engine-examples/mines/AGENTS.md +3 -0
  177. package/engine-examples/mines/README.md +13 -0
  178. package/engine-examples/mines/bundle/assets.json +1 -0
  179. package/engine-examples/mines/bundle/game.css +12 -0
  180. package/engine-examples/mines/bundle/game.js +58 -0
  181. package/engine-examples/mines/bundle/hbtest.json +8 -0
  182. package/engine-examples/mines/bundle/index.html +16 -0
  183. package/engine-examples/mines/math/gen.mjs +40 -0
  184. package/engine-examples/mines/math/manifest.json +1 -0
  185. package/engine-examples/mines/math/mines-1-1.tsv +2 -0
  186. package/engine-examples/mines/math/mines-1-2.tsv +2 -0
  187. package/engine-examples/mines/math/mines-1-22.tsv +2 -0
  188. package/engine-examples/mines/math/mines-1-23.tsv +2 -0
  189. package/engine-examples/mines/math/mines-1-24.tsv +2 -0
  190. package/engine-examples/mines/math/mines-1-3.tsv +2 -0
  191. package/engine-examples/mines/math/mines-10-1.tsv +2 -0
  192. package/engine-examples/mines/math/mines-10-10.tsv +2 -0
  193. package/engine-examples/mines/math/mines-10-11.tsv +2 -0
  194. package/engine-examples/mines/math/mines-10-12.tsv +2 -0
  195. package/engine-examples/mines/math/mines-10-13.tsv +2 -0
  196. package/engine-examples/mines/math/mines-10-14.tsv +2 -0
  197. package/engine-examples/mines/math/mines-10-15.tsv +2 -0
  198. package/engine-examples/mines/math/mines-10-2.tsv +2 -0
  199. package/engine-examples/mines/math/mines-10-3.tsv +2 -0
  200. package/engine-examples/mines/math/mines-10-4.tsv +2 -0
  201. package/engine-examples/mines/math/mines-10-5.tsv +2 -0
  202. package/engine-examples/mines/math/mines-10-6.tsv +2 -0
  203. package/engine-examples/mines/math/mines-10-7.tsv +2 -0
  204. package/engine-examples/mines/math/mines-10-8.tsv +2 -0
  205. package/engine-examples/mines/math/mines-10-9.tsv +2 -0
  206. package/engine-examples/mines/math/mines-11-1.tsv +2 -0
  207. package/engine-examples/mines/math/mines-11-10.tsv +2 -0
  208. package/engine-examples/mines/math/mines-11-11.tsv +2 -0
  209. package/engine-examples/mines/math/mines-11-12.tsv +2 -0
  210. package/engine-examples/mines/math/mines-11-13.tsv +2 -0
  211. package/engine-examples/mines/math/mines-11-14.tsv +2 -0
  212. package/engine-examples/mines/math/mines-11-2.tsv +2 -0
  213. package/engine-examples/mines/math/mines-11-3.tsv +2 -0
  214. package/engine-examples/mines/math/mines-11-4.tsv +2 -0
  215. package/engine-examples/mines/math/mines-11-5.tsv +2 -0
  216. package/engine-examples/mines/math/mines-11-6.tsv +2 -0
  217. package/engine-examples/mines/math/mines-11-7.tsv +2 -0
  218. package/engine-examples/mines/math/mines-11-8.tsv +2 -0
  219. package/engine-examples/mines/math/mines-11-9.tsv +2 -0
  220. package/engine-examples/mines/math/mines-12-1.tsv +2 -0
  221. package/engine-examples/mines/math/mines-12-10.tsv +2 -0
  222. package/engine-examples/mines/math/mines-12-11.tsv +2 -0
  223. package/engine-examples/mines/math/mines-12-12.tsv +2 -0
  224. package/engine-examples/mines/math/mines-12-13.tsv +2 -0
  225. package/engine-examples/mines/math/mines-12-2.tsv +2 -0
  226. package/engine-examples/mines/math/mines-12-3.tsv +2 -0
  227. package/engine-examples/mines/math/mines-12-4.tsv +2 -0
  228. package/engine-examples/mines/math/mines-12-5.tsv +2 -0
  229. package/engine-examples/mines/math/mines-12-6.tsv +2 -0
  230. package/engine-examples/mines/math/mines-12-7.tsv +2 -0
  231. package/engine-examples/mines/math/mines-12-8.tsv +2 -0
  232. package/engine-examples/mines/math/mines-12-9.tsv +2 -0
  233. package/engine-examples/mines/math/mines-13-1.tsv +2 -0
  234. package/engine-examples/mines/math/mines-13-10.tsv +2 -0
  235. package/engine-examples/mines/math/mines-13-11.tsv +2 -0
  236. package/engine-examples/mines/math/mines-13-12.tsv +2 -0
  237. package/engine-examples/mines/math/mines-13-2.tsv +2 -0
  238. package/engine-examples/mines/math/mines-13-3.tsv +2 -0
  239. package/engine-examples/mines/math/mines-13-4.tsv +2 -0
  240. package/engine-examples/mines/math/mines-13-5.tsv +2 -0
  241. package/engine-examples/mines/math/mines-13-6.tsv +2 -0
  242. package/engine-examples/mines/math/mines-13-7.tsv +2 -0
  243. package/engine-examples/mines/math/mines-13-8.tsv +2 -0
  244. package/engine-examples/mines/math/mines-13-9.tsv +2 -0
  245. package/engine-examples/mines/math/mines-14-1.tsv +2 -0
  246. package/engine-examples/mines/math/mines-14-10.tsv +2 -0
  247. package/engine-examples/mines/math/mines-14-11.tsv +2 -0
  248. package/engine-examples/mines/math/mines-14-2.tsv +2 -0
  249. package/engine-examples/mines/math/mines-14-3.tsv +2 -0
  250. package/engine-examples/mines/math/mines-14-4.tsv +2 -0
  251. package/engine-examples/mines/math/mines-14-5.tsv +2 -0
  252. package/engine-examples/mines/math/mines-14-6.tsv +2 -0
  253. package/engine-examples/mines/math/mines-14-7.tsv +2 -0
  254. package/engine-examples/mines/math/mines-14-8.tsv +2 -0
  255. package/engine-examples/mines/math/mines-14-9.tsv +2 -0
  256. package/engine-examples/mines/math/mines-15-1.tsv +2 -0
  257. package/engine-examples/mines/math/mines-15-10.tsv +2 -0
  258. package/engine-examples/mines/math/mines-15-2.tsv +2 -0
  259. package/engine-examples/mines/math/mines-15-3.tsv +2 -0
  260. package/engine-examples/mines/math/mines-15-4.tsv +2 -0
  261. package/engine-examples/mines/math/mines-15-5.tsv +2 -0
  262. package/engine-examples/mines/math/mines-15-6.tsv +2 -0
  263. package/engine-examples/mines/math/mines-15-7.tsv +2 -0
  264. package/engine-examples/mines/math/mines-15-8.tsv +2 -0
  265. package/engine-examples/mines/math/mines-15-9.tsv +2 -0
  266. package/engine-examples/mines/math/mines-16-1.tsv +2 -0
  267. package/engine-examples/mines/math/mines-16-2.tsv +2 -0
  268. package/engine-examples/mines/math/mines-16-3.tsv +2 -0
  269. package/engine-examples/mines/math/mines-16-4.tsv +2 -0
  270. package/engine-examples/mines/math/mines-16-5.tsv +2 -0
  271. package/engine-examples/mines/math/mines-16-6.tsv +2 -0
  272. package/engine-examples/mines/math/mines-16-7.tsv +2 -0
  273. package/engine-examples/mines/math/mines-16-8.tsv +2 -0
  274. package/engine-examples/mines/math/mines-16-9.tsv +2 -0
  275. package/engine-examples/mines/math/mines-17-1.tsv +2 -0
  276. package/engine-examples/mines/math/mines-17-2.tsv +2 -0
  277. package/engine-examples/mines/math/mines-17-3.tsv +2 -0
  278. package/engine-examples/mines/math/mines-17-4.tsv +2 -0
  279. package/engine-examples/mines/math/mines-17-5.tsv +2 -0
  280. package/engine-examples/mines/math/mines-17-6.tsv +2 -0
  281. package/engine-examples/mines/math/mines-17-7.tsv +2 -0
  282. package/engine-examples/mines/math/mines-17-8.tsv +2 -0
  283. package/engine-examples/mines/math/mines-18-1.tsv +2 -0
  284. package/engine-examples/mines/math/mines-18-2.tsv +2 -0
  285. package/engine-examples/mines/math/mines-18-3.tsv +2 -0
  286. package/engine-examples/mines/math/mines-18-4.tsv +2 -0
  287. package/engine-examples/mines/math/mines-18-5.tsv +2 -0
  288. package/engine-examples/mines/math/mines-18-6.tsv +2 -0
  289. package/engine-examples/mines/math/mines-18-7.tsv +2 -0
  290. package/engine-examples/mines/math/mines-19-1.tsv +2 -0
  291. package/engine-examples/mines/math/mines-19-2.tsv +2 -0
  292. package/engine-examples/mines/math/mines-19-3.tsv +2 -0
  293. package/engine-examples/mines/math/mines-19-4.tsv +2 -0
  294. package/engine-examples/mines/math/mines-19-5.tsv +2 -0
  295. package/engine-examples/mines/math/mines-19-6.tsv +2 -0
  296. package/engine-examples/mines/math/mines-2-1.tsv +2 -0
  297. package/engine-examples/mines/math/mines-2-19.tsv +2 -0
  298. package/engine-examples/mines/math/mines-2-2.tsv +2 -0
  299. package/engine-examples/mines/math/mines-2-20.tsv +2 -0
  300. package/engine-examples/mines/math/mines-2-21.tsv +2 -0
  301. package/engine-examples/mines/math/mines-2-22.tsv +2 -0
  302. package/engine-examples/mines/math/mines-2-23.tsv +2 -0
  303. package/engine-examples/mines/math/mines-2-3.tsv +2 -0
  304. package/engine-examples/mines/math/mines-2-4.tsv +2 -0
  305. package/engine-examples/mines/math/mines-2-5.tsv +2 -0
  306. package/engine-examples/mines/math/mines-20-1.tsv +2 -0
  307. package/engine-examples/mines/math/mines-20-2.tsv +2 -0
  308. package/engine-examples/mines/math/mines-20-3.tsv +2 -0
  309. package/engine-examples/mines/math/mines-20-4.tsv +2 -0
  310. package/engine-examples/mines/math/mines-20-5.tsv +2 -0
  311. package/engine-examples/mines/math/mines-21-1.tsv +2 -0
  312. package/engine-examples/mines/math/mines-21-2.tsv +2 -0
  313. package/engine-examples/mines/math/mines-21-3.tsv +2 -0
  314. package/engine-examples/mines/math/mines-21-4.tsv +2 -0
  315. package/engine-examples/mines/math/mines-22-1.tsv +2 -0
  316. package/engine-examples/mines/math/mines-22-2.tsv +2 -0
  317. package/engine-examples/mines/math/mines-22-3.tsv +2 -0
  318. package/engine-examples/mines/math/mines-23-1.tsv +2 -0
  319. package/engine-examples/mines/math/mines-23-2.tsv +2 -0
  320. package/engine-examples/mines/math/mines-24-1.tsv +2 -0
  321. package/engine-examples/mines/math/mines-3-1.tsv +2 -0
  322. package/engine-examples/mines/math/mines-3-17.tsv +2 -0
  323. package/engine-examples/mines/math/mines-3-18.tsv +2 -0
  324. package/engine-examples/mines/math/mines-3-19.tsv +2 -0
  325. package/engine-examples/mines/math/mines-3-2.tsv +2 -0
  326. package/engine-examples/mines/math/mines-3-20.tsv +2 -0
  327. package/engine-examples/mines/math/mines-3-21.tsv +2 -0
  328. package/engine-examples/mines/math/mines-3-22.tsv +2 -0
  329. package/engine-examples/mines/math/mines-3-3.tsv +2 -0
  330. package/engine-examples/mines/math/mines-3-4.tsv +2 -0
  331. package/engine-examples/mines/math/mines-3-5.tsv +2 -0
  332. package/engine-examples/mines/math/mines-3-6.tsv +2 -0
  333. package/engine-examples/mines/math/mines-4-1.tsv +2 -0
  334. package/engine-examples/mines/math/mines-4-13.tsv +2 -0
  335. package/engine-examples/mines/math/mines-4-14.tsv +2 -0
  336. package/engine-examples/mines/math/mines-4-15.tsv +2 -0
  337. package/engine-examples/mines/math/mines-4-16.tsv +2 -0
  338. package/engine-examples/mines/math/mines-4-17.tsv +2 -0
  339. package/engine-examples/mines/math/mines-4-18.tsv +2 -0
  340. package/engine-examples/mines/math/mines-4-19.tsv +2 -0
  341. package/engine-examples/mines/math/mines-4-2.tsv +2 -0
  342. package/engine-examples/mines/math/mines-4-20.tsv +2 -0
  343. package/engine-examples/mines/math/mines-4-21.tsv +2 -0
  344. package/engine-examples/mines/math/mines-4-3.tsv +2 -0
  345. package/engine-examples/mines/math/mines-4-4.tsv +2 -0
  346. package/engine-examples/mines/math/mines-4-5.tsv +2 -0
  347. package/engine-examples/mines/math/mines-4-6.tsv +2 -0
  348. package/engine-examples/mines/math/mines-4-7.tsv +2 -0
  349. package/engine-examples/mines/math/mines-4-8.tsv +2 -0
  350. package/engine-examples/mines/math/mines-4-9.tsv +2 -0
  351. package/engine-examples/mines/math/mines-5-1.tsv +2 -0
  352. package/engine-examples/mines/math/mines-5-10.tsv +2 -0
  353. package/engine-examples/mines/math/mines-5-11.tsv +2 -0
  354. package/engine-examples/mines/math/mines-5-12.tsv +2 -0
  355. package/engine-examples/mines/math/mines-5-13.tsv +2 -0
  356. package/engine-examples/mines/math/mines-5-14.tsv +2 -0
  357. package/engine-examples/mines/math/mines-5-15.tsv +2 -0
  358. package/engine-examples/mines/math/mines-5-16.tsv +2 -0
  359. package/engine-examples/mines/math/mines-5-17.tsv +2 -0
  360. package/engine-examples/mines/math/mines-5-18.tsv +2 -0
  361. package/engine-examples/mines/math/mines-5-19.tsv +2 -0
  362. package/engine-examples/mines/math/mines-5-2.tsv +2 -0
  363. package/engine-examples/mines/math/mines-5-20.tsv +2 -0
  364. package/engine-examples/mines/math/mines-5-3.tsv +2 -0
  365. package/engine-examples/mines/math/mines-5-4.tsv +2 -0
  366. package/engine-examples/mines/math/mines-5-5.tsv +2 -0
  367. package/engine-examples/mines/math/mines-5-6.tsv +2 -0
  368. package/engine-examples/mines/math/mines-5-7.tsv +2 -0
  369. package/engine-examples/mines/math/mines-5-8.tsv +2 -0
  370. package/engine-examples/mines/math/mines-5-9.tsv +2 -0
  371. package/engine-examples/mines/math/mines-6-1.tsv +2 -0
  372. package/engine-examples/mines/math/mines-6-10.tsv +2 -0
  373. package/engine-examples/mines/math/mines-6-11.tsv +2 -0
  374. package/engine-examples/mines/math/mines-6-12.tsv +2 -0
  375. package/engine-examples/mines/math/mines-6-13.tsv +2 -0
  376. package/engine-examples/mines/math/mines-6-14.tsv +2 -0
  377. package/engine-examples/mines/math/mines-6-15.tsv +2 -0
  378. package/engine-examples/mines/math/mines-6-16.tsv +2 -0
  379. package/engine-examples/mines/math/mines-6-17.tsv +2 -0
  380. package/engine-examples/mines/math/mines-6-18.tsv +2 -0
  381. package/engine-examples/mines/math/mines-6-19.tsv +2 -0
  382. package/engine-examples/mines/math/mines-6-2.tsv +2 -0
  383. package/engine-examples/mines/math/mines-6-3.tsv +2 -0
  384. package/engine-examples/mines/math/mines-6-4.tsv +2 -0
  385. package/engine-examples/mines/math/mines-6-5.tsv +2 -0
  386. package/engine-examples/mines/math/mines-6-6.tsv +2 -0
  387. package/engine-examples/mines/math/mines-6-7.tsv +2 -0
  388. package/engine-examples/mines/math/mines-6-8.tsv +2 -0
  389. package/engine-examples/mines/math/mines-6-9.tsv +2 -0
  390. package/engine-examples/mines/math/mines-7-1.tsv +2 -0
  391. package/engine-examples/mines/math/mines-7-10.tsv +2 -0
  392. package/engine-examples/mines/math/mines-7-11.tsv +2 -0
  393. package/engine-examples/mines/math/mines-7-12.tsv +2 -0
  394. package/engine-examples/mines/math/mines-7-13.tsv +2 -0
  395. package/engine-examples/mines/math/mines-7-14.tsv +2 -0
  396. package/engine-examples/mines/math/mines-7-15.tsv +2 -0
  397. package/engine-examples/mines/math/mines-7-16.tsv +2 -0
  398. package/engine-examples/mines/math/mines-7-17.tsv +2 -0
  399. package/engine-examples/mines/math/mines-7-18.tsv +2 -0
  400. package/engine-examples/mines/math/mines-7-2.tsv +2 -0
  401. package/engine-examples/mines/math/mines-7-3.tsv +2 -0
  402. package/engine-examples/mines/math/mines-7-4.tsv +2 -0
  403. package/engine-examples/mines/math/mines-7-5.tsv +2 -0
  404. package/engine-examples/mines/math/mines-7-6.tsv +2 -0
  405. package/engine-examples/mines/math/mines-7-7.tsv +2 -0
  406. package/engine-examples/mines/math/mines-7-8.tsv +2 -0
  407. package/engine-examples/mines/math/mines-7-9.tsv +2 -0
  408. package/engine-examples/mines/math/mines-8-1.tsv +2 -0
  409. package/engine-examples/mines/math/mines-8-10.tsv +2 -0
  410. package/engine-examples/mines/math/mines-8-11.tsv +2 -0
  411. package/engine-examples/mines/math/mines-8-12.tsv +2 -0
  412. package/engine-examples/mines/math/mines-8-13.tsv +2 -0
  413. package/engine-examples/mines/math/mines-8-14.tsv +2 -0
  414. package/engine-examples/mines/math/mines-8-15.tsv +2 -0
  415. package/engine-examples/mines/math/mines-8-16.tsv +2 -0
  416. package/engine-examples/mines/math/mines-8-17.tsv +2 -0
  417. package/engine-examples/mines/math/mines-8-2.tsv +2 -0
  418. package/engine-examples/mines/math/mines-8-3.tsv +2 -0
  419. package/engine-examples/mines/math/mines-8-4.tsv +2 -0
  420. package/engine-examples/mines/math/mines-8-5.tsv +2 -0
  421. package/engine-examples/mines/math/mines-8-6.tsv +2 -0
  422. package/engine-examples/mines/math/mines-8-7.tsv +2 -0
  423. package/engine-examples/mines/math/mines-8-8.tsv +2 -0
  424. package/engine-examples/mines/math/mines-8-9.tsv +2 -0
  425. package/engine-examples/mines/math/mines-9-1.tsv +2 -0
  426. package/engine-examples/mines/math/mines-9-10.tsv +2 -0
  427. package/engine-examples/mines/math/mines-9-11.tsv +2 -0
  428. package/engine-examples/mines/math/mines-9-12.tsv +2 -0
  429. package/engine-examples/mines/math/mines-9-13.tsv +2 -0
  430. package/engine-examples/mines/math/mines-9-14.tsv +2 -0
  431. package/engine-examples/mines/math/mines-9-15.tsv +2 -0
  432. package/engine-examples/mines/math/mines-9-16.tsv +2 -0
  433. package/engine-examples/mines/math/mines-9-2.tsv +2 -0
  434. package/engine-examples/mines/math/mines-9-3.tsv +2 -0
  435. package/engine-examples/mines/math/mines-9-4.tsv +2 -0
  436. package/engine-examples/mines/math/mines-9-5.tsv +2 -0
  437. package/engine-examples/mines/math/mines-9-6.tsv +2 -0
  438. package/engine-examples/mines/math/mines-9-7.tsv +2 -0
  439. package/engine-examples/mines/math/mines-9-8.tsv +2 -0
  440. package/engine-examples/mines/math/mines-9-9.tsv +2 -0
  441. package/engine-examples/plinko/AGENTS.md +3 -0
  442. package/engine-examples/plinko/README.md +9 -0
  443. package/engine-examples/plinko/bundle/assets.json +1 -0
  444. package/engine-examples/plinko/bundle/game.css +5 -0
  445. package/engine-examples/plinko/bundle/game.js +92 -0
  446. package/engine-examples/plinko/bundle/hbtest.json +4 -0
  447. package/engine-examples/plinko/bundle/index.html +16 -0
  448. package/engine-examples/plinko/bundle/model.json +1 -0
  449. package/engine-examples/plinko/math/gen.mjs +40 -0
  450. package/engine-examples/plinko/math/manifest.json +1 -0
  451. package/engine-examples/plinko/math/plinko-12-high.tsv +13 -0
  452. package/engine-examples/plinko/math/plinko-12-low.tsv +13 -0
  453. package/engine-examples/plinko/math/plinko-12-medium.tsv +13 -0
  454. package/engine-examples/plinko/math/plinko-16-high.tsv +17 -0
  455. package/engine-examples/plinko/math/plinko-16-low.tsv +17 -0
  456. package/engine-examples/plinko/math/plinko-16-medium.tsv +17 -0
  457. package/engine-examples/plinko/math/plinko-8-high.tsv +9 -0
  458. package/engine-examples/plinko/math/plinko-8-low.tsv +9 -0
  459. package/engine-examples/plinko/math/plinko-8-medium.tsv +9 -0
  460. package/engine-examples/plinko/math-kit/pack.mjs +165 -0
  461. package/engine-examples/plinko/math-kit/package.json +8 -0
  462. package/engine-examples/plinko/math-kit/slot.mjs +94 -0
  463. package/engine-examples/plinko/math-kit/tumble.mjs +94 -0
  464. package/engine-examples/slot/AGENTS.md +3 -0
  465. package/engine-examples/slot/README.md +26 -0
  466. package/engine-examples/slot/bundle/assets.json +1 -0
  467. package/engine-examples/slot/bundle/game.css +19 -0
  468. package/engine-examples/slot/bundle/game.js +42 -0
  469. package/engine-examples/slot/bundle/hbtest.json +4 -0
  470. package/engine-examples/slot/bundle/index.html +23 -0
  471. package/engine-examples/slot/math/gen.mjs +79 -0
  472. package/engine-examples/slot/math/manifest.json +1 -0
  473. package/engine-examples/slot/math/slot.tsv +110592 -0
  474. package/engine-examples/tower/AGENTS.md +3 -0
  475. package/engine-examples/tower/README.md +21 -0
  476. package/engine-examples/tower/bundle/assets.json +1 -0
  477. package/engine-examples/tower/bundle/game.css +8 -0
  478. package/engine-examples/tower/bundle/game.js +37 -0
  479. package/engine-examples/tower/bundle/hbtest.json +5 -0
  480. package/engine-examples/tower/bundle/index.html +16 -0
  481. package/engine-examples/tower/math/floor.tsv +4 -0
  482. package/engine-examples/tower/math/manifest.json +61 -0
  483. package/engine-examples/tumble/AGENTS.md +3 -0
  484. package/engine-examples/tumble/README.md +18 -0
  485. package/engine-examples/tumble/bundle/assets.json +1 -0
  486. package/engine-examples/tumble/bundle/game.css +13 -0
  487. package/engine-examples/tumble/bundle/game.js +119 -0
  488. package/engine-examples/tumble/bundle/hbtest.json +4 -0
  489. package/engine-examples/tumble/bundle/i18n.json +122 -0
  490. package/engine-examples/tumble/bundle/index.html +20 -0
  491. package/engine-examples/tumble/bundle/model.json +1 -0
  492. package/engine-examples/tumble/math/gen.mjs +38 -0
  493. package/engine-examples/tumble/math-kit/pack.mjs +165 -0
  494. package/engine-examples/tumble/math-kit/package.json +8 -0
  495. package/engine-examples/tumble/math-kit/slot.mjs +94 -0
  496. package/engine-examples/tumble/math-kit/tumble.mjs +94 -0
  497. package/engine-examples/wheel/README.md +14 -0
  498. package/engine-examples/wheel/bundle/assets.json +1 -0
  499. package/engine-examples/wheel/bundle/game.css +7 -0
  500. package/engine-examples/wheel/bundle/game.js +51 -0
  501. package/engine-examples/wheel/bundle/hbtest.json +4 -0
  502. package/engine-examples/wheel/bundle/index.html +19 -0
  503. package/engine-examples/wheel/math/high.tsv +24 -0
  504. package/engine-examples/wheel/math/low.tsv +24 -0
  505. package/engine-examples/wheel/math/manifest.json +48 -0
  506. package/engine-examples/wheel/math/medium.tsv +24 -0
  507. package/engine-math/pack.mjs +165 -0
  508. package/engine-math/package.json +1 -0
  509. package/engine-math/slot.mjs +94 -0
  510. package/engine-math/tumble.mjs +94 -0
  511. package/engine-mcp/package.json +1 -0
  512. package/engine-mcp/server.mjs +151 -0
  513. package/engine-sdk/GUIDE.md +855 -0
  514. package/engine-sdk/assets.js +92 -0
  515. package/engine-sdk/autoplay.js +82 -0
  516. package/engine-sdk/bet-field.js +391 -0
  517. package/engine-sdk/kit/fiat-coins.js +51 -0
  518. package/engine-sdk/kit/numeric-input.js +116 -0
  519. package/engine-sdk/kit/wallet-format.js +423 -0
  520. package/engine-sdk/marks.js +22 -0
  521. package/engine-sdk/messages.js +289 -0
  522. package/engine-sdk/money.js +89 -0
  523. package/engine-sdk/package.json +1 -0
  524. package/engine-sdk/protocol.js +263 -0
  525. package/engine-sdk/sdk.d.ts +138 -0
  526. package/engine-sdk/sdk.js +330 -0
  527. package/engine-sdk/stake.js +68 -0
  528. package/engine-sdk/ui.d.ts +36 -0
  529. package/engine-sdk/ui.js +340 -0
  530. package/engine-shell/core/faults.js +127 -0
  531. package/engine-shell/core/rgs.js +193 -0
  532. package/engine-shell/core/router.js +917 -0
  533. package/engine-shell/host/boot.js +408 -0
  534. package/engine-shell/host/engine-v1.js +438 -0
  535. package/engine-shell/host/fair.js +285 -0
  536. package/engine-shell/host/fonts/SOURCE.json +11 -0
  537. package/engine-shell/host/fonts/figtree-400.woff2 +0 -0
  538. package/engine-shell/host/fonts/figtree-500.woff2 +0 -0
  539. package/engine-shell/host/fonts/figtree-600.woff2 +0 -0
  540. package/engine-shell/host/fonts/red-hat-display-700.woff2 +0 -0
  541. package/engine-shell/host/index.html +230 -0
  542. package/engine-shell/host/page.js +809 -0
  543. package/engine-shell/host/sandbox-panel.js +681 -0
  544. package/engine-shell/host/sandbox-scenarios.js +554 -0
  545. package/engine-shell/host/site-bridge.js +87 -0
  546. package/engine-shell/host/strings.js +916 -0
  547. package/engine-shell/package.json +1 -0
  548. package/engine-shell/verifiers/badcat/coin.tsv +2 -0
  549. package/engine-shell/verifiers/badcat/dice-2.tsv +11 -0
  550. package/engine-shell/verifiers/badcat/dice-5.tsv +26 -0
  551. package/engine-shell/verifiers/badcat/dice-9.tsv +46 -0
  552. package/engine-shell/verifiers/badcat/manifest.json +3344 -0
  553. package/engine-shell/verifiers/badcat/rps.tsv +3 -0
  554. package/engine-shell/verifiers/badcat/triple.tsv +66 -0
  555. package/engine-shell/verifiers/engine-verify.js +358 -0
  556. package/engine-shell/verifiers/package.json +5 -0
  557. package/engine-shell/verifiers/parity/manifest.json +27 -0
  558. package/engine-shell/verifiers/parity/wide.tsv +5 -0
  559. package/engine-shell/verifiers/parity-vectors.json +244 -0
  560. package/engine-shell/verifiers/vectors.json +100 -0
  561. 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}&currency=1&currencyPayload=${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);