@zaisma/game-server 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/README.md +2 -0
  2. package/bench/brand/.assetsignore +4 -0
  3. package/bench/brand/README.md +51 -0
  4. package/bench/brand/_headers +13 -0
  5. package/bench/brand/app-icon-1024.png +0 -0
  6. package/bench/brand/app-icon-192.png +0 -0
  7. package/bench/brand/app-icon-512.png +0 -0
  8. package/bench/brand/apple-touch-icon.png +0 -0
  9. package/bench/brand/cover-guide/coin-cascade-busy-fine.webp +0 -0
  10. package/bench/brand/cover-guide/gem-grid-cover.webp +0 -0
  11. package/bench/brand/cover-guide/gem-grid-screenshot.webp +0 -0
  12. package/bench/brand/cover-guide/good-arena.webp +0 -0
  13. package/bench/brand/cover-guide/good-four-in-a-row.webp +0 -0
  14. package/bench/brand/cover-guide/lava-leap-extra-text.webp +0 -0
  15. package/bench/brand/cover-guide/lava-leap-fake-reward.webp +0 -0
  16. package/bench/brand/cover-guide/lava-leap-fine.webp +0 -0
  17. package/bench/brand/cover-guide/lava-leap-overlay.webp +0 -0
  18. package/bench/brand/cover-guide/screenshot-fine.webp +0 -0
  19. package/bench/brand/cover-guide/screenshot-not-fine.webp +0 -0
  20. package/bench/brand/design/covers/ashen-crown.webp +0 -0
  21. package/bench/brand/design/covers/frostline.webp +0 -0
  22. package/bench/brand/design/covers/glasswing.webp +0 -0
  23. package/bench/brand/design/covers/hollow-tide.webp +0 -0
  24. package/bench/brand/design/covers/iron-vigil.webp +0 -0
  25. package/bench/brand/design/covers/kestrel.webp +0 -0
  26. package/bench/brand/design/covers/neon-drift.webp +0 -0
  27. package/bench/brand/design/covers/verdant.webp +0 -0
  28. package/bench/brand/favicon-32.png +0 -0
  29. package/bench/brand/favicon.ico +0 -0
  30. package/bench/brand/favicon.svg +1 -0
  31. package/bench/brand/flags/LICENSE +21 -0
  32. package/bench/brand/flags/ad.png +0 -0
  33. package/bench/brand/flags/ae.png +0 -0
  34. package/bench/brand/flags/af.png +0 -0
  35. package/bench/brand/flags/ag.png +0 -0
  36. package/bench/brand/flags/ai.png +0 -0
  37. package/bench/brand/flags/al.png +0 -0
  38. package/bench/brand/flags/am.png +0 -0
  39. package/bench/brand/flags/ao.png +0 -0
  40. package/bench/brand/flags/aq.png +0 -0
  41. package/bench/brand/flags/ar.png +0 -0
  42. package/bench/brand/flags/as.png +0 -0
  43. package/bench/brand/flags/at.png +0 -0
  44. package/bench/brand/flags/au.png +0 -0
  45. package/bench/brand/flags/aw.png +0 -0
  46. package/bench/brand/flags/ax.png +0 -0
  47. package/bench/brand/flags/az.png +0 -0
  48. package/bench/brand/flags/ba.png +0 -0
  49. package/bench/brand/flags/bb.png +0 -0
  50. package/bench/brand/flags/bd.png +0 -0
  51. package/bench/brand/flags/be.png +0 -0
  52. package/bench/brand/flags/bf.png +0 -0
  53. package/bench/brand/flags/bg.png +0 -0
  54. package/bench/brand/flags/bh.png +0 -0
  55. package/bench/brand/flags/bi.png +0 -0
  56. package/bench/brand/flags/bj.png +0 -0
  57. package/bench/brand/flags/bl.png +0 -0
  58. package/bench/brand/flags/bm.png +0 -0
  59. package/bench/brand/flags/bn.png +0 -0
  60. package/bench/brand/flags/bo.png +0 -0
  61. package/bench/brand/flags/bq.png +0 -0
  62. package/bench/brand/flags/br.png +0 -0
  63. package/bench/brand/flags/bs.png +0 -0
  64. package/bench/brand/flags/bt.png +0 -0
  65. package/bench/brand/flags/bv.png +0 -0
  66. package/bench/brand/flags/bw.png +0 -0
  67. package/bench/brand/flags/by.png +0 -0
  68. package/bench/brand/flags/bz.png +0 -0
  69. package/bench/brand/flags/ca.png +0 -0
  70. package/bench/brand/flags/cc.png +0 -0
  71. package/bench/brand/flags/cd.png +0 -0
  72. package/bench/brand/flags/cf.png +0 -0
  73. package/bench/brand/flags/cg.png +0 -0
  74. package/bench/brand/flags/ch.png +0 -0
  75. package/bench/brand/flags/ci.png +0 -0
  76. package/bench/brand/flags/ck.png +0 -0
  77. package/bench/brand/flags/cl.png +0 -0
  78. package/bench/brand/flags/cm.png +0 -0
  79. package/bench/brand/flags/cn.png +0 -0
  80. package/bench/brand/flags/co.png +0 -0
  81. package/bench/brand/flags/cp.png +0 -0
  82. package/bench/brand/flags/cr.png +0 -0
  83. package/bench/brand/flags/cu.png +0 -0
  84. package/bench/brand/flags/cv.png +0 -0
  85. package/bench/brand/flags/cw.png +0 -0
  86. package/bench/brand/flags/cx.png +0 -0
  87. package/bench/brand/flags/cy.png +0 -0
  88. package/bench/brand/flags/cz.png +0 -0
  89. package/bench/brand/flags/de.png +0 -0
  90. package/bench/brand/flags/dg.png +0 -0
  91. package/bench/brand/flags/dj.png +0 -0
  92. package/bench/brand/flags/dk.png +0 -0
  93. package/bench/brand/flags/dm.png +0 -0
  94. package/bench/brand/flags/do.png +0 -0
  95. package/bench/brand/flags/dz.png +0 -0
  96. package/bench/brand/flags/ec.png +0 -0
  97. package/bench/brand/flags/ee.png +0 -0
  98. package/bench/brand/flags/eg.png +0 -0
  99. package/bench/brand/flags/eh.png +0 -0
  100. package/bench/brand/flags/er.png +0 -0
  101. package/bench/brand/flags/es.png +0 -0
  102. package/bench/brand/flags/et.png +0 -0
  103. package/bench/brand/flags/eu.png +0 -0
  104. package/bench/brand/flags/fi.png +0 -0
  105. package/bench/brand/flags/fj.png +0 -0
  106. package/bench/brand/flags/fk.png +0 -0
  107. package/bench/brand/flags/fm.png +0 -0
  108. package/bench/brand/flags/fo.png +0 -0
  109. package/bench/brand/flags/fr.png +0 -0
  110. package/bench/brand/flags/ga.png +0 -0
  111. package/bench/brand/flags/gb.png +0 -0
  112. package/bench/brand/flags/gd.png +0 -0
  113. package/bench/brand/flags/ge.png +0 -0
  114. package/bench/brand/flags/gf.png +0 -0
  115. package/bench/brand/flags/gg.png +0 -0
  116. package/bench/brand/flags/gh.png +0 -0
  117. package/bench/brand/flags/gi.png +0 -0
  118. package/bench/brand/flags/gl.png +0 -0
  119. package/bench/brand/flags/gm.png +0 -0
  120. package/bench/brand/flags/gn.png +0 -0
  121. package/bench/brand/flags/gp.png +0 -0
  122. package/bench/brand/flags/gq.png +0 -0
  123. package/bench/brand/flags/gr.png +0 -0
  124. package/bench/brand/flags/gs.png +0 -0
  125. package/bench/brand/flags/gt.png +0 -0
  126. package/bench/brand/flags/gu.png +0 -0
  127. package/bench/brand/flags/gw.png +0 -0
  128. package/bench/brand/flags/gy.png +0 -0
  129. package/bench/brand/flags/hk.png +0 -0
  130. package/bench/brand/flags/hm.png +0 -0
  131. package/bench/brand/flags/hn.png +0 -0
  132. package/bench/brand/flags/hr.png +0 -0
  133. package/bench/brand/flags/ht.png +0 -0
  134. package/bench/brand/flags/hu.png +0 -0
  135. package/bench/brand/flags/ic.png +0 -0
  136. package/bench/brand/flags/id.png +0 -0
  137. package/bench/brand/flags/ie.png +0 -0
  138. package/bench/brand/flags/il.png +0 -0
  139. package/bench/brand/flags/im.png +0 -0
  140. package/bench/brand/flags/in.png +0 -0
  141. package/bench/brand/flags/io.png +0 -0
  142. package/bench/brand/flags/iq.png +0 -0
  143. package/bench/brand/flags/ir.png +0 -0
  144. package/bench/brand/flags/is.png +0 -0
  145. package/bench/brand/flags/it.png +0 -0
  146. package/bench/brand/flags/je.png +0 -0
  147. package/bench/brand/flags/jm.png +0 -0
  148. package/bench/brand/flags/jo.png +0 -0
  149. package/bench/brand/flags/jp.png +0 -0
  150. package/bench/brand/flags/ke.png +0 -0
  151. package/bench/brand/flags/kg.png +0 -0
  152. package/bench/brand/flags/kh.png +0 -0
  153. package/bench/brand/flags/ki.png +0 -0
  154. package/bench/brand/flags/km.png +0 -0
  155. package/bench/brand/flags/kn.png +0 -0
  156. package/bench/brand/flags/kp.png +0 -0
  157. package/bench/brand/flags/kr.png +0 -0
  158. package/bench/brand/flags/kw.png +0 -0
  159. package/bench/brand/flags/ky.png +0 -0
  160. package/bench/brand/flags/kz.png +0 -0
  161. package/bench/brand/flags/la.png +0 -0
  162. package/bench/brand/flags/lb.png +0 -0
  163. package/bench/brand/flags/lc.png +0 -0
  164. package/bench/brand/flags/li.png +0 -0
  165. package/bench/brand/flags/lk.png +0 -0
  166. package/bench/brand/flags/lr.png +0 -0
  167. package/bench/brand/flags/ls.png +0 -0
  168. package/bench/brand/flags/lt.png +0 -0
  169. package/bench/brand/flags/lu.png +0 -0
  170. package/bench/brand/flags/lv.png +0 -0
  171. package/bench/brand/flags/ly.png +0 -0
  172. package/bench/brand/flags/ma.png +0 -0
  173. package/bench/brand/flags/mc.png +0 -0
  174. package/bench/brand/flags/md.png +0 -0
  175. package/bench/brand/flags/me.png +0 -0
  176. package/bench/brand/flags/mf.png +0 -0
  177. package/bench/brand/flags/mg.png +0 -0
  178. package/bench/brand/flags/mh.png +0 -0
  179. package/bench/brand/flags/mk.png +0 -0
  180. package/bench/brand/flags/ml.png +0 -0
  181. package/bench/brand/flags/mm.png +0 -0
  182. package/bench/brand/flags/mn.png +0 -0
  183. package/bench/brand/flags/mo.png +0 -0
  184. package/bench/brand/flags/mp.png +0 -0
  185. package/bench/brand/flags/mq.png +0 -0
  186. package/bench/brand/flags/mr.png +0 -0
  187. package/bench/brand/flags/ms.png +0 -0
  188. package/bench/brand/flags/mt.png +0 -0
  189. package/bench/brand/flags/mu.png +0 -0
  190. package/bench/brand/flags/mv.png +0 -0
  191. package/bench/brand/flags/mw.png +0 -0
  192. package/bench/brand/flags/mx.png +0 -0
  193. package/bench/brand/flags/my.png +0 -0
  194. package/bench/brand/flags/mz.png +0 -0
  195. package/bench/brand/flags/na.png +0 -0
  196. package/bench/brand/flags/nc.png +0 -0
  197. package/bench/brand/flags/ne.png +0 -0
  198. package/bench/brand/flags/nf.png +0 -0
  199. package/bench/brand/flags/ng.png +0 -0
  200. package/bench/brand/flags/ni.png +0 -0
  201. package/bench/brand/flags/nl.png +0 -0
  202. package/bench/brand/flags/no.png +0 -0
  203. package/bench/brand/flags/np.png +0 -0
  204. package/bench/brand/flags/nr.png +0 -0
  205. package/bench/brand/flags/nu.png +0 -0
  206. package/bench/brand/flags/nz.png +0 -0
  207. package/bench/brand/flags/om.png +0 -0
  208. package/bench/brand/flags/pa.png +0 -0
  209. package/bench/brand/flags/pc.png +0 -0
  210. package/bench/brand/flags/pe.png +0 -0
  211. package/bench/brand/flags/pf.png +0 -0
  212. package/bench/brand/flags/pg.png +0 -0
  213. package/bench/brand/flags/ph.png +0 -0
  214. package/bench/brand/flags/pk.png +0 -0
  215. package/bench/brand/flags/pl.png +0 -0
  216. package/bench/brand/flags/pm.png +0 -0
  217. package/bench/brand/flags/pn.png +0 -0
  218. package/bench/brand/flags/pr.png +0 -0
  219. package/bench/brand/flags/ps.png +0 -0
  220. package/bench/brand/flags/pt.png +0 -0
  221. package/bench/brand/flags/pw.png +0 -0
  222. package/bench/brand/flags/py.png +0 -0
  223. package/bench/brand/flags/qa.png +0 -0
  224. package/bench/brand/flags/re.png +0 -0
  225. package/bench/brand/flags/ro.png +0 -0
  226. package/bench/brand/flags/rs.png +0 -0
  227. package/bench/brand/flags/ru.png +0 -0
  228. package/bench/brand/flags/rw.png +0 -0
  229. package/bench/brand/flags/sa.png +0 -0
  230. package/bench/brand/flags/sb.png +0 -0
  231. package/bench/brand/flags/sc.png +0 -0
  232. package/bench/brand/flags/sd.png +0 -0
  233. package/bench/brand/flags/se.png +0 -0
  234. package/bench/brand/flags/sg.png +0 -0
  235. package/bench/brand/flags/sh.png +0 -0
  236. package/bench/brand/flags/si.png +0 -0
  237. package/bench/brand/flags/sj.png +0 -0
  238. package/bench/brand/flags/sk.png +0 -0
  239. package/bench/brand/flags/sl.png +0 -0
  240. package/bench/brand/flags/sm.png +0 -0
  241. package/bench/brand/flags/sn.png +0 -0
  242. package/bench/brand/flags/so.png +0 -0
  243. package/bench/brand/flags/sr.png +0 -0
  244. package/bench/brand/flags/ss.png +0 -0
  245. package/bench/brand/flags/st.png +0 -0
  246. package/bench/brand/flags/sv.png +0 -0
  247. package/bench/brand/flags/sx.png +0 -0
  248. package/bench/brand/flags/sy.png +0 -0
  249. package/bench/brand/flags/sz.png +0 -0
  250. package/bench/brand/flags/tc.png +0 -0
  251. package/bench/brand/flags/td.png +0 -0
  252. package/bench/brand/flags/tf.png +0 -0
  253. package/bench/brand/flags/tg.png +0 -0
  254. package/bench/brand/flags/th.png +0 -0
  255. package/bench/brand/flags/tj.png +0 -0
  256. package/bench/brand/flags/tk.png +0 -0
  257. package/bench/brand/flags/tl.png +0 -0
  258. package/bench/brand/flags/tm.png +0 -0
  259. package/bench/brand/flags/tn.png +0 -0
  260. package/bench/brand/flags/to.png +0 -0
  261. package/bench/brand/flags/tr.png +0 -0
  262. package/bench/brand/flags/tt.png +0 -0
  263. package/bench/brand/flags/tv.png +0 -0
  264. package/bench/brand/flags/tw.png +0 -0
  265. package/bench/brand/flags/tz.png +0 -0
  266. package/bench/brand/flags/ua.png +0 -0
  267. package/bench/brand/flags/ug.png +0 -0
  268. package/bench/brand/flags/um.png +0 -0
  269. package/bench/brand/flags/un.png +0 -0
  270. package/bench/brand/flags/us.png +0 -0
  271. package/bench/brand/flags/uy.png +0 -0
  272. package/bench/brand/flags/uz.png +0 -0
  273. package/bench/brand/flags/va.png +0 -0
  274. package/bench/brand/flags/vc.png +0 -0
  275. package/bench/brand/flags/ve.png +0 -0
  276. package/bench/brand/flags/vg.png +0 -0
  277. package/bench/brand/flags/vi.png +0 -0
  278. package/bench/brand/flags/vn.png +0 -0
  279. package/bench/brand/flags/vu.png +0 -0
  280. package/bench/brand/flags/wf.png +0 -0
  281. package/bench/brand/flags/ws.png +0 -0
  282. package/bench/brand/flags/xk.png +0 -0
  283. package/bench/brand/flags/xx.png +0 -0
  284. package/bench/brand/flags/ye.png +0 -0
  285. package/bench/brand/flags/yt.png +0 -0
  286. package/bench/brand/flags/za.png +0 -0
  287. package/bench/brand/flags/zm.png +0 -0
  288. package/bench/brand/flags/zw.png +0 -0
  289. package/bench/brand/fonts/OFL.txt +93 -0
  290. package/bench/brand/fonts/jost.7726a5cd.woff2 +0 -0
  291. package/bench/brand/loader/gif/zaisma-loader-black-128.gif +0 -0
  292. package/bench/brand/loader/gif/zaisma-loader-black-512.gif +0 -0
  293. package/bench/brand/loader/gif/zaisma-loader-white-128.gif +0 -0
  294. package/bench/brand/loader/gif/zaisma-loader-white-512.gif +0 -0
  295. package/bench/brand/loader/png/zaisma-loader-black-128.png +0 -0
  296. package/bench/brand/loader/png/zaisma-loader-black-512.png +0 -0
  297. package/bench/brand/loader/png/zaisma-loader-white-128.png +0 -0
  298. package/bench/brand/loader/png/zaisma-loader-white-512.png +0 -0
  299. package/bench/brand/loader/webp/zaisma-loader-black-128.webp +0 -0
  300. package/bench/brand/loader/webp/zaisma-loader-black-512.webp +0 -0
  301. package/bench/brand/loader/webp/zaisma-loader-white-128.webp +0 -0
  302. package/bench/brand/loader/webp/zaisma-loader-white-512.webp +0 -0
  303. package/bench/brand/loader/zaisma-loader-black.svg +1 -0
  304. package/bench/brand/loader/zaisma-loader-white.svg +1 -0
  305. package/bench/brand/loader/zaisma-loader.svg +1 -0
  306. package/bench/brand/sign-in/city-day-phone.webp +0 -0
  307. package/bench/brand/sign-in/city-day.webp +0 -0
  308. package/bench/brand/sign-in/city-night-phone.webp +0 -0
  309. package/bench/brand/sign-in/city-night.webp +0 -0
  310. package/bench/brand/zaisma-app-icon.svg +1 -0
  311. package/bench/brand/zaisma-mark-black-2048.png +0 -0
  312. package/bench/brand/zaisma-mark-black.svg +1 -0
  313. package/bench/brand/zaisma-mark-metal-2048.png +0 -0
  314. package/bench/brand/zaisma-mark-metal.svg +1 -0
  315. package/bench/brand/zaisma-mark-small-black.svg +1 -0
  316. package/bench/brand/zaisma-mark-small-white.svg +1 -0
  317. package/bench/brand/zaisma-mark-tricolor.svg +1 -0
  318. package/bench/brand/zaisma-mark-white-2048.png +0 -0
  319. package/bench/brand/zaisma-mark-white.svg +1 -0
  320. package/bench/brand/zaisma-wordmark-black-2400.png +0 -0
  321. package/bench/brand/zaisma-wordmark-black.svg +1 -0
  322. package/bench/brand/zaisma-wordmark-metal-2400.png +0 -0
  323. package/bench/brand/zaisma-wordmark-metal.svg +1 -0
  324. package/bench/brand/zaisma-wordmark-white-2400.png +0 -0
  325. package/bench/brand/zaisma-wordmark-white.svg +1 -0
  326. package/bench/glass.js +46466 -0
  327. package/bin/local-play-bench.d.ts +8 -0
  328. package/bin/local-play-bench.js +218 -0
  329. package/bin/local-play-certificate.js +99 -0
  330. package/bin/local-play-runner.js +43 -0
  331. package/bin/local-play.d.ts +41 -0
  332. package/bin/local-play.js +1567 -0
  333. package/bin/node.js +71 -5
  334. package/package.json +1 -1
  335. package/sdk/picture.js +97 -0
  336. package/sdk/relay.js +269 -0
  337. package/sdk/saves.js +884 -0
  338. package/sdk/zaisma.d.ts +1720 -0
  339. package/sdk/zaisma.js +2374 -0
  340. package/src/qr-code.d.ts +20 -0
  341. package/src/qr-code.js +319 -0
  342. package/src/room.js +1 -1
@@ -0,0 +1,1567 @@
1
+ // Play your multiplayer game on your own computer: your game and its game server, with stand-ins for what
2
+ // only Zaisma does, so two browser tabs play together with no account, key or internet.
3
+ //
4
+ // zaisma-game-server play-locally [folder] [--port 4800] [--game <slug>] [--on-network]
5
+ //
6
+ // It starts four things on consecutive ports from --port: the test bench (4800), whose dev page has the bench's own bar
7
+ // above a frame of the player's page, zaisma.com's game page drawn by the site's own code, with the bench answering its
8
+ // calls; the game's site (4801), your folder served as Zaisma serves it, with the SDK set up, framed by the player's
9
+ // page; the stand-in rooms (4802), which answers the calls the SDK makes to Zaisma's rooms and signs real player passes
10
+ // and receipts with keys made on the first run; and your game server (4803), the Node runner, as its own process.
11
+ // Different ports are different origins, so the game is on another origin from its page, as on Zaisma.
12
+ // Everything it keeps is in <folder>/.zaisma-local/. Nothing here changes the game server: it checks the
13
+ // stand-in passes the way it checks Zaisma's, against the keys the stand-in rooms publishes.
14
+ import { createServer, request as httpRequest } from "node:http";
15
+ import { createServer as createSecureServer } from "node:https";
16
+ import { createServer as createSocketServer } from "node:net";
17
+ import { hostname as computerName } from "node:os";
18
+ import { spawn } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
21
+ import { pathToFileURL } from "node:url";
22
+ import { networkInterfaces } from "node:os";
23
+ import { basename, extname, isAbsolute, join, relative as relativePath, resolve, sep } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { WebSocket, WebSocketServer } from "ws";
26
+ import { BUSY_CLOSE_CODE, GameServerError, PASS_VERSION_NUMBER, RECEIPT_TOKEN_TYPE, RECEIPT_VERSION_NUMBER, generateSigningKey, guestName, jwks, loadSigningKey, roomCode, signPass, verifyPass } from "../src/internal.js";
27
+ import { qrCodeModules, qrCodeSvg } from "../src/qr-code.js";
28
+ import { ARRIVE_SCRIPT_SOURCE, FRAME_ALLOW, KEPT_SCRIPT_SOURCE, devScriptSource } from "./local-play-bench.js";
29
+ import { isCertificateGood, makeCertificate } from "./local-play-certificate.js";
30
+
31
+ /** The first of the four ports. */
32
+ export const DEFAULT_PORT_NUMBER = 4800;
33
+ /** The version the SDK's setup names. */
34
+ export const LOCAL_VERSION_ID = "version_local";
35
+ /** The folder in the game's folder that holds what the bench keeps. */
36
+ export const LOCAL_FOLDER = ".zaisma-local";
37
+ /** Every _zaisma/ file the games Worker serves, split by whether it works here or only loads and does nothing (test/node/local-play-parity.test.ts holds this to apps/games/src/index.ts). */
38
+ export const SERVED_ZAISMA_PATHS = ["_zaisma/v1.js", "_zaisma/v1.d.ts", "_zaisma/relay.js", "_zaisma/picture.js", "_zaisma/saves.js", "_zaisma/restore"];
39
+ export const ONLY_ON_ZAISMA_PATHS = ["_zaisma/performance.js", "_zaisma/warm", "_zaisma/warm.json", "_zaisma/warm.js"];
40
+ const OPTION_NAMES = ["gameFolder", "port", "isOnNetwork", "gameSlug", "zaismaKey", "developerApiUrl", "log"];
41
+ const SAVE_LIMIT_BYTES = 5 * 1024 * 1024;
42
+ const PASS_DURATION_SEC = 24 * 3600;
43
+ const SCRIPTS_VERSION_NUMBER = 7;
44
+ const SLOW_RESTART_MS = 150;
45
+ const RUNNER_START_TIMEOUT_MS = 15_000;
46
+ const LOCAL_RUNNER_KEY = "zaisma-local-key";
47
+ const TYPES = {
48
+ ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
49
+ ".css": "text/css; charset=utf-8", ".json": "application/json", ".map": "application/json", ".txt": "text/plain; charset=utf-8", ".svg": "image/svg+xml",
50
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".ico": "image/x-icon",
51
+ ".wasm": "application/wasm", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".mp4": "video/mp4", ".webm": "video/webm",
52
+ ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json",
53
+ };
54
+
55
+ const here = fileURLToPath(new URL(".", import.meta.url));
56
+
57
+ /**
58
+ * The bench's Glass (bench/glass.ts): the site's components, layout and buy sheet, bundled from apps/site's source. The published package
59
+ * carries the bundle (dist/bench/glass.js, made by scripts/build.mjs); in the repository it's bundled from the source as the bench starts,
60
+ * so it always draws the design as it is.
61
+ */
62
+ async function loadGlass(localFolder) {
63
+ const built = join(here, "../bench/glass.js");
64
+ if (existsSync(built)) return import(pathToFileURL(built).href);
65
+ const { buildBenchGlass } = await import(pathToFileURL(join(here, "../scripts/bench.mjs")).href);
66
+ const bundled = join(localFolder, "bench-glass.mjs");
67
+ await buildBenchGlass(bundled);
68
+ return import(`${pathToFileURL(bundled).href}?at=${Date.now()}`);
69
+ }
70
+
71
+ /** The brand folder the site serves its logos, favicons, loader and font from: beside the bundle in the package, at the top of the repository. */
72
+ const brandFolder = [join(here, "../bench/brand"), join(here, "../../../brand")].find((folder) => existsSync(folder)) ?? null;
73
+
74
+ /** A file of the brand folder by the path the site serves it at, or null; never its design sources or notes (brand/.assetsignore). */
75
+ function brandFile(path) {
76
+ if (!brandFolder) return null;
77
+ let decoded;
78
+ try {
79
+ decoded = decodeURIComponent(path).replace(/^\/+/, "");
80
+ } catch {
81
+ return null;
82
+ }
83
+ const file = resolve(brandFolder, decoded);
84
+ const inside = relativePath(brandFolder, file);
85
+ if (!decoded || inside.startsWith("..") || isAbsolute(inside) || /\.(afdesign|md)$/i.test(file) || !existsSync(file) || !statSync(file).isFile()) return null;
86
+ return file;
87
+ }
88
+
89
+ /** The SDK's served files: in the repository next to the package, in the published package beside bin/. */
90
+ function sdkText(name) {
91
+ for (const folder of [join(here, "../../sdk"), join(here, "../sdk")]) if (existsSync(join(folder, name))) return readFileSync(join(folder, name), "utf8");
92
+ throw new GameServerError("missing_sdk_file", "sdk", `${name} is missing from this install of @zaisma/game-server.`, "Install it again: npx --yes @zaisma/game-server@1.");
93
+ }
94
+
95
+ /** A script with its setup handed in as a parameter, as the games Worker does, so the setup is never a global the game can read. */
96
+ function withSetup(parameterName, setup, script) {
97
+ return `(function (${parameterName}) {\n${script}\n})(${JSON.stringify(setup)});\n`;
98
+ }
99
+
100
+ /** Put scripts first in <head>, before any game script runs (apps/games/src/index.ts, withScripts). */
101
+ export function withScripts(page, scriptUrls) {
102
+ const tag = scriptUrls.map((scriptUrl) => `<script src="${scriptUrl}"></script>`).join("");
103
+ const head = page.match(/<head(\s[^>]*)?>/i);
104
+ if (head) return page.slice(0, head.index + head[0].length) + tag + page.slice(head.index + head[0].length);
105
+ const html = page.match(/<html(\s[^>]*)?>/i);
106
+ if (html) return page.slice(0, html.index + html[0].length) + tag + page.slice(html.index + html[0].length);
107
+ const doctype = page.match(/^\s*<!doctype[^>]*>/i);
108
+ return doctype ? doctype[0] + tag + page.slice(doctype[0].length) : tag + page;
109
+ }
110
+
111
+ /** The headers every file of the game's site carries: the games Worker's (packages/core/src/headers.ts), with the connect sources set to the local origins. */
112
+ export function localGameHeaders(contentType, benchOrigin, connectOrigins) {
113
+ return {
114
+ "Content-Type": contentType,
115
+ "Content-Security-Policy": [
116
+ "default-src 'self' data: blob:",
117
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
118
+ "style-src 'self' 'unsafe-inline'",
119
+ ["connect-src 'self' data: blob:", ...connectOrigins].join(" "),
120
+ "worker-src 'self' blob:",
121
+ "frame-src 'self'",
122
+ "form-action 'none'",
123
+ "base-uri 'self'",
124
+ "object-src 'none'",
125
+ `frame-ancestors ${benchOrigin}`,
126
+ ].join("; "),
127
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), hid=(), display-capture=(), fullscreen=(self), gamepad=(self), autoplay=(self)",
128
+ "X-Content-Type-Options": "nosniff",
129
+ "Referrer-Policy": "no-referrer",
130
+ "Cross-Origin-Opener-Policy": "same-origin",
131
+ "Document-Isolation-Policy": "isolate-and-credentialless",
132
+ };
133
+ }
134
+
135
+ const randomText = (characterCount) => Array.from(crypto.getRandomValues(new Uint8Array(characterCount)), (byte) => "abcdefghijklmnopqrstuvwxyz0123456789"[byte % 36]).join("");
136
+ const now = () => Date.now();
137
+ const isoOf = (ms) => new Date(ms).toISOString();
138
+
139
+ /** A fresh bench: no players yet. Accounts are made as players sign in, and kept with their friends, blocks and items. */
140
+ function defaultState() {
141
+ return {
142
+ accounts: [],
143
+ // Who each player is: their number to "guest" or an account's id.
144
+ tabs: {},
145
+ // Each player's guest, while they play as one: { id, name }.
146
+ guests: {},
147
+ friends: [],
148
+ blocks: [],
149
+ ownedItems: {},
150
+ items: [],
151
+ purchases: [],
152
+ purchaseDelivery: "game",
153
+ isItemsCopied: false,
154
+ itemsCopiedAt: null,
155
+ };
156
+ }
157
+
158
+ /** A pretty price like $1.99, in the currency's own way. */
159
+ function priceText(currencyCode, minorUnits) {
160
+ try {
161
+ const format = new Intl.NumberFormat("en", { style: "currency", currency: String(currencyCode).toUpperCase() });
162
+ const fractionDigitCount = format.resolvedOptions().maximumFractionDigits;
163
+ return format.format(minorUnits / 10 ** fractionDigitCount);
164
+ } catch {
165
+ return `${minorUnits} ${currencyCode}`;
166
+ }
167
+ }
168
+
169
+ /** The game server file in the folder, as uploads find it, or null for the relay. */
170
+ export function serverEntryOf(gameFolder) {
171
+ for (const entry of ["zaisma-server/index.js", "zaisma-server.js"]) if (existsSync(join(gameFolder, entry))) return join(gameFolder, entry);
172
+ return null;
173
+ }
174
+
175
+ /** Whether a path in the folder is the game server's, in any case: macOS and Windows find zaisma-server.js as ZAISMA-SERVER.JS too. */
176
+ const isServerFile = (relativePath) => {
177
+ const lowered = relativePath.toLowerCase();
178
+ return lowered === "zaisma-server.js" || lowered.startsWith("zaisma-server/");
179
+ };
180
+
181
+ function sourceOf(gameFolder) {
182
+ const found = [];
183
+ const walk = (folder) => {
184
+ for (const entry of readdirSync(folder, { withFileTypes: true })) {
185
+ const path = join(folder, entry.name);
186
+ if (entry.isDirectory()) walk(path);
187
+ else found.push(path);
188
+ }
189
+ };
190
+ const folder = join(gameFolder, "zaisma-server");
191
+ if (existsSync(folder)) walk(folder);
192
+ if (existsSync(join(gameFolder, "zaisma-server.js"))) found.push(join(gameFolder, "zaisma-server.js"));
193
+ return found.map((path) => readFileSync(path, "utf8")).join("\n");
194
+ }
195
+
196
+ /** The first non-internal IPv4 addresses of this computer, for a phone on the same Wi-Fi. */
197
+ function networkAddresses() {
198
+ const found = [];
199
+ for (const list of Object.values(networkInterfaces())) for (const each of list ?? []) if (each.family === "IPv4" && !each.internal) found.push(each.address);
200
+ return found;
201
+ }
202
+
203
+ function checkedOptions(options) {
204
+ const fail = (code, field, message, fix) => new GameServerError(code, field, message, fix);
205
+ if (options === null || typeof options !== "object" || Array.isArray(options)) throw fail("bad_option", "options", "startLocalPlay takes one object of options.", `Call startLocalPlay({ gameFolder }), with any of: ${OPTION_NAMES.join(", ")}.`);
206
+ for (const field of Object.keys(options)) if (!OPTION_NAMES.includes(field)) throw fail("unknown_option", field, `${field} isn't a startLocalPlay option.`, `startLocalPlay takes: ${OPTION_NAMES.join(", ")}.`);
207
+ const { gameFolder, port = DEFAULT_PORT_NUMBER, isOnNetwork = false, gameSlug = null, zaismaKey = process.env.ZAISMA_KEY ?? "", developerApiUrl = (process.env.ZAISMA_API || "https://zaisma.com").replace(/\/+$/, "") + "/api/dev", log = (line) => console.log(line) } = options;
208
+ if (typeof gameFolder !== "string" || !gameFolder) throw fail("bad_option", "gameFolder", "gameFolder is missing.", "Give the folder of your game, the one with index.html at the top.");
209
+ if (!Number.isInteger(port) || port < 1 || port > 65532) throw fail("bad_option", "port", "port isn't a whole number from 1 to 65532.", "Give the first of four ports, like 4800: the bench, the game's site, the stand-in rooms and your game server take it and the next three.");
210
+ if (typeof isOnNetwork !== "boolean") throw fail("bad_option", "isOnNetwork", "isOnNetwork isn't true or false.", "Give true to let a phone on the same Wi-Fi in, or leave it out.");
211
+ if (gameSlug !== null && !(typeof gameSlug === "string" && /^[a-z0-9-]{1,40}$/.test(gameSlug))) throw fail("bad_option", "gameSlug", "gameSlug isn't a game's slug.", "A slug is 1 to 40 lowercase letters, digits or hyphens, like sky-hopper.");
212
+ if (typeof log !== "function") throw fail("bad_option", "log", "log isn't a function.", "Give a function that takes one line of text, or leave it out to print to the terminal.");
213
+ return { gameFolder: resolve(gameFolder), port, isOnNetwork, gameSlug, zaismaKey, developerApiUrl, log };
214
+ }
215
+
216
+ /**
217
+ * Start the bench for a game folder. Options (all closed, as startServer's): gameFolder (required), port
218
+ * (4800: the first of four), isOnNetwork (false: localhost only), gameSlug (by default the folder's name),
219
+ * zaismaKey (an API key; with gameSlug, each start brings in the game's items from Zaisma; by default the
220
+ * ZAISMA_KEY environment variable), developerApiUrl (where that copy is read from) and log (a function for lines
221
+ * of text; by default the terminal). Answers { benchUrl, gameSiteUrl, roomsUrl, networkUrl, runnerStartCount,
222
+ * close() }: close() saves every room first and finishes once everything has stopped.
223
+ */
224
+ export async function startLocalPlay(givenOptions) {
225
+ const { gameFolder, port, isOnNetwork, gameSlug: givenSlug, zaismaKey, developerApiUrl, log: printLine } = checkedOptions(givenOptions);
226
+ // Everything the bench says goes to the terminal, and the last of it to the dev page's Log.
227
+ const logLines = [];
228
+ const log = (line) => {
229
+ logLines.push(line);
230
+ if (logLines.length > 200) logLines.shift();
231
+ printLine(line);
232
+ };
233
+ if (!existsSync(gameFolder) || !statSync(gameFolder).isDirectory()) throw new GameServerError("bad_option", "gameFolder", `${gameFolder} isn't a folder.`, "Give the folder of your game, the one with index.html at the top.");
234
+ const gameSlug = givenSlug ?? (basename(gameFolder).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "my-game");
235
+ // The game page's title: the one the game's own page has, else its slug.
236
+ let gameTitle = gameSlug;
237
+ try {
238
+ gameTitle = /<title[^>]*>([^<]{1,80})<\/title>/i.exec(readFileSync(join(gameFolder, "index.html"), "utf8"))?.[1].trim() || gameSlug;
239
+ } catch {
240
+ // No index.html yet: the game's site says it's missing.
241
+ }
242
+ const ports = { bench: port, game: port + 1, rooms: port + 2, runner: port + 3 };
243
+ const host = isOnNetwork ? undefined : "127.0.0.1";
244
+ const localFolder = join(gameFolder, LOCAL_FOLDER);
245
+ mkdirSync(join(localFolder, "saves"), { recursive: true });
246
+
247
+ // --- The keys: one pair for passes, one for receipts, made on the first run ---
248
+ const keysFile = join(localFolder, "keys.json");
249
+ let keyTexts = existsSync(keysFile) ? JSON.parse(readFileSync(keysFile, "utf8")) : null;
250
+ if (!keyTexts?.pass || !keyTexts?.receipt) {
251
+ keyTexts = { pass: await generateSigningKey(), receipt: await generateSigningKey() };
252
+ writeFileSync(keysFile, JSON.stringify(keyTexts, null, 2));
253
+ }
254
+ const passSigning = await loadSigningKey(keyTexts.pass);
255
+ const receiptSigning = await loadSigningKey(keyTexts.receipt);
256
+
257
+ // --- The bench's state: who each tab is, friends, blocks, items. It survives a restart ---
258
+ const stateFile = join(localFolder, "bench.json");
259
+ const state = existsSync(stateFile) ? { ...defaultState(), ...JSON.parse(readFileSync(stateFile, "utf8")) } : defaultState();
260
+ const saveState = () => writeFileSync(stateFile, JSON.stringify(state, null, 2));
261
+ // Players made before they had an age and a social mode: grown-ups, playing as their chat setting said.
262
+ for (const account of state.accounts) {
263
+ if (!account.ageBand) account.ageBand = "18+";
264
+ if (!account.socialMode) account.socialMode = account.whoCanChat === "nobody" ? "solo_focused" : account.whoCanChat === "friends" ? "friends_focused" : "open_and_social";
265
+ delete account.whoCanChat;
266
+ }
267
+ if (!existsSync(stateFile)) saveState();
268
+ // The game's items are the ones it has on Zaisma, brought in at every start with --game and a key: an item the bench made up would
269
+ // be one the game's code has never heard of. Offline, the bench keeps the ones it brought in last.
270
+ if (givenSlug && zaismaKey) {
271
+ try {
272
+ const response = await fetch(`${developerApiUrl}/games/${givenSlug}/items`, { headers: { authorization: `Bearer ${zaismaKey}` } });
273
+ if (response.ok) {
274
+ const answered = await response.json();
275
+ state.items = Object.values(answered.items ?? {}).map((listedItem) => ({ itemName: listedItem.item_name, title: listedItem.title, description: listedItem.description ?? "", itemKind: listedItem.item_kind, itemCategory: listedItem.item_category, priceMinorUnits: listedItem.price_minor_units ?? null, currencyCode: listedItem.currency_code ?? null }));
276
+ const itemNames = new Set(state.items.map((listedItem) => listedItem.itemName));
277
+ for (const [accountId, owned] of Object.entries(state.ownedItems)) state.ownedItems[accountId] = owned.filter((itemName) => itemNames.has(itemName));
278
+ if (answered.purchase_delivery) state.purchaseDelivery = answered.purchase_delivery;
279
+ state.isItemsCopied = true;
280
+ state.itemsCopiedAt = isoOf(now());
281
+ saveState();
282
+ log(`Brought in ${state.items.length} item${state.items.length === 1 ? "" : "s"} from ${givenSlug} on Zaisma.`);
283
+ } else log(`Couldn't bring in ${givenSlug}'s items from Zaisma (${response.status}): ${state.items.length ? "the bench keeps the ones it brought in last" : "the game has none here"}. Check ZAISMA_KEY reaches the game.`);
284
+ } catch {
285
+ log(`Couldn't reach Zaisma for ${givenSlug}'s items: ${state.items.length ? "the bench keeps the ones it brought in last" : "the game has none here"}.`);
286
+ }
287
+ }
288
+ const glass = await loadGlass(localFolder);
289
+ const accountOf = (id) => state.accounts.find((account) => account.id === id) ?? null;
290
+ const accountOfPlayer = (playerId) => state.accounts.find((account) => account.playerId === playerId) ?? null;
291
+ const asOf = (playerNumber) => state.tabs[playerNumber] ?? "guest";
292
+ const isPair = (pair, first, second) => (pair[0] === first && pair[1] === second) || (pair[0] === second && pair[1] === first);
293
+
294
+ // --- Origins: the browser reaches them by whatever address it was given (localhost, or the computer's address on the network) ---
295
+ const localhostOrigin = (portNumber) => `http://localhost:${portNumber}`;
296
+ const issuerUrl = localhostOrigin(ports.rooms);
297
+ // Over the network the bench speaks HTTPS too (a phone's browser may insist), on the same ports: each origin is the scheme the request came by.
298
+ const schemeOf = (request) => (request.socket.encrypted ? "https" : "http");
299
+ const originFor = (request, portNumber) => `${schemeOf(request)}://${hostnameOf(request)}:${portNumber}`;
300
+ const hostnameOf = (request) => /^(\[[^\]]+\]|[^:]+)/.exec(request.headers.host ?? "localhost")?.[1] ?? "localhost";
301
+ const isGameOrigin = (origin) => typeof origin === "string" && URL.canParse(origin) && new URL(origin).port === String(ports.game) && ["http:", "https:"].includes(new URL(origin).protocol);
302
+ const addresses = isOnNetwork ? networkAddresses() : [];
303
+ const networkOrigin = addresses.length ? `https://${addresses[0]}:${ports.bench}` : null;
304
+
305
+ // The bench's own certificate, made on this computer for its names and addresses and kept until they change or it nears its end.
306
+ let certificate = null;
307
+ if (isOnNetwork) {
308
+ const certificateFile = join(localFolder, "certificate.json");
309
+ const names = [...new Set(["localhost", "127.0.0.1", "::1", computerName(), `${computerName().split(".")[0]}.local`, ...addresses])].filter((each) => /^[A-Za-z0-9.:-]+$/.test(each));
310
+ const kept = existsSync(certificateFile) ? JSON.parse(readFileSync(certificateFile, "utf8")) : null;
311
+ certificate = kept && isCertificateGood(kept, names) ? kept : makeCertificate(names);
312
+ if (certificate !== kept) writeFileSync(certificateFile, JSON.stringify(certificate, null, 2));
313
+ }
314
+
315
+ // --- Passes and receipts ---
316
+ const roomKeys = new Map(); // key -> { playerNumber, as }
317
+ const guestOf = (playerNumber) => {
318
+ // Kept with the bench, as the tab keeps its guest's pass: a restart, or the tab coming back, is the same guest.
319
+ if (!state.guests[playerNumber]) {
320
+ state.guests[playerNumber] = { id: `player_${randomText(10)}`, name: guestName() };
321
+ saveState();
322
+ }
323
+ return state.guests[playerNumber];
324
+ };
325
+ const tagsOf = (accountIds) => accountIds.map((id) => accountOf(id)?.playerTagId).filter(Boolean);
326
+ const friendIdsOf = (accountId) => state.friends.filter((pair) => pair.includes(accountId)).map((pair) => pair.find((id) => id !== accountId));
327
+ const blockIdsOf = (accountId) => [...new Set(state.blocks.filter((block) => block.blocker === accountId || block.blocked === accountId).map((block) => (block.blocker === accountId ? block.blocked : block.blocker)))];
328
+ const ownedNamesOf = (accountId) => state.ownedItems[accountId] ?? [];
329
+
330
+ async function passFor(as, playerNumber) {
331
+ const nowSec = Math.floor(now() / 1000);
332
+ const account = as === "guest" ? null : accountOf(as);
333
+ const player = account ? { id: account.playerId, name: account.username, kind: "account" } : { ...guestOf(playerNumber), kind: "guest" };
334
+ // Who they chat with, by Zaisma's own rules for their age and social mode, and an opaque chat group for their band, never the age.
335
+ const chat = account ? glass.chatOf(account.ageBand, account.socialMode) : null;
336
+ const claims = account
337
+ ? {
338
+ sub: account.playerId, aud: gameSlug, iss: issuerUrl, pass_version_number: PASS_VERSION_NUMBER, kind: "account",
339
+ who_can_chat: chat.whoCanChat, ...(chat.bandGroup ? { chat_group_id: `chat_group_${createHash("sha256").update(`local:${gameSlug}:${chat.bandGroup}`).digest("hex").slice(0, 24)}` } : {}),
340
+ player_tag_id: account.playerTagId, friend_player_tag_ids: tagsOf(friendIdsOf(account.id)), block_player_tag_ids: tagsOf(blockIdsOf(account.id)),
341
+ owned_item_names: ownedNamesOf(account.id), iat: nowSec, exp: nowSec + PASS_DURATION_SEC,
342
+ }
343
+ : { sub: player.id, name: player.name, aud: gameSlug, iss: issuerUrl, pass_version_number: PASS_VERSION_NUMBER, kind: "guest", owned_item_names: [], iat: nowSec, exp: nowSec + PASS_DURATION_SEC };
344
+ return { pass: await signPass(passSigning, claims), player, expiresAt: isoOf((nowSec + PASS_DURATION_SEC) * 1000) };
345
+ }
346
+
347
+ const encoder = new TextEncoder();
348
+ const base64Url = (bytes) => Buffer.from(bytes).toString("base64url");
349
+ async function signReceipt(claims) {
350
+ const header = base64Url(encoder.encode(JSON.stringify({ alg: "EdDSA", typ: RECEIPT_TOKEN_TYPE, kid: receiptSigning.publicKey.kid })));
351
+ const body = base64Url(encoder.encode(JSON.stringify(claims)));
352
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, receiptSigning.key, encoder.encode(`${header}.${body}`)));
353
+ return `${header}.${body}.${base64Url(signature)}`;
354
+ }
355
+ async function purchaseFor(purchase) {
356
+ const receipt = await signReceipt({
357
+ sub: purchase.playerId, aud: gameSlug, iss: `${issuerUrl}/receipts`, jti: purchase.id, receipt_version_number: RECEIPT_VERSION_NUMBER, purchase_id: purchase.id, purchased_at: purchase.purchasedAt,
358
+ purchase_status: purchase.refundedAt ? "refunded" : "completed", item_name: purchase.itemName, item_kind: purchase.itemKind, item_count: purchase.itemCount, purchase_source: "grant", paid_minor_units: null, currency_code: null,
359
+ game_account_id: purchase.gameAccountId, iat: Math.floor(now() / 1000),
360
+ });
361
+ return {
362
+ id: purchase.id, itemName: purchase.itemName, itemKind: purchase.itemKind, itemCount: purchase.itemCount, purchaseSource: "grant", purchaseStatus: purchase.refundedAt ? "refunded" : "completed", gameAccountId: purchase.gameAccountId,
363
+ paidMinorUnits: null, currencyCode: null, purchasedAt: purchase.purchasedAt, refundedAt: purchase.refundedAt ?? null, receipt,
364
+ };
365
+ }
366
+ async function claimsOf(pass) {
367
+ try {
368
+ return await verifyPass(typeof pass === "string" ? pass : "", [passSigning.publicKey], { gameSlug, issuerUrl });
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+
374
+ // --- Players: every tab or phone that opens the bench is a new player, numbered as it arrives, a guest until it signs in ---
375
+ // A player's page is /p/<number>/g/<slug>, so the requests its page makes say whose they are: same-origin requests carry the page as their
376
+ // referrer. The readable sign-in cookie the page's scripts look for is set for that path alone, so each player's page reads its own.
377
+ // The dev page asks for the state about once a second, which says the player is still here, and says when it closes. One not heard from
378
+ // for 70 seconds is gone too: a browser slows a background tab's timers to once a minute. (Polling, not a held-open stream per tab: a
379
+ // browser holds at most six connections to one address.)
380
+ const PLAYER_OPEN_MS = 70_000;
381
+ const PLAYER_PATH = /^\/p\/([1-9][0-9]{0,3})(?=\/|$)/;
382
+ const heardAtMs = new Map(); // player number -> when its dev page last asked
383
+ const lastRoomCodes = new Map(); // player number -> the room it was last seen in, to go back to after a sign-in that left the page
384
+ let reloadNumber = 0;
385
+ /** Tell every dev page to reload its game, at its next ask. */
386
+ const tell = (event) => {
387
+ if (event === "reload") reloadNumber++;
388
+ };
389
+ const alive = { has: (playerNumber) => now() - (heardAtMs.get(playerNumber) ?? 0) < PLAYER_OPEN_MS };
390
+ const openPlayerNumbers = () => [...heardAtMs.keys()].filter((playerNumber) => alive.has(playerNumber)).sort((first, second) => first - second);
391
+ const nextPlayerNumber = () => {
392
+ let playerNumber = 1;
393
+ while (alive.has(playerNumber)) playerNumber++;
394
+ return playerNumber;
395
+ };
396
+ /**
397
+ * A tab's player. A tab keeps its number (the dev page keeps it in sessionStorage, as the game keeps its pass there), so coming back is the
398
+ * same player. A new tab takes the first number nobody holds, at once, so two tabs opened together are two players, and starts as a new guest.
399
+ */
400
+ const claimPlayer = (keptNumber) => {
401
+ if (Number.isInteger(keptNumber) && keptNumber > 0 && keptNumber <= 9999) {
402
+ heardAtMs.set(keptNumber, now());
403
+ return keptNumber;
404
+ }
405
+ const playerNumber = nextPlayerNumber();
406
+ heardAtMs.set(playerNumber, now());
407
+ state.tabs[playerNumber] = "guest";
408
+ delete state.guests[playerNumber];
409
+ saveState();
410
+ return playerNumber;
411
+ };
412
+ const roomsTracked = new Map(); // code -> { sockets: Set<{ isSpectator, playerId }>, isListed, name, labels, maximumPlayerCount, isOpen }
413
+ const ownRooms = new Map(); // player id -> { roomCode, friendJoinsEnabled }
414
+ const saidOnce = new Set();
415
+ const logOnce = (line) => {
416
+ if (saidOnce.has(line)) return;
417
+ saidOnce.add(line);
418
+ log(line);
419
+ };
420
+
421
+ /** The player a request is from: its own path (/p/<number>/...), or the page it was made on, theirs or their dev page (/?player=). 0 for none. */
422
+ const playerNumberOf = (request, url) => {
423
+ const own = PLAYER_PATH.exec(url.pathname);
424
+ if (own) return Number(own[1]);
425
+ const referrer = request.headers.referer;
426
+ if (!referrer || !URL.canParse(referrer)) return 0;
427
+ const from = new URL(referrer);
428
+ const page = PLAYER_PATH.exec(from.pathname);
429
+ if (page) return Number(page[1]);
430
+ const devNumber = from.pathname === "/" ? Number(from.searchParams.get("player")) : 0;
431
+ return Number.isInteger(devNumber) && devNumber > 0 && devNumber <= 9999 ? devNumber : 0;
432
+ };
433
+ const playingAccount = (playerNumber) => (asOf(playerNumber) === "guest" ? null : accountOf(asOf(playerNumber)));
434
+ const playerIdOf = (playerNumber) => playingAccount(playerNumber)?.playerId ?? state.guests[playerNumber]?.id ?? null;
435
+ const roomOfPlayerId = (playerId) => (playerId ? ([...roomsTracked].find(([, room]) => [...room.sockets].some((each) => each.playerId === playerId && !each.isSpectator))?.[0] ?? null) : null);
436
+
437
+ /** A friend's account's friends playing the game now, as on Zaisma: a player open as them, or a room they're in. */
438
+ function friendsPlaying(accountId) {
439
+ const playingIds = new Set(openPlayerNumbers().map((playerNumber) => asOf(playerNumber)));
440
+ return friendIdsOf(accountId).map((id) => accountOf(id)).filter(Boolean).flatMap((friend) => {
441
+ const code = roomOfPlayerId(friend.playerId);
442
+ const ownRoom = ownRooms.get(friend.playerId) ?? null;
443
+ if (!code && !ownRoom && !playingIds.has(friend.id)) return [];
444
+ return [{ playerId: friend.playerId, username: friend.username, roomCode: code ?? (ownRoom?.friendJoinsEnabled ? ownRoom.roomCode : null), ownRoom }];
445
+ });
446
+ }
447
+
448
+ /** A room key for the player, as Zaisma hands one to the page as Play is pressed: the stand-in rooms swaps it for the player's pass. */
449
+ function roomKeyFor(playerNumber) {
450
+ const as = asOf(playerNumber);
451
+ const key = `local_key_${randomText(24)}`;
452
+ roomKeys.set(key, { playerNumber, as });
453
+ return { key, playerKind: as === "guest" ? "guest" : "account" };
454
+ }
455
+
456
+ // The bench's players, made on this computer with Zaisma's own age bands and social modes: no passwords, kept in bench.json.
457
+ const EXAMPLE_NAMES = ["Anna", "Ben", "Carla", "David", "Emma", "Felix", "Grace", "Henry", "Iris", "Jonas", "Lena", "Max"];
458
+ const suggestedName = () => EXAMPLE_NAMES.find((name) => !state.accounts.some((account) => account.username === name)) ?? `Player ${state.accounts.length + 1}`;
459
+ const AGE_BANDS = ["13-15", "16-17", "18+"];
460
+ const isUsername = (name) => /^[\p{L}\p{N} _-]{1,20}$/u.test(name);
461
+ const accountByName = (username) => state.accounts.find((account) => account.username.toLowerCase() === username.toLowerCase()) ?? null;
462
+ function makeAccount(username, ageBand = "18+", socialMode = glass.startingSocialMode(ageBand)) {
463
+ const base = username.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30) || "player";
464
+ let id = base;
465
+ for (let suffixNumber = 2; accountOf(id); suffixNumber++) id = `${base}-${suffixNumber}`;
466
+ const account = { id, username, playerId: `player_${randomText(10)}`, playerTagId: `player_tag_${randomText(12)}`, ageBand, socialMode };
467
+ state.accounts.push(account);
468
+ return account;
469
+ }
470
+ /** Why a profile can't be, or null: a name that's a name and not another player's, a band, and a social mode the band can choose. */
471
+ function profileRefusal(username, ageBand, socialMode, exceptId = null) {
472
+ if (!isUsername(username)) return "A name is 1 to 20 letters, digits, spaces, hyphens or underscores.";
473
+ if (state.accounts.some((account) => account.id !== exceptId && account.username.toLowerCase() === username.toLowerCase())) return `There's a player named ${username} already.`;
474
+ if (!AGE_BANDS.includes(ageBand)) return "Pick an age.";
475
+ if (!glass.isSocialModeAllowed(ageBand, socialMode)) return ageBand === "13-15" ? "13–15 can be solo-focused or friends-focused, as on Zaisma." : "Pick how they play.";
476
+ return null;
477
+ }
478
+ /** A tab's name: the account it plays as, or the name the game gives its guest. */
479
+ const tabNameOf = (playerNumber) => playingAccount(playerNumber)?.username ?? guestOf(playerNumber).name;
480
+ /** The bench's players, by name: the order every list of them keeps, whoever played last. */
481
+ const playersByName = () => [...state.accounts].sort((first, second) => first.username.localeCompare(second.username, "en", { sensitivity: "base" }));
482
+ /** Signs a player (tab) in as an account. */
483
+ function signInAs(playerNumber, account) {
484
+ const before = tabNameOf(playerNumber);
485
+ const isAlready = asOf(playerNumber) === account.id;
486
+ state.tabs[playerNumber] = account.id;
487
+ saveState();
488
+ if (isAlready) return;
489
+ log(`${before} signed in as ${account.username}.`);
490
+ // As on Zaisma, the account can be open twice (a phone and a laptop): in a room, the newest connection takes over from the other.
491
+ const tabCount = openPlayerNumbers().filter((openNumber) => asOf(openNumber) === account.id).length + (openPlayerNumbers().includes(playerNumber) ? 0 : 1);
492
+ if (tabCount > 1) log(`${account.username} is open in ${tabCount} tabs now, as on two devices: in a room, the newest connection takes over and the other hears "Joined from somewhere else".`);
493
+ }
494
+
495
+ /** A purchase for an account: bought through the buy sheet with the test card, or granted from the player's details. */
496
+ async function addPurchase(account, listedItem, itemCount, gameAccountId, source, purchaseId = `purchase_${randomText(12)}`) {
497
+ const deliveredItemCount = listedItem.itemKind === "consumable" ? itemCount : 1;
498
+ const purchase = { id: purchaseId, playerId: account.playerId, accountId: account.id, itemName: listedItem.itemName, itemKind: listedItem.itemKind, itemCount: deliveredItemCount, gameAccountId: gameAccountId ?? null, purchasedAt: isoOf(now()), isHandled: false, source, refundedAt: null };
499
+ state.purchases.push(purchase);
500
+ if (listedItem.itemKind === "permanent") state.ownedItems[account.id] = [...new Set([...ownedNamesOf(account.id), listedItem.itemName])];
501
+ saveState();
502
+ return purchase;
503
+ }
504
+ async function buy(playerNumber, itemName, itemCount, gameAccountId, purchaseId = `purchase_${randomText(12)}`) {
505
+ const listedItem = state.items.find((each) => each.itemName === itemName);
506
+ if (!listedItem) return { error: { code: "no_item", message: `The game has no item named ${itemName} on Zaisma.`, fix: "Set it on Zaisma (zaisma items set), then start the bench again." } };
507
+ const account = playingAccount(playerNumber);
508
+ if (!account) return { error: { code: "account_needed", message: "Buying is for players signed in, and this player is a guest.", fix: "Sign in from the buy sheet." } };
509
+ return { purchase: await purchaseFor(await addPurchase(account, listedItem, itemCount, gameAccountId, "bought", purchaseId)) };
510
+ }
511
+
512
+ /** What the dev page and its sheets show for a player. */
513
+ function devViewFor(playerNumber) {
514
+ const account = playingAccount(playerNumber);
515
+ return {
516
+ gameSlug, playerNumber, accountName: account?.username ?? null, tabName: tabNameOf(playerNumber), roomCode: roomOfPlayerId(playerIdOf(playerNumber)),
517
+ players: playersByName().map((each) => ({ playerId: each.playerId, name: each.username, isThisTab: each.id === account?.id, isInAnotherTab: openPlayerNumbers().some((openNumber) => openNumber !== playerNumber && asOf(openNumber) === each.id) })),
518
+ networkUrl: networkOrigin, logLines: [...logLines],
519
+ };
520
+ }
521
+
522
+ const matches = (searchText, ...texts) => !searchText || texts.some((text) => text.toLowerCase().includes(searchText.toLowerCase()));
523
+ const titleOf = (itemName) => state.items.find((listedItem) => listedItem.itemName === itemName)?.title ?? itemName;
524
+ const openNumbersOf = (account) => openPlayerNumbers().filter((each) => asOf(each) === account.id);
525
+ const blockedIdsOf = (account) => state.blocks.filter((block) => block.blocker === account.id).map((block) => block.blocked);
526
+ const blockerIdsOf = (account) => state.blocks.filter((block) => block.blocked === account.id).map((block) => block.blocker);
527
+ const playerIdsOf = (accountIds) => accountIds.map((id) => accountOf(id)?.playerId).filter(Boolean);
528
+
529
+ // --- The bench's API: everything the Players panel and the dev bar do, as JSON, for the developer's AI as much as for the panel ---
530
+ // Players are named by their player_id, the ID games see. Answers and errors are shaped as Zaisma's developer API's.
531
+ const apiPlayer = (account) => ({
532
+ player_id: account.playerId, name: account.username, age_band: account.ageBand, social_mode: account.socialMode, who_can_chat: glass.chatOf(account.ageBand, account.socialMode).whoCanChat,
533
+ open_player_numbers: openNumbersOf(account), friend_player_ids: playerIdsOf(friendIdsOf(account.id)), block_player_ids: playerIdsOf(blockedIdsOf(account)),
534
+ blocked_by_player_ids: playerIdsOf(blockerIdsOf(account)), owned_item_names: ownedNamesOf(account.id),
535
+ });
536
+ const apiPurchase = (purchase) => ({
537
+ purchase_id: purchase.id, player_id: purchase.playerId, item_name: purchase.itemName, item_count: purchase.itemCount, purchase_source: purchase.source === "bought" ? "test_card" : "grant",
538
+ purchased_at: purchase.purchasedAt, is_handled: purchase.isHandled, refunded_at: purchase.refundedAt ?? null,
539
+ });
540
+ const apiOpenPlayer = (playerNumber) => ({ player_number: playerNumber, player_id: playingAccount(playerNumber)?.playerId ?? null, kind: playingAccount(playerNumber) ? "account" : "guest", room_code: roomOfPlayerId(playerIdOf(playerNumber)), url: `${localhostOrigin(ports.bench)}/?player=${playerNumber}` });
541
+
542
+ async function answerApi(request, response, url) {
543
+ const reply = (status, body, headers = {}) => {
544
+ response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
545
+ response.end(JSON.stringify(body));
546
+ };
547
+ const fail = (status, code, message, fix) => reply(status, { error: { code, message, fix } });
548
+ const path = url.pathname.replace(/\/+$/, "");
549
+ const method = request.method;
550
+ const body = method === "GET" || method === "DELETE" ? {} : await readJson(request);
551
+ const noPlayer = () => fail(404, "no_player", "The bench has no player with that player_id.", "GET /bench/api/players lists them.");
552
+ if (path === "/bench/api" && method === "GET") {
553
+ return reply(200, {
554
+ game_slug: gameSlug, bench_url: localhostOrigin(ports.bench), network_url: networkOrigin, game_site_url: localhostOrigin(ports.game), rooms_url: issuerUrl,
555
+ players: playersByName().map(apiPlayer), open_players: openPlayerNumbers().map(apiOpenPlayer),
556
+ rooms: [...roomsTracked].map(([code, room]) => ({ room_code: code, player_count: [...room.sockets].filter((each) => !each.isSpectator).length })),
557
+ items: state.items.map((listedItem) => ({ item_name: listedItem.itemName, title: listedItem.title, item_kind: listedItem.itemKind, price_text: listedItem.priceMinorUnits === null || !listedItem.currencyCode ? null : priceText(listedItem.currencyCode, listedItem.priceMinorUnits) })),
558
+ });
559
+ }
560
+ if (path === "/bench/api/log" && method === "GET") return reply(200, { log_lines: [...logLines] });
561
+ if (path === "/bench/api/players" && method === "GET") return reply(200, { players: playersByName().map(apiPlayer) });
562
+ // A new player: any of a name, an age band and a social mode, each left out taking the next example name and Zaisma's defaults.
563
+ if (path === "/bench/api/players" && method === "POST") {
564
+ const ageBand = body.age_band ?? "18+";
565
+ const socialMode = body.social_mode ?? (AGE_BANDS.includes(ageBand) ? glass.startingSocialMode(ageBand) : "friends_focused");
566
+ const username = typeof body.name === "string" ? body.name.trim() : suggestedName();
567
+ const refused = profileRefusal(username, ageBand, socialMode);
568
+ if (refused) return fail(400, "bad_player", refused, "Give a name of 1 to 20 letters, digits, spaces, hyphens or underscores, an age_band (13-15, 16-17, 18+) and a social_mode the band can choose.");
569
+ const account = makeAccount(username, ageBand, socialMode);
570
+ saveState();
571
+ log(`Made ${username}.`);
572
+ return reply(201, { player: apiPlayer(account) });
573
+ }
574
+ if (path === "/bench/api/open-players" && method === "GET") return reply(200, { open_players: openPlayerNumbers().map(apiOpenPlayer) });
575
+ // A new player (tab) for the AI or a script to open: as a guest, or signed in as one of the bench's players; in a room when it names one.
576
+ if (path === "/bench/api/open-players" && method === "POST") {
577
+ const account = body.player_id ? accountOfPlayer(body.player_id) : null;
578
+ if (body.player_id && !account) return noPlayer();
579
+ const playerNumber = claimPlayer();
580
+ if (account) signInAs(playerNumber, account);
581
+ const room = typeof body.room_code === "string" && /^[A-Z0-9]{6}$/.test(body.room_code) ? body.room_code : null;
582
+ return reply(201, { ...apiOpenPlayer(playerNumber), url: `${localhostOrigin(ports.bench)}/?player=${playerNumber}${room ? `&room=${room}` : ""}` });
583
+ }
584
+ const openMatch = /^\/bench\/api\/open-players\/([1-9][0-9]{0,3})(\/pass)?$/.exec(path);
585
+ if (openMatch) {
586
+ const playerNumber = Number(openMatch[1]);
587
+ // A pass for the player, as the game gets one: for a script that joins the game server's rooms itself, over a WebSocket with the game site's Origin.
588
+ if (openMatch[2] && method === "POST") {
589
+ const given = await passFor(asOf(playerNumber), playerNumber);
590
+ return reply(200, { pass: given.pass, player: given.player, expires_at: given.expiresAt, game_slug: gameSlug, rooms_url: issuerUrl, game_site_url: localhostOrigin(ports.game) });
591
+ }
592
+ // Who this player plays as: one of the bench's players, or null for a new guest.
593
+ if (!openMatch[2] && method === "PUT") {
594
+ if (body.player_id === null) {
595
+ const before = tabNameOf(playerNumber);
596
+ state.tabs[playerNumber] = "guest";
597
+ delete state.guests[playerNumber];
598
+ saveState();
599
+ log(`${before} signed out: this tab is ${tabNameOf(playerNumber)}, a new guest.`);
600
+ } else {
601
+ const account = accountOfPlayer(body.player_id);
602
+ if (!account) return noPlayer();
603
+ signInAs(playerNumber, account);
604
+ }
605
+ heardAtMs.set(playerNumber, now());
606
+ return reply(200, apiOpenPlayer(playerNumber), { "Set-Cookie": signedInCookie(playerNumber) });
607
+ }
608
+ }
609
+ const playerMatch = /^\/bench\/api\/players\/(player_[a-z0-9]{1,40})(\/.*)?$/.exec(path);
610
+ if (!playerMatch) return fail(404, "not_local", `${method} ${path} isn't a call of the bench's API.`, "GET /bench/api says what the bench has; the README's \"Play it on your own computer\" lists every call.");
611
+ const account = accountOfPlayer(playerMatch[1]);
612
+ if (!account) return noPlayer();
613
+ const rest = playerMatch[2] ?? "";
614
+ const done = () => {
615
+ saveState();
616
+ return reply(200, { player: apiPlayer(account) });
617
+ };
618
+ if (rest === "" && method === "GET") return reply(200, { player: apiPlayer(account), purchases: state.purchases.filter((purchase) => purchase.accountId === account.id).map(apiPurchase) });
619
+ if (rest === "" && method === "PATCH") {
620
+ const username = typeof body.name === "string" ? body.name.trim() : account.username;
621
+ const ageBand = body.age_band ?? account.ageBand;
622
+ // A band that can't keep the social mode takes the band's starting one, as Zaisma's account page does.
623
+ const socialMode = body.social_mode ?? (glass.isSocialModeAllowed(ageBand, account.socialMode) ? account.socialMode : glass.startingSocialMode(ageBand));
624
+ const refused = profileRefusal(username, ageBand, socialMode, account.id);
625
+ if (refused) return fail(400, "bad_player", refused, "Give a name of 1 to 20 letters, digits, spaces, hyphens or underscores, an age_band (13-15, 16-17, 18+) and a social_mode the band can choose.");
626
+ Object.assign(account, { username, ageBand, socialMode });
627
+ return done();
628
+ }
629
+ if (rest === "" && method === "DELETE") {
630
+ state.accounts = state.accounts.filter((each) => each.id !== account.id);
631
+ state.friends = state.friends.filter((pair) => !pair.includes(account.id));
632
+ state.blocks = state.blocks.filter((block) => block.blocker !== account.id && block.blocked !== account.id);
633
+ state.purchases = state.purchases.filter((purchase) => purchase.accountId !== account.id);
634
+ delete state.ownedItems[account.id];
635
+ for (const [playerNumber, as] of Object.entries(state.tabs)) if (as === account.id) state.tabs[playerNumber] = "guest";
636
+ rmSync(saveFileOf(account.id), { force: true });
637
+ saveState();
638
+ log(`Deleted ${account.username}.`);
639
+ return reply(200, { is_deleted: true });
640
+ }
641
+ const pairMatch = /^\/(friends|blocks)\/(player_[a-z0-9]{1,40})$/.exec(rest);
642
+ if (pairMatch && (method === "PUT" || method === "DELETE")) {
643
+ const other = accountOfPlayer(pairMatch[2]);
644
+ if (!other || other.id === account.id) return fail(404, "no_player", "That's not another of the bench's players.", "GET /bench/api/players lists them.");
645
+ if (pairMatch[1] === "friends") {
646
+ state.friends = state.friends.filter((pair) => !isPair(pair, account.id, other.id));
647
+ if (method === "PUT") {
648
+ if (state.blocks.some((block) => isPair([block.blocker, block.blocked], account.id, other.id))) return fail(409, "blocked_pair", `${account.username} and ${other.username} can't be friends while one blocks the other.`, "Remove the block first: DELETE .../blocks/<player_id>.");
649
+ state.friends.push([account.id, other.id]);
650
+ }
651
+ } else {
652
+ state.blocks = state.blocks.filter((block) => !(block.blocker === account.id && block.blocked === other.id));
653
+ if (method === "PUT") {
654
+ // A block ends a friendship too, as on Zaisma.
655
+ state.friends = state.friends.filter((pair) => !isPair(pair, account.id, other.id));
656
+ state.blocks.push({ blocker: account.id, blocked: other.id });
657
+ }
658
+ }
659
+ return done();
660
+ }
661
+ // Grant one of the game's items: a purchase the game gets as Zaisma would send it.
662
+ if (rest === "/items" && method === "POST") {
663
+ const listedItem = state.items.find((each) => each.itemName === body.item_name);
664
+ if (!listedItem) return fail(404, "no_item", `The game has no item named ${String(body.item_name)} on Zaisma.`, "GET /bench/api lists its items.");
665
+ const itemCount = Number.isInteger(body.item_count) && body.item_count > 0 ? body.item_count : 1;
666
+ const purchase = await addPurchase(account, listedItem, itemCount, typeof body.game_account_id === "string" ? body.game_account_id : null, "grant");
667
+ log(`Granted ${listedItem.title} to ${account.username}.`);
668
+ return reply(201, { purchase: apiPurchase(purchase), player: apiPlayer(account) });
669
+ }
670
+ const itemMatch = /^\/items\/([^/]{1,100})$/.exec(rest);
671
+ if (itemMatch && method === "DELETE") {
672
+ state.ownedItems[account.id] = ownedNamesOf(account.id).filter((itemName) => itemName !== decodeURIComponent(itemMatch[1]));
673
+ return done();
674
+ }
675
+ if (rest === "/purchases" && method === "GET") return reply(200, { purchases: state.purchases.filter((purchase) => purchase.accountId === account.id).map(apiPurchase) });
676
+ const refundMatch = /^\/purchases\/(purchase_[a-z0-9]+)\/refund$/.exec(rest);
677
+ if (refundMatch && method === "POST") {
678
+ const purchase = state.purchases.find((each) => each.id === refundMatch[1] && each.accountId === account.id);
679
+ if (!purchase) return fail(404, "no_purchase", "This player has no purchase with that ID.", "GET .../purchases lists them.");
680
+ if (purchase.refundedAt) return fail(409, "already_refunded", "It was refunded already.", "Nothing to do.");
681
+ // The game hears it as Zaisma's refund: the purchase again, refunded, and a permanent item no longer owned.
682
+ purchase.refundedAt = isoOf(now());
683
+ purchase.isHandled = false;
684
+ if (purchase.itemKind === "permanent") state.ownedItems[account.id] = ownedNamesOf(account.id).filter((itemName) => itemName !== purchase.itemName);
685
+ saveState();
686
+ log(`Refunded ${account.username}'s ${purchase.itemName}.`);
687
+ return reply(200, { purchase: apiPurchase(purchase), player: apiPlayer(account) });
688
+ }
689
+ return fail(404, "not_local", `${method} ${path} isn't a call of the bench's API.`, "The README's \"Play it on your own computer\" lists every call.");
690
+ }
691
+
692
+ /** A part of the Players panel, drawn afresh after every change: the list, New player, or one player's tab. */
693
+ function panelHtml(url, place) {
694
+ const view = url.searchParams.get("view") ?? "list";
695
+ const searchText = (url.searchParams.get("search") ?? "").slice(0, 100);
696
+ const current = playingAccount(place.playerNumber);
697
+ const panelPlayer = (account) => ({ playerId: account.playerId, name: account.username, ageBand: account.ageBand, socialMode: account.socialMode, openPlayerNumbers: openNumbersOf(account) });
698
+ const account = view === "player" ? accountOfPlayer(url.searchParams.get("player_id") ?? "") : null;
699
+ if (view === "new") return glass.newPlayerPanelHtml({ suggestedName: suggestedName() });
700
+ if (!account) {
701
+ return glass.playersPanelHtml(place, { players: playersByName().filter((each) => matches(searchText, each.username)).map(panelPlayer), currentPlayerId: current?.playerId ?? null, searchText, totalCount: state.accounts.length });
702
+ }
703
+ const tab = url.searchParams.get("tab") === "items" ? "items" : "profile";
704
+ const named = (ids) => ids.map((id) => accountOf(id)).filter(Boolean).map((other) => ({ playerId: other.playerId, name: other.username }));
705
+ const owned = new Set(ownedNamesOf(account.id));
706
+ return glass.playerPanelHtml(place, {
707
+ player: panelPlayer(account), isCurrent: current?.id === account.id, tab,
708
+ friends: named(friendIdsOf(account.id)), blocked: named(blockedIdsOf(account)), blockedBy: named(blockerIdsOf(account)).map((other) => other.name),
709
+ others: state.accounts.filter((other) => other.id !== account.id).map((other) => ({ playerId: other.playerId, name: other.username })),
710
+ // The game's items, owned first, each with this player's purchases of it, newest first.
711
+ items: state.items
712
+ .filter((listedItem) => matches(searchText, listedItem.title, listedItem.itemName))
713
+ .map((listedItem) => ({
714
+ itemName: listedItem.itemName, title: listedItem.title, itemKind: listedItem.itemKind,
715
+ priceText: listedItem.priceMinorUnits === null || !listedItem.currencyCode ? "only granted" : priceText(listedItem.currencyCode, listedItem.priceMinorUnits), isOwned: owned.has(listedItem.itemName),
716
+ purchases: state.purchases
717
+ .filter((purchase) => purchase.accountId === account.id && purchase.itemName === listedItem.itemName)
718
+ .reverse()
719
+ .map((purchase) => ({
720
+ purchaseId: purchase.id,
721
+ detail: [`${purchase.source === "bought" ? "Test card" : "Granted"}${purchase.itemCount > 1 ? ` × ${purchase.itemCount}` : ""}`, purchase.purchasedAt.slice(0, 16).replace("T", " "), purchase.isHandled ? "handled" : "waiting for the game", ...(purchase.refundedAt ? ["refunded"] : [])].join(" · "),
722
+ isRefunded: Boolean(purchase.refundedAt),
723
+ })),
724
+ }))
725
+ .sort((first, second) => Number(second.isOwned) - Number(first.isOwned) || second.purchases.length - first.purchases.length),
726
+ searchText,
727
+ });
728
+ }
729
+
730
+ const placesFor = (request) => ({ localSiteOrigin: localhostOrigin(ports.bench), gameOrigin: originFor(request, ports.game), roomsOrigin: originFor(request, ports.rooms) });
731
+ const readText = async (request) => {
732
+ const chunks = [];
733
+ let sizeBytes = 0;
734
+ for await (const chunk of request) {
735
+ sizeBytes += chunk.length;
736
+ if (sizeBytes > SAVE_LIMIT_BYTES * 2) return null;
737
+ chunks.push(chunk);
738
+ }
739
+ return Buffer.concat(chunks).toString("utf8");
740
+ };
741
+ const readJson = async (request) => {
742
+ const text = await readText(request);
743
+ try {
744
+ const body = JSON.parse(text || "{}");
745
+ return body && typeof body === "object" && !Array.isArray(body) ? body : {};
746
+ } catch {
747
+ return {};
748
+ }
749
+ };
750
+ const sendPage = (response, page, extraHeaders = {}, httpStatus = page.httpStatus) => {
751
+ const headers = Object.fromEntries(page.headers.filter(([name]) => !["link", "speculation-rules", "cache-control"].includes(name.toLowerCase())));
752
+ response.writeHead(httpStatus, { ...headers, "Cache-Control": "no-store", ...extraHeaders });
753
+ response.end(page.body);
754
+ };
755
+ const signedInCookie = (playerNumber) => (playingAccount(playerNumber) ? `zaisma_in=1; Path=/p/${playerNumber}; SameSite=Lax` : `zaisma_in=; Path=/p/${playerNumber}; SameSite=Lax; Max-Age=0`);
756
+
757
+ // The buy sheet's calls (apps/site/src/buy.ts), stood in for: the price, a saved test card, and paying with it grants the item.
758
+ const pendingSales = new Map(); // purchase id -> { playerNumber, itemName, itemCount, gameAccountId }
759
+ const grantedPurchases = new Map(); // purchase id -> the purchase as the sheet gets it
760
+ const sheetPurchase = (given) => ({ id: given.id, item_name: given.itemName, item_kind: given.itemKind, item_count: given.itemCount, purchase_source: given.purchaseSource, purchase_status: given.purchaseStatus, game_account_id: given.gameAccountId, paid_minor_units: given.paidMinorUnits, currency_code: given.currencyCode, purchased_at: given.purchasedAt, refunded_at: given.refundedAt, receipt: given.receipt });
761
+ async function answerBuy(request, response, url, reply) {
762
+ const fail = (status, code, message) => reply(status, { error: { code, message } });
763
+ const body = await readJson(request);
764
+ const path = url.pathname;
765
+ if (request.method === "POST" && path === `/api/buy/${gameSlug}/start`) {
766
+ const playerNumber = playerNumberOf(request, url);
767
+ const listedItem = state.items.find((each) => each.itemName === body.item_name);
768
+ if (!listedItem) return fail(404, "no_item", `This game has no item named ${String(body.item_name)} on Zaisma. Set it there (zaisma items set), then start the bench again.`);
769
+ if (listedItem.priceMinorUnits === null || !listedItem.currencyCode) return fail(400, "not_for_sale", `${listedItem.title} isn't for sale: it's only granted.`);
770
+ if (!playingAccount(playerNumber)) return fail(403, "account_needed", "Buying needs an account.");
771
+ const itemCount = listedItem.itemKind === "consumable" && Number.isInteger(body.item_count) && body.item_count > 0 ? body.item_count : 1;
772
+ const purchaseId = `purchase_${randomText(12)}`;
773
+ pendingSales.set(purchaseId, { playerNumber, itemName: listedItem.itemName, itemCount, gameAccountId: typeof body.game_account_id === "string" ? body.game_account_id : null });
774
+ const totalMinorUnits = listedItem.priceMinorUnits * itemCount;
775
+ return reply(200, {
776
+ purchase_id: purchaseId,
777
+ item: { title: listedItem.title, description: listedItem.description },
778
+ item_count: itemCount,
779
+ price: { price_text: priceText(listedItem.currencyCode, totalMinorUnits), price_minor_units: totalMinorUnits, currency_code: listedItem.currencyCode },
780
+ seller: { business_name: "the game's developer" },
781
+ refund_policy_words: "A test on this computer: the test card is never charged.",
782
+ // Stripe's test card, as the site's own local preview has it.
783
+ saved_card: { card_brand: "visa", card_last_digits: "4242" },
784
+ is_confirmation_needed: false,
785
+ confirmation_reason: null,
786
+ confirmation_methods: {},
787
+ has_account_email: true,
788
+ is_eu_withdrawal_line_shown: false,
789
+ wallet: null,
790
+ });
791
+ }
792
+ const payMatch = /^\/api\/buy\/purchases\/(purchase_[a-z0-9]+)\/pay-with-saved-card$/.exec(path);
793
+ if (request.method === "POST" && payMatch) {
794
+ const sale = pendingSales.get(payMatch[1]);
795
+ if (!sale) return fail(404, "no_purchase", "That purchase isn't waiting to be paid.");
796
+ pendingSales.delete(payMatch[1]);
797
+ const answer = await buy(sale.playerNumber, sale.itemName, sale.itemCount, sale.gameAccountId, payMatch[1]);
798
+ if ("error" in answer) return fail(400, answer.error.code, answer.error.message);
799
+ const purchase = sheetPurchase(answer.purchase);
800
+ grantedPurchases.set(purchase.id, purchase);
801
+ return reply(200, { status: "completed", purchase });
802
+ }
803
+ const purchaseMatch = /^\/api\/buy\/purchases\/(purchase_[a-z0-9]+)$/.exec(path);
804
+ if (request.method === "GET" && purchaseMatch) {
805
+ const purchase = grantedPurchases.get(purchaseMatch[1]);
806
+ return purchase ? reply(200, { purchase }) : fail(404, "no_purchase", "No purchase has that ID.");
807
+ }
808
+ return fail(404, "not_local", `${request.method} ${path} is a part of Zaisma's buy sheet the bench has no stand-in for.`);
809
+ }
810
+
811
+ // Cloud saves (apps/site/src/saves.ts), stood in for: one save per account, kept in .zaisma-local/saves/, with its version number.
812
+ const saveFileOf = (accountId) => join(localFolder, "saves", `${accountId}.json`);
813
+ const savedRowOf = (accountId) => {
814
+ try {
815
+ const row = JSON.parse(readFileSync(saveFileOf(accountId), "utf8"));
816
+ return Number.isInteger(row?.version_number) ? row : null;
817
+ } catch {
818
+ return null;
819
+ }
820
+ };
821
+ async function answerSaves(request, url, reply, playerNumber) {
822
+ const account = playingAccount(playerNumber);
823
+ if (!account) return reply(401, { error: "Sign in to keep your progress." });
824
+ const rest = url.pathname.slice(`/api/saves/${gameSlug}`.length);
825
+ const row = savedRowOf(account.id);
826
+ if (rest === "/copies" && request.method === "GET") return reply(200, { save_copies: [] });
827
+ if (rest.startsWith("/copies/")) return reply(404, { error: { code: "no_save_copy", message: "The bench keeps one save per account, with no earlier versions." } });
828
+ if (rest !== "") return reply(404, { error: { code: "not_local", message: "Not a saves call." } });
829
+ if (request.method === "GET") {
830
+ if (url.searchParams.has("version")) return reply(200, { version_number: row?.version_number ?? 0, saved_at: row?.saved_at ?? null, changed_by_kind: row ? "player" : null });
831
+ const tag = createHash("sha256").update(`${account.id}:${gameSlug}`).digest("hex").slice(0, 16);
832
+ if (!row) return reply(200, { tag, version_number: 0 });
833
+ return reply(200, { tag, version_number: row.version_number, sha256: row.sha256, saved_at: row.saved_at, changed_by_kind: "player", save_data: row.save_data });
834
+ }
835
+ if (request.method !== "PUT") return reply(405, { error: "PUT or GET." });
836
+ const text = await readText(request);
837
+ let body = {};
838
+ try {
839
+ body = JSON.parse(text ?? "") ?? {};
840
+ } catch {
841
+ body = {};
842
+ }
843
+ if (typeof body.save_json !== "string") return reply(400, { error: "save_json is missing." });
844
+ if (Buffer.byteLength(body.save_json) > SAVE_LIMIT_BYTES) return reply(413, { error: "Over 5 MB." });
845
+ let saveData;
846
+ try {
847
+ saveData = JSON.parse(body.save_json);
848
+ } catch {
849
+ return reply(400, { error: "save_json isn't JSON." });
850
+ }
851
+ if (body.keep === true) return reply(200, { save_copy_id: `save_copy_${randomText(12)}` });
852
+ const currentNumber = row?.version_number ?? 0;
853
+ if (body.replace !== true && Number(body.base_version_number) !== currentNumber) return reply(409, { error: "Played on another device.", version_number: currentNumber, saved_at: row?.saved_at ?? null, changed_by_kind: "player" });
854
+ const saved = { version_number: currentNumber + 1, sha256: createHash("sha256").update(body.save_json).digest("hex"), saved_at: isoOf(now()), save_data: saveData };
855
+ writeFileSync(saveFileOf(account.id), JSON.stringify(saved));
856
+ return reply(200, { version_number: saved.version_number, sha256: saved.sha256, saved_at: saved.saved_at, kept_save_copy_id: null });
857
+ }
858
+
859
+ /** The calls zaisma.com's game page makes, stood in for (apps/site/src/pages.ts and the scripts and sheets it holds). */
860
+ async function answerSite(request, response, url) {
861
+ const reply = (status, body, headers = {}) => {
862
+ response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
863
+ response.end(body === null ? undefined : JSON.stringify(body));
864
+ };
865
+ const noContent = () => {
866
+ response.writeHead(204, { "Cache-Control": "no-store" });
867
+ response.end();
868
+ };
869
+ const fragment = (markup) => {
870
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
871
+ response.end(markup);
872
+ };
873
+ const path = url.pathname;
874
+ const playerNumber = playerNumberOf(request, url);
875
+ const account = playingAccount(playerNumber);
876
+ const isPost = request.method === "POST";
877
+ const game = `/g/${gameSlug}`;
878
+
879
+ // The player's page: the game page as zaisma.com draws it for them.
880
+ const page = PLAYER_PATH.exec(path);
881
+ if (page && path === `/p/${playerNumber}${game}` && request.method === "GET") {
882
+ const pageUrl = `${game}${url.search}`;
883
+ const soldCategories = [...new Set(state.items.filter((listedItem) => listedItem.priceMinorUnits !== null && listedItem.currencyCode).map((listedItem) => listedItem.itemCategory))];
884
+ const drawn = await glass.playerPage({ places: placesFor(request), gameSlug, gameTitle, pageUrl, account: account ? { id: account.id, username: account.username } : null, purchaseDelivery: state.purchaseDelivery, soldCategories });
885
+ return sendPage(response, drawn, { "Set-Cookie": signedInCookie(playerNumber) });
886
+ }
887
+ if (page) return reply(404, { error: { code: "not_local", message: "A player's address holds only their game page." } });
888
+ // A link to the game page (Join a friend, a room's link) reaches the player's own page. The game page sends its links to the top
889
+ // window when it's framed, as in Zaisma's home, so a top-level one comes back to the player's dev page; with no player, a new one's.
890
+ if ((path === game || path === `${game}/`) && request.method === "GET") {
891
+ const room = /^[A-Z0-9]{6}$/.test(url.searchParams.get("room") ?? "") ? url.searchParams.get("room") : null;
892
+ const devPath = `/?${playerNumber ? `player=${playerNumber}` : ""}${room ? `${playerNumber ? "&" : ""}room=${room}` : ""}`;
893
+ const isFramed = request.headers["sec-fetch-dest"] === "iframe";
894
+ response.writeHead(302, { Location: isFramed && playerNumber ? `/p/${playerNumber}${game}${url.search}` : devPath, "Cache-Control": "no-store" });
895
+ return response.end();
896
+ }
897
+ if (path.startsWith("/api/buy/")) return answerBuy(request, response, url, reply);
898
+ if (path === `/api/saves/${gameSlug}` || path.startsWith(`/api/saves/${gameSlug}/`)) return answerSaves(request, url, reply, playerNumber);
899
+
900
+ // Play: a ticket for the time reports (which go nowhere here) and the room key. A tester link's play isn't on the bench.
901
+ if (isPost && path === `/api/plays/${gameSlug}`) {
902
+ const body = await readJson(request);
903
+ if (body.ticket !== undefined || body.tester !== undefined || !playerNumber) return noContent();
904
+ return reply(200, { ticket: `ticket_local_${randomText(12)}`, ...roomKeyFor(playerNumber) });
905
+ }
906
+ // A guest who signed in while playing: the account, and a room key for its pass.
907
+ if (isPost && path === `/api/plays/${gameSlug}/account`) {
908
+ await readJson(request);
909
+ if (!account) return reply(401, { error: { code: "not_signed_in", message: "Not signed in." } });
910
+ return reply(200, { player: { id: account.playerId, name: account.username, kind: "account" }, ...roomKeyFor(playerNumber) });
911
+ }
912
+ // Where friends see what a player plays, and what they played: only on Zaisma.
913
+ if (isPost && (path === `/api/me/played/${gameSlug}` || path === "/api/presence" || path === "/csp-reports")) {
914
+ await readText(request);
915
+ return noContent();
916
+ }
917
+ if (request.method === "GET" && path === `${game}/live-version`) return reply(200, { live_version_id: LOCAL_VERSION_ID, live_version_match_update_mode: "next_match" });
918
+ if (request.method === "GET" && path === `${game}/qr-code.svg`) {
919
+ const code = /^[A-Z0-9]{6}$/.test(url.searchParams.get("room") ?? "") ? url.searchParams.get("room") : null;
920
+ const target = `${networkOrigin ?? originFor(request, ports.bench)}/${code ? `?room=${code}` : ""}`;
921
+ response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "no-store" });
922
+ return response.end(qrCodeSvg(target));
923
+ }
924
+
925
+ // Friends: who's playing, the Friend marks in the players sheet, and blocks.
926
+ if (request.method === "GET" && path === `${game}/friends`) return fragment(account ? glass.friendsHereHtml(gameSlug, friendsPlaying(account.id)) : "");
927
+ if (request.method === "GET" && path === `${game}/players/friends`) {
928
+ if (!account) return reply(401, { error: { code: "not_signed_in", message: "Sign in to see your friends." } });
929
+ const room = (url.searchParams.get("room") ?? "").toUpperCase();
930
+ return fragment(glass.friendRowsHtml(gameSlug, friendsPlaying(account.id).filter((friend) => friend.roomCode !== room)));
931
+ }
932
+ if (request.method === "GET" && path === "/friends/among") {
933
+ const given = (url.searchParams.get("player_ids") ?? "").split(",").filter(Boolean);
934
+ if (given.length > 50 || given.some((playerId) => !/^player_[a-z0-9]{1,40}$/.test(playerId))) return reply(400, { error: { code: "bad_player_ids", message: "player_ids takes up to 50 player IDs, separated by commas." } });
935
+ const friendIds = new Set(account ? friendIdsOf(account.id) : []);
936
+ const players = given.map((playerId) => accountOfPlayer(playerId)).filter((other) => other && other.id !== account?.id).map((other) => ({ player_id: other.playerId, name: other.username, is_friend: friendIds.has(other.id) }));
937
+ return reply(200, { players });
938
+ }
939
+ const blockMatch = /^\/friends\/blocks\/(player_[a-z0-9]{1,40})$/.exec(path);
940
+ if (blockMatch) {
941
+ const target = accountOfPlayer(blockMatch[1]);
942
+ if (!account || !target || target.id === account.id) return reply(404, { error: { code: "no_player", message: "No account to block there." } });
943
+ if (isPost) {
944
+ await readText(request);
945
+ // A block ends a friendship too, and holds in every game's passes from the next one on.
946
+ state.friends = state.friends.filter((pair) => !isPair(pair, account.id, target.id));
947
+ if (!state.blocks.some((block) => block.blocker === account.id && block.blocked === target.id)) state.blocks.push({ blocker: account.id, blocked: target.id });
948
+ saveState();
949
+ log(`${account.username} blocked ${target.username}.`);
950
+ return reply(200, { is_blocked: true });
951
+ }
952
+ return reply(200, { player: { player_id: target.playerId, name: target.username }, is_friend: friendIdsOf(account.id).includes(target.id), is_blocked: state.blocks.some((block) => block.blocker === account.id && block.blocked === target.id) });
953
+ }
954
+ // A report goes to Zaisma's moderators, or the game's developer: here the terminal says what it would send.
955
+ if (isPost && path === `${game}/report`) {
956
+ const body = await readJson(request);
957
+ log(`Report (not sent anywhere): ${JSON.stringify({ reason: body.reason, details: body.details, room: body.room, player: body.player?.name ?? null, hasPicture: typeof body.picture === "string" })}`);
958
+ return reply(200, { is_game_hidden: false, is_update_paused: false });
959
+ }
960
+ if (isPost && path === `/api/links/${gameSlug}`) {
961
+ await readText(request);
962
+ logOnce("Only on Zaisma: the game's registered links (Zaisma.links.open). They're set on Zaisma, which opens them there.");
963
+ return reply(404, { error: { code: "unknown_link", message: "The bench has none of the game's registered links: they're set on Zaisma." } });
964
+ }
965
+
966
+ // Signing in: Zaisma's sign-in and Make an account go to the bench's Players panel, as a page of its own.
967
+ if ((path === "/signin" || path === "/join") && request.method === "GET") {
968
+ const place = { playerNumber, isPage: true };
969
+ return sendPage(response, await glass.panelPage(placesFor(request), place, panelHtml(new URL("/bench/panel", url), place)));
970
+ }
971
+ if (path === "/bench/panel" && request.method === "GET") {
972
+ const askedNumber = Number(url.searchParams.get("player"));
973
+ const place = { playerNumber: Number.isInteger(askedNumber) && askedNumber > 0 ? askedNumber : playerNumber, isPage: url.searchParams.get("page") === "yes" };
974
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
975
+ return response.end(panelHtml(url, place));
976
+ }
977
+ if (path === "/bench/api" || path.startsWith("/bench/api/")) return answerApi(request, response, url);
978
+ if (path === "/account/kept" && request.method === "GET") {
979
+ const keptNumber = Number(url.searchParams.get("player")) || 0;
980
+ return sendPage(response, await glass.keptPage(placesFor(request), playingAccount(keptNumber)?.username ?? null, keptNumber));
981
+ }
982
+ if (path === "/bench/kept.js") {
983
+ response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" });
984
+ return response.end(KEPT_SCRIPT_SOURCE);
985
+ }
986
+ return null;
987
+ }
988
+
989
+ /** The dev page's own calls. */
990
+ async function answerBench(request, response, url) {
991
+ const reply = (status, body, headers = {}) => {
992
+ response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
993
+ response.end(JSON.stringify(body));
994
+ };
995
+ const path = url.pathname;
996
+ const givenNumber = Number(url.searchParams.get("player"));
997
+ const playerNumber = Number.isInteger(givenNumber) && givenNumber > 0 ? givenNumber : 0;
998
+ if (path === "/bench/state" && request.method === "GET") {
999
+ if (playerNumber) heardAtMs.set(playerNumber, now());
1000
+ const roomCode = playerNumber ? roomOfPlayerId(playerIdOf(playerNumber)) : null;
1001
+ if (roomCode) lastRoomCodes.set(playerNumber, roomCode);
1002
+ return reply(200, { reloadNumber, roomCode, openPlayerNumbers: openPlayerNumbers() });
1003
+ }
1004
+ if (path === "/bench/claim" && request.method === "POST") {
1005
+ const given = await readJson(request);
1006
+ return reply(200, { playerNumber: claimPlayer(given.keptPlayerNumber) });
1007
+ }
1008
+ if (path === "/bench/arrive.js") {
1009
+ response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" });
1010
+ return response.end(ARRIVE_SCRIPT_SOURCE);
1011
+ }
1012
+ if (path === "/bench/leave" && request.method === "POST") {
1013
+ heardAtMs.delete(playerNumber);
1014
+ return reply(200, { isLeft: true });
1015
+ }
1016
+ const sheetMatch = /^\/bench\/sheet\/(phone|log)$/.exec(path);
1017
+ if (sheetMatch && request.method === "GET") {
1018
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
1019
+ return response.end(glass.devSheetBody(sheetMatch[1], devViewFor(playerNumber)));
1020
+ }
1021
+ if (path === "/bench/dev.js") {
1022
+ response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" });
1023
+ return response.end(devScriptSource());
1024
+ }
1025
+ if (path === "/bench/qr.svg") {
1026
+ const target = url.searchParams.get("text") ?? "";
1027
+ if (!target || target.length > 200) return reply(400, { error: { code: "bad_text", message: "text isn't a link of up to 200 characters.", fix: "Give the address to show." } });
1028
+ response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "no-store" });
1029
+ return response.end(qrCodeSvg(target));
1030
+ }
1031
+ return reply(404, { error: { code: "not_local", message: `${request.method} ${path} isn't a call the bench makes.`, fix: "Open the bench at its address." } });
1032
+ }
1033
+
1034
+ const benchServer = createServer(async (request, response) => {
1035
+ try {
1036
+ const url = new URL(request.url ?? "/", "http://bench");
1037
+ // The dev page: a new player gets their number first, then the page frames their game page.
1038
+ if (url.pathname === "/" && request.method === "GET") {
1039
+ const givenNumber = Number(url.searchParams.get("player"));
1040
+ let room = /^[A-Z0-9]{6}$/.test(url.searchParams.get("room") ?? "") ? url.searchParams.get("room") : null;
1041
+ if (!Number.isInteger(givenNumber) || givenNumber < 1 || givenNumber > 9999) {
1042
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
1043
+ return response.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Test bench</title><body style="margin:0;background:#000"><script src="/bench/arrive.js"></script>`);
1044
+ }
1045
+ heardAtMs.set(givenNumber, now());
1046
+ // Back from a sign-in that left the page: into the room the player was in, while it's still going.
1047
+ const lastRoom = lastRoomCodes.get(givenNumber);
1048
+ if (!room && lastRoom && roomsTracked.has(lastRoom)) room = lastRoom;
1049
+ const framePath = `/p/${givenNumber}/g/${gameSlug}${room ? `?room=${room}` : "?play"}`;
1050
+ return sendPage(response, await glass.devPage(placesFor(request), devViewFor(givenNumber), { path: framePath, allow: FRAME_ALLOW }));
1051
+ }
1052
+ if (url.pathname.startsWith("/bench/") && !/^\/bench\/(kept\.js|panel|api)(\/|$)/.test(url.pathname)) return await answerBench(request, response, url);
1053
+ const answered = await answerSite(request, response, url);
1054
+ if (answered !== null || response.headersSent) return;
1055
+ // Logos, favicons, the loader and the font, from the brand folder as the site serves it.
1056
+ const brand = brandFile(url.pathname);
1057
+ if (brand) {
1058
+ response.writeHead(200, { "Content-Type": TYPES[extname(brand).toLowerCase()] ?? "application/octet-stream", "Cache-Control": "no-cache" });
1059
+ return response.end(readFileSync(brand));
1060
+ }
1061
+ // The site's stylesheet and scripts, by the paths its page names.
1062
+ const asset = await glass.siteAsset(url.pathname);
1063
+ if (asset) {
1064
+ response.writeHead(asset.httpStatus, asset.headers);
1065
+ return response.end(asset.body);
1066
+ }
1067
+ logOnce(`Only on Zaisma: ${request.method} ${url.pathname}`);
1068
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1069
+ response.end(`${url.pathname} is only on Zaisma. The bench is at /.`);
1070
+ } catch (error) {
1071
+ log(`The bench couldn't answer ${request.url}: ${error instanceof Error ? error.message : error}`);
1072
+ if (!response.headersSent) response.writeHead(500);
1073
+ response.end();
1074
+ }
1075
+ });
1076
+
1077
+ // --- The game's site ---
1078
+ const connectOriginsFor = (request) => [originFor(request, ports.rooms), originFor(request, ports.rooms).replace(/^http/, "ws"), originFor(request, ports.bench), originFor(request, ports.game)];
1079
+ const gameServer = createServer(async (request, response) => {
1080
+ try {
1081
+ const url = new URL(request.url ?? "/", "http://game");
1082
+ const benchOrigin = originFor(request, ports.bench);
1083
+ const headersFor = (contentType, cacheControl = "no-cache") => ({ ...localGameHeaders(contentType, benchOrigin, connectOriginsFor(request)), "Cache-Control": cacheControl });
1084
+ const send = (status, contentType, body, cacheControl) => {
1085
+ response.writeHead(status, headersFor(contentType, cacheControl));
1086
+ response.end(request.method === "HEAD" ? undefined : body);
1087
+ };
1088
+ let path;
1089
+ try {
1090
+ path = decodeURIComponent(url.pathname).replace(/^\/+/, "");
1091
+ } catch {
1092
+ return send(400, "text/plain; charset=utf-8", "Bad address.");
1093
+ }
1094
+ // The game page frames the game at /<slug>/, as Zaisma's games Worker serves a game by path.
1095
+ if (path === gameSlug) {
1096
+ response.writeHead(301, { Location: `/${gameSlug}/${url.search}` });
1097
+ return response.end();
1098
+ }
1099
+ if (path.startsWith(`${gameSlug}/`)) path = path.slice(gameSlug.length + 1);
1100
+ if (path.startsWith("_zaisma/")) {
1101
+ const js = "text/javascript; charset=utf-8";
1102
+ if (path === "_zaisma/v1.js") {
1103
+ const config = { rooms: originFor(request, ports.rooms), game: gameSlug, site: benchOrigin, version: LOCAL_VERSION_ID, purchaseDelivery: state.purchaseDelivery };
1104
+ return send(200, js, withSetup("__ZAISMA__", config, sdkText("zaisma.js")));
1105
+ }
1106
+ if (path === "_zaisma/v1.d.ts") return send(200, "text/plain; charset=utf-8", sdkText("zaisma.d.ts"));
1107
+ if (path === "_zaisma/relay.js") return send(200, js, withSetup("__ZAISMA_RELAY__", { rooms: originFor(request, ports.rooms), game: gameSlug, site: benchOrigin, ownRelayHosts: [], fetchEarly: false }, sdkText("relay.js")));
1108
+ if (path === "_zaisma/picture.js") return send(200, js, withSetup("__ZAISMA_PICTURE__", { site: benchOrigin }, sdkText("picture.js")));
1109
+ if (path === "_zaisma/saves.js") {
1110
+ const versionId = url.searchParams.get("version_id");
1111
+ return send(200, js, withSetup("__ZAISMA_SAVES__", { site: benchOrigin, saveLimitBytes: SAVE_LIMIT_BYTES, versionId: versionId === LOCAL_VERSION_ID ? versionId : null }, sdkText("saves.js")));
1112
+ }
1113
+ if (path === "_zaisma/restore") return send(200, "text/html; charset=utf-8", `<!doctype html><meta charset="utf-8"><title>Loading</title><body style="margin:0;background:#000"><script src="saves.js?v=${SCRIPTS_VERSION_NUMBER}&version_id=${LOCAL_VERSION_ID}"></script>`);
1114
+ // Zaisma's measuring of how the game runs, and its warm-up before Play: here they load and do nothing.
1115
+ if (path === "_zaisma/performance.js" || path === "_zaisma/warm.js") return send(200, js, `// ${path} is only on Zaisma: it does nothing here.\n`);
1116
+ if (path === "_zaisma/warm") return send(200, "text/html; charset=utf-8", "<!doctype html><title>Only on Zaisma</title>");
1117
+ if (path === "_zaisma/warm.json") return send(200, "application/json", "[]");
1118
+ return send(404, "text/plain; charset=utf-8", "Not found.");
1119
+ }
1120
+ const asked = path === "" || path.endsWith("/") ? `${path}index.html` : path;
1121
+ const file = resolve(gameFolder, asked);
1122
+ // Judged on where the path lands, not how it was written (an encoded slash, .., another letter case), so the bench's
1123
+ // own folder (its signing keys) and the game server's files are never served, as on Zaisma.
1124
+ const relative = relativePath(gameFolder, file).split(sep).join("/");
1125
+ const inside = relative !== "" && !relative.startsWith("../") && relative !== ".." && !isAbsolute(relative);
1126
+ if (!inside || relative.split("/")[0].toLowerCase() === LOCAL_FOLDER || isServerFile(relative)) return send(404, "text/plain; charset=utf-8", "File not found: this game is missing a file.");
1127
+ let target = file;
1128
+ if (existsSync(target) && statSync(target).isDirectory()) target = join(target, "index.html");
1129
+ if (!existsSync(target)) return send(404, "text/plain; charset=utf-8", "File not found: this game is missing a file.");
1130
+ const contentType = TYPES[extname(target).toLowerCase()] ?? "application/octet-stream";
1131
+ const bytes = readFileSync(target);
1132
+ if (contentType.startsWith("text/html")) {
1133
+ const injected = withScripts(bytes.toString("utf8"), [`/_zaisma/saves.js?v=${SCRIPTS_VERSION_NUMBER}&version_id=${LOCAL_VERSION_ID}`]);
1134
+ return send(200, contentType, injected);
1135
+ }
1136
+ const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.range ?? "");
1137
+ if (range && (range[1] || range[2])) {
1138
+ const startByte = range[1] ? Number(range[1]) : Math.max(0, bytes.length - Number(range[2]));
1139
+ const endByte = range[1] && range[2] ? Math.min(Number(range[2]), bytes.length - 1) : bytes.length - 1;
1140
+ if (startByte > endByte || startByte >= bytes.length) {
1141
+ response.writeHead(416, { "Content-Range": `bytes */${bytes.length}` });
1142
+ return response.end();
1143
+ }
1144
+ response.writeHead(206, { ...headersFor(contentType), "Accept-Ranges": "bytes", "Content-Range": `bytes ${startByte}-${endByte}/${bytes.length}`, "Content-Length": endByte - startByte + 1 });
1145
+ return response.end(request.method === "HEAD" ? undefined : bytes.subarray(startByte, endByte + 1));
1146
+ }
1147
+ response.writeHead(200, { ...headersFor(contentType), "Accept-Ranges": "bytes" });
1148
+ response.end(request.method === "HEAD" ? undefined : bytes);
1149
+ } catch (error) {
1150
+ log(`The game's site couldn't answer ${request.url}: ${error instanceof Error ? error.message : error}`);
1151
+ if (!response.headersSent) response.writeHead(500);
1152
+ response.end();
1153
+ }
1154
+ });
1155
+
1156
+ // --- The stand-in rooms: Zaisma's rooms origin, answering the calls the SDK makes ---
1157
+ const roomsProfile = (account, benchOrigin) => ({ id: account.playerId, name: account.username, username: account.username, display_name: account.username, bio: "", link_urls: [], profile_url: `${benchOrigin}/players/${account.username}`, is_profile_private: false, published_games: [], played_games: [], playing_now: null });
1158
+ const roomsServer = createServer(async (request, response) => {
1159
+ const origin = request.headers.origin;
1160
+ const url = new URL(request.url ?? "/", "http://rooms");
1161
+ const path = url.pathname;
1162
+ const cors = isGameOrigin(origin) ? { "Access-Control-Allow-Origin": origin, Vary: "Origin" } : {};
1163
+ const reply = (status, body, headers = {}) => {
1164
+ response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...cors, ...headers });
1165
+ response.end(JSON.stringify(body));
1166
+ };
1167
+ const fail = (status, code, message) => reply(status, { error: { code, message } });
1168
+ const notLocal = () => fail(404, "not_local", `${request.method} ${path} is a Zaisma call the local bench has no stand-in for. It works once the game is on Zaisma.`);
1169
+ try {
1170
+ if (request.method === "OPTIONS") {
1171
+ response.writeHead(204, { ...cors, "Access-Control-Allow-Methods": "GET, POST", "Access-Control-Allow-Headers": "Content-Type" });
1172
+ return response.end();
1173
+ }
1174
+ if (path === "/.well-known/jwks.json") return reply(200, jwks([passSigning.publicKey]), { "Access-Control-Allow-Origin": "*" });
1175
+ if (path === "/.well-known/receipt-jwks.json") return reply(200, jwks([receiptSigning.publicKey]), { "Access-Control-Allow-Origin": "*" });
1176
+ const readBody = async () => {
1177
+ const chunks = [];
1178
+ for await (const chunk of request) chunks.push(chunk);
1179
+ const text = Buffer.concat(chunks).toString("utf8");
1180
+ try {
1181
+ const body = JSON.parse(text || "{}");
1182
+ return body && typeof body === "object" ? body : {};
1183
+ } catch {
1184
+ return {};
1185
+ }
1186
+ };
1187
+ const playerMatch = /^\/v1\/players\/([a-z0-9_-]{1,60})$/.exec(path);
1188
+ if (playerMatch && request.method === "GET") {
1189
+ const account = accountOfPlayer(playerMatch[1]);
1190
+ if (!account) return fail(404, "no_player", "No stand-in account has that player ID. Guests have no profile.");
1191
+ return reply(200, { player: roomsProfile(account, originFor(request, ports.bench)) }, { "Access-Control-Allow-Origin": "*" });
1192
+ }
1193
+ // The developer API the game server calls to hand over purchases (game_server delivery), stood in for.
1194
+ const developerMatch = /^\/api\/dev\/games\/([a-z0-9-]{1,40})(\/.*)?$/.exec(path);
1195
+ if (developerMatch) {
1196
+ const rest = developerMatch[2] ?? "";
1197
+ if (request.method === "GET" && rest === "/items") return reply(200, { items: Object.fromEntries(state.items.map((listedItem) => [listedItem.itemName, { item_name: listedItem.itemName }])), purchase_delivery: state.purchaseDelivery });
1198
+ const forPlayer = /^\/players\/([a-z0-9_-]+)\/items$/.exec(rest);
1199
+ if (request.method === "GET" && forPlayer) {
1200
+ const waiting = state.purchases.filter((purchase) => purchase.playerId === forPlayer[1] && !purchase.isHandled);
1201
+ const unhandled = await Promise.all(waiting.map(async (purchase) => {
1202
+ const given = await purchaseFor(purchase);
1203
+ return { id: given.id, item_name: given.itemName, item_kind: given.itemKind, item_count: given.itemCount, purchase_source: given.purchaseSource, purchase_status: given.purchaseStatus, game_account_id: given.gameAccountId, paid_minor_units: null, currency_code: null, purchased_at: given.purchasedAt, refunded_at: null, receipt: given.receipt };
1204
+ }));
1205
+ return reply(200, { unhandled_purchases: unhandled });
1206
+ }
1207
+ const handled = /^\/purchases\/(purchase_[a-z0-9]+)\/handled$/.exec(rest);
1208
+ if (request.method === "POST" && handled) {
1209
+ const purchase = state.purchases.find((each) => each.id === handled[1]);
1210
+ if (!purchase) return fail(404, "no_purchase", "The bench has no purchase by that ID.");
1211
+ if (purchase.isHandled) return fail(409, "already_handled", "That purchase is already handled.");
1212
+ purchase.isHandled = true;
1213
+ saveState();
1214
+ return reply(200, { isHandled: true });
1215
+ }
1216
+ return notLocal();
1217
+ }
1218
+ if (request.method === "POST" && ["/v1/pass", "/v1/rooms", "/v1/report", "/v1/filter", "/v1/friends", "/v1/friends/room", "/v1/items/unhandled", "/v1/items/handled"].includes(path)) {
1219
+ if (!isGameOrigin(origin)) return fail(403, "bad_origin", "Not allowed from here.");
1220
+ const body = await readBody();
1221
+ if (body.gameSlug !== gameSlug) return fail(404, "game_unavailable", `The bench plays ${gameSlug}, not ${String(body.gameSlug)}.`);
1222
+ if (path === "/v1/pass") {
1223
+ const given = typeof body.roomKey === "string" ? roomKeys.get(body.roomKey) : null;
1224
+ if (!given) return fail(403, "unchecked", "Zaisma couldn't check this browser, so it can't play online. Reload the game to try again.");
1225
+ return reply(200, await passFor(given.as, given.playerNumber));
1226
+ }
1227
+ if (path === "/v1/rooms") return reply(200, { roomCode: roomCode() });
1228
+ if (path === "/v1/filter") return reply(200, { isAllowed: true, reason: null, message: null, hasSwearing: false, text: String(body.text ?? ""), is_stand_in: true });
1229
+ if (path === "/v1/report") {
1230
+ log(`Report (not sent anywhere): ${JSON.stringify({ roomCode: body.roomCode, playerId: body.playerId, playerName: body.playerName, reason: body.reason, details: body.details })}`);
1231
+ return reply(200, { isReported: true });
1232
+ }
1233
+ const claims = await claimsOf(body.pass);
1234
+ if (!claims) return fail(401, "bad_pass", "This player's pass didn't check out. Zaisma.player.getPass() gets a new one.");
1235
+ if (path === "/v1/friends/room") {
1236
+ if (body.serverUrl === null && body.roomCode === null) ownRooms.delete(claims.sub);
1237
+ else ownRooms.set(claims.sub, { roomCode: String(body.roomCode ?? ""), friendJoinsEnabled: body.friendJoinsEnabled !== false });
1238
+ return reply(200, { isSet: true });
1239
+ }
1240
+ if (path === "/v1/friends") {
1241
+ if (claims.kind !== "account") return fail(403, "account_needed", "Friends are for players signed in to Zaisma. Ask a guest to sign in first (Zaisma.player.signIn).");
1242
+ const me = accountOfPlayer(claims.sub);
1243
+ // Friends playing the game now, as on the game page: Join's room, or why not.
1244
+ const friends = friendsPlaying(me?.id ?? "").map((friend) => ({ player_id: friend.playerId, username: friend.username, room_code: friend.roomCode, server_url: null, join_refusal: friend.roomCode ? null : friend.ownRoom ? "private_room" : "friend_not_in_room" }));
1245
+ return reply(200, { friends });
1246
+ }
1247
+ // /v1/items/unhandled and /v1/items/handled, as apps/rooms answers them. Guests own nothing.
1248
+ if (path === "/v1/items/handled") {
1249
+ if (state.purchaseDelivery !== "game") return fail(409, "handled_by_server", `This game's purchases go to its ${state.purchaseDelivery === "game_server" ? "game server's onPurchase" : "own server"} (purchase_delivery ${state.purchaseDelivery}), which marks them handled.`);
1250
+ const purchase = state.purchases.find((each) => each.id === body.purchaseId && each.playerId === claims.sub);
1251
+ if (!purchase) return fail(404, "no_purchase", 'This player has no purchase with that ID in this game: take it from Zaisma.items.on("purchase") or unhandled().');
1252
+ if (purchase.isHandled) return fail(409, "already_handled", "It was marked handled already.");
1253
+ purchase.isHandled = true;
1254
+ saveState();
1255
+ return reply(200, { isHandled: true });
1256
+ }
1257
+ if (claims.kind !== "account") return reply(200, { purchases: [] });
1258
+ const waiting = state.purchases.filter((purchase) => purchase.playerId === claims.sub && !purchase.isHandled);
1259
+ return reply(200, { purchases: await Promise.all(waiting.map(purchaseFor)) });
1260
+ }
1261
+ const requestMatch = /^\/v1\/requests\/([a-z0-9-]{1,40})$/.exec(path);
1262
+ if (requestMatch && request.method === "POST") {
1263
+ if (!isGameOrigin(origin)) return fail(403, "bad_origin", "Not allowed from here.");
1264
+ const chunks = [];
1265
+ for await (const chunk of request) chunks.push(chunk);
1266
+ const upstream = await fetch(`http://127.0.0.1:${ports.runner}${path}`, { method: "POST", headers: { "content-type": "text/plain", origin: localhostOrigin(ports.game) }, body: Buffer.concat(chunks) }).catch(() => null);
1267
+ if (!upstream) return fail(503, "busy", "The game server is starting again. Try again in a moment.");
1268
+ response.writeHead(upstream.status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...cors });
1269
+ return response.end(Buffer.from(await upstream.arrayBuffer()));
1270
+ }
1271
+ const listMatch = /^\/v1\/rooms\/([a-z0-9-]{1,40})$/.exec(path);
1272
+ if (listMatch && request.method === "GET") {
1273
+ if (!isGameOrigin(origin)) return fail(403, "bad_origin", "Not allowed from here.");
1274
+ const listed = [...roomsTracked].filter(([, room]) => room.isListed && [...room.sockets].some((each) => !each.isSpectator));
1275
+ const rooms = listed.map(([code, room]) => ({
1276
+ roomCode: code, playerCount: [...room.sockets].filter((each) => !each.isSpectator).length, heldSeatCount: 0, maximumPlayerCount: room.maximumPlayerCount, spectatorCount: [...room.sockets].filter((each) => each.isSpectator).length,
1277
+ maximumSpectatorCount: 0, isOpen: room.isOpen, location: null, isNear: true, name: room.name, labels: room.labels, isKept: false, inviteUrl: `${originFor(request, ports.bench)}/g/${gameSlug}?room=${code}`,
1278
+ }));
1279
+ let totalPlayerCount = 0;
1280
+ for (const room of rooms) totalPlayerCount += room.playerCount;
1281
+ return reply(200, { rooms, playing: { playerCount: totalPlayerCount, roomCount: rooms.length, closedRoomCount: rooms.filter((room) => !room.isOpen).length }, joinableRoomCount: rooms.filter((room) => room.isOpen && room.playerCount < room.maximumPlayerCount).length, openRoomCount: rooms.filter((room) => room.isOpen).length, regions: [], region: null, moreOpenRoomCount: 0, filters: [] });
1282
+ }
1283
+ const itemsMatch = /^\/v1\/items\/([a-z0-9-]{1,40})$/.exec(path);
1284
+ if (itemsMatch && request.method === "GET") {
1285
+ if (!isGameOrigin(origin)) return fail(403, "bad_origin", "Not allowed from here.");
1286
+ return reply(200, { items: state.items.filter((listedItem) => listedItem.priceMinorUnits !== null && listedItem.currencyCode).map((listedItem) => ({ itemName: listedItem.itemName, title: listedItem.title, description: listedItem.description, itemKind: listedItem.itemKind, itemCategory: listedItem.itemCategory, priceMinorUnits: listedItem.priceMinorUnits, currencyCode: listedItem.currencyCode, priceText: priceText(listedItem.currencyCode, listedItem.priceMinorUnits) })) });
1287
+ }
1288
+ return notLocal();
1289
+ } catch (error) {
1290
+ log(`The stand-in rooms couldn't answer ${request.method} ${path}: ${error instanceof Error ? error.message : error}`);
1291
+ if (!response.headersSent) fail(500, "busy", "The bench couldn't answer. Its terminal says why.");
1292
+ }
1293
+ });
1294
+
1295
+ // WebSockets to a room go straight to the game server, which checks the pass; the bench only watches the first and the welcome to list rooms.
1296
+ const sockets = new WebSocketServer({ noServer: true });
1297
+ roomsServer.on("upgrade", (request, socket, head) => {
1298
+ const match = /^\/v1\/rooms\/([a-z0-9-]{1,40})\/([A-Z0-9]{6})$/.exec(new URL(request.url ?? "/", "http://rooms").pathname);
1299
+ if (!match || !isGameOrigin(request.headers.origin)) {
1300
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
1301
+ return socket.destroy();
1302
+ }
1303
+ sockets.handleUpgrade(request, socket, head, (client) => proxyRoom(client, match[2], new URL(request.url, "http://rooms").pathname));
1304
+ });
1305
+ function proxyRoom(client, code, path) {
1306
+ const upstream = new WebSocket(`ws://127.0.0.1:${ports.runner}${path}`, { headers: { origin: localhostOrigin(ports.game) } });
1307
+ const seen = { isSpectator: false, playerId: null };
1308
+ const waiting = [];
1309
+ const room = () => roomsTracked.get(code);
1310
+ const forget = () => {
1311
+ const tracked = room();
1312
+ if (!tracked) return;
1313
+ tracked.sockets.delete(seen);
1314
+ if (!tracked.sockets.size) roomsTracked.delete(code);
1315
+ };
1316
+ const closeCode = (givenCode) => (Number.isInteger(givenCode) && givenCode >= 1000 && givenCode <= 4999 && ![1004, 1005, 1006, 1015].includes(givenCode) ? givenCode : 1011);
1317
+ client.on("message", (received, isBinary) => {
1318
+ if (!isBinary) {
1319
+ try {
1320
+ const hello = JSON.parse(received.toString());
1321
+ if (hello?.type === "hello") {
1322
+ seen.isSpectator = hello.isSpectator === true;
1323
+ seen.playerId = typeof hello.pass === "string" ? (JSON.parse(Buffer.from(hello.pass.split(".")[1] ?? "", "base64url").toString("utf8")).sub ?? null) : null;
1324
+ const made = hello.room ?? {};
1325
+ const tracked = roomsTracked.get(code) ?? { sockets: new Set(), isListed: false, name: null, labels: {}, maximumPlayerCount: 100, isOpen: true };
1326
+ if (made.isListed === true) tracked.isListed = true;
1327
+ if (typeof made.name === "string") tracked.name = made.name;
1328
+ if (made.labels && typeof made.labels === "object") tracked.labels = made.labels;
1329
+ roomsTracked.set(code, tracked);
1330
+ tracked.sockets.add(seen);
1331
+ }
1332
+ } catch {
1333
+ // Not a hello the bench can read: the game server answers it.
1334
+ }
1335
+ }
1336
+ if (upstream.readyState === WebSocket.OPEN) upstream.send(received, { binary: isBinary });
1337
+ else waiting.push([received, isBinary]);
1338
+ });
1339
+ upstream.on("open", () => {
1340
+ for (const [received, isBinary] of waiting.splice(0)) upstream.send(received, { binary: isBinary });
1341
+ });
1342
+ upstream.on("message", (received, isBinary) => {
1343
+ if (!isBinary) {
1344
+ try {
1345
+ const message = JSON.parse(received.toString());
1346
+ if (message?.type === "welcome" && room()) {
1347
+ const settings = message.room ?? {};
1348
+ if (typeof settings.maximumPlayerCount === "number") room().maximumPlayerCount = settings.maximumPlayerCount;
1349
+ if (typeof settings.isOpen === "boolean") room().isOpen = settings.isOpen;
1350
+ if (typeof settings.isListed === "boolean") room().isListed = settings.isListed;
1351
+ }
1352
+ } catch {
1353
+ // Not JSON the bench reads.
1354
+ }
1355
+ }
1356
+ if (client.readyState === WebSocket.OPEN) client.send(received, { binary: isBinary });
1357
+ });
1358
+ upstream.on("close", (upstreamCloseCode, reason) => {
1359
+ forget();
1360
+ if (client.readyState === WebSocket.OPEN) client.close(closeCode(upstreamCloseCode), reason.toString().slice(0, 120));
1361
+ });
1362
+ // The game server is starting again: the game hears "busy" and tries again, with its rooms back as they were.
1363
+ upstream.on("error", () => {
1364
+ forget();
1365
+ if (client.readyState === WebSocket.OPEN) client.close(BUSY_CLOSE_CODE, "busy");
1366
+ });
1367
+ client.on("close", () => {
1368
+ forget();
1369
+ upstream.close();
1370
+ });
1371
+ client.on("error", () => upstream.terminate());
1372
+ }
1373
+
1374
+ // --- The game server: the Node runner, as its own process, so a change starts it again with fresh code ---
1375
+ let runner = null;
1376
+ let runnerStartCount = 0;
1377
+ // The game server's source as the running one started with it: a change event with nothing changed (a late or repeated one) starts nothing.
1378
+ let runnerSource = null;
1379
+ let isClosing = false;
1380
+ const runnerFile = join(here, "local-play-runner.js");
1381
+ const entry = () => serverEntryOf(gameFolder);
1382
+ const roomsAddress = () => issuerUrl;
1383
+ function startRunner() {
1384
+ return new Promise((done) => {
1385
+ runnerSource = sourceOf(gameFolder);
1386
+ // Games that handle purchases in their game server get them there, as on Zaisma with purchase_delivery game_server,
1387
+ // read again at each start unless the delivery was copied from the game on Zaisma.
1388
+ const delivery = /\bonPurchase\b|\bonRefund\b/.test(runnerSource) ? "game_server" : "game";
1389
+ if (!state.isItemsCopied && state.purchaseDelivery !== delivery) {
1390
+ state.purchaseDelivery = delivery;
1391
+ saveState();
1392
+ tell("reload");
1393
+ }
1394
+ const config = { entry: entry(), gameSlug, port: ports.runner, origin: localhostOrigin(ports.game), issuerUrl: roomsAddress(), databaseFile: join(localFolder, "database.sqlite"), roomStateFile: join(localFolder, "room-states.sqlite"), developerApiUrl: `${roomsAddress()}/api/dev`, zaismaKey: LOCAL_RUNNER_KEY };
1395
+ const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", runnerFile, JSON.stringify(config)], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env } });
1396
+ let isReady = false;
1397
+ const timeout = setTimeout(() => {
1398
+ if (!isReady) {
1399
+ log("Your game server is slow to start. Its output is above.");
1400
+ done(child);
1401
+ }
1402
+ }, RUNNER_START_TIMEOUT_MS);
1403
+ const lines = (stream, label) => {
1404
+ let rest = "";
1405
+ stream.setEncoding("utf8");
1406
+ stream.on("data", (chunk) => {
1407
+ rest += chunk;
1408
+ const parts = rest.split("\n");
1409
+ rest = parts.pop();
1410
+ for (const line of parts) {
1411
+ if (line === "ZAISMA_LOCAL_RUNNER_READY" && !isReady) {
1412
+ isReady = true;
1413
+ clearTimeout(timeout);
1414
+ done(child);
1415
+ } else log(`${label}${line}`);
1416
+ }
1417
+ });
1418
+ };
1419
+ lines(child.stdout, "[game server] ");
1420
+ lines(child.stderr, "[game server] ");
1421
+ child.on("exit", (code) => {
1422
+ clearTimeout(timeout);
1423
+ if (runner === child) runner = null;
1424
+ if (!isReady) {
1425
+ if (!isClosing) log("Your game server didn't start. Fix what it says above and save the file: it starts again by itself.");
1426
+ done(null);
1427
+ } else if (!isClosing && !child.zaismaIsRestarting) log(`Your game server stopped (exit code ${code}). Save a game server file to start it again.`);
1428
+ });
1429
+ runnerStartCount++;
1430
+ }).then((child) => {
1431
+ runner = child;
1432
+ return child;
1433
+ });
1434
+ }
1435
+ function stopRunner() {
1436
+ const child = runner;
1437
+ if (!child) return Promise.resolve();
1438
+ child.zaismaIsRestarting = true;
1439
+ return new Promise((done) => {
1440
+ child.once("exit", () => done());
1441
+ child.kill("SIGTERM");
1442
+ setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
1443
+ });
1444
+ }
1445
+ let restartChain = Promise.resolve();
1446
+ const restartRunner = () => {
1447
+ restartChain = restartChain.then(async () => {
1448
+ if (isClosing) return;
1449
+ await stopRunner();
1450
+ await startRunner();
1451
+ if (runner) log("Your game server is running again, with its rooms as they were.");
1452
+ });
1453
+ return restartChain;
1454
+ };
1455
+
1456
+ // --- Watching the folder: the game server's files start it again, anything else reloads the game ---
1457
+ let watcher = null;
1458
+ let timer = null;
1459
+ let isServerChanged = false;
1460
+ let isOtherChanged = false;
1461
+ try {
1462
+ watcher = watch(gameFolder, { recursive: true }, (eventType, name) => {
1463
+ const relative = String(name ?? "").split(sep).join("/");
1464
+ if (!relative || relative === LOCAL_FOLDER || relative.startsWith(`${LOCAL_FOLDER}/`) || relative.startsWith(".git/") || relative.startsWith("node_modules/") || relative.endsWith(".DS_Store")) return;
1465
+ if (isServerFile(relative)) isServerChanged = true;
1466
+ else isOtherChanged = true;
1467
+ clearTimeout(timer);
1468
+ timer = setTimeout(() => {
1469
+ const wasServerChanged = isServerChanged;
1470
+ const wasOtherChanged = isOtherChanged;
1471
+ isServerChanged = isOtherChanged = false;
1472
+ if (wasServerChanged && (!runner || sourceOf(gameFolder) !== runnerSource)) {
1473
+ log("Your game server changed: starting it again.");
1474
+ void restartRunner();
1475
+ }
1476
+ if (wasOtherChanged) {
1477
+ log("A game file changed: the game reloads.");
1478
+ tell("reload");
1479
+ }
1480
+ }, SLOW_RESTART_MS);
1481
+ });
1482
+ } catch {
1483
+ log("This computer can't watch the folder for changes, so reload the game yourself after a change.");
1484
+ }
1485
+
1486
+ // --- Start everything ---
1487
+ // On the network each port takes both: a connection that opens with TLS's handshake (its first byte, 0x16) goes to HTTPS with the
1488
+ // bench's certificate, any other to plain http, so this computer keeps http://localhost and a phone that asks for HTTPS gets it.
1489
+ const secureServers = [];
1490
+ const fronts = [];
1491
+ const frontFor = (server) => {
1492
+ if (!certificate) return server;
1493
+ const secure = createSecureServer({ cert: certificate.certificatePem, key: certificate.keyPem });
1494
+ for (const event of ["request", "upgrade"]) for (const listener of server.listeners(event)) secure.on(event, listener);
1495
+ secureServers.push(secure);
1496
+ const front = createSocketServer((socket) => {
1497
+ socket.once("data", (firstChunk) => {
1498
+ socket.pause();
1499
+ socket.unshift(firstChunk);
1500
+ (firstChunk[0] === 0x16 ? secure : server).emit("connection", socket);
1501
+ process.nextTick(() => socket.resume());
1502
+ });
1503
+ socket.on("error", () => socket.destroy());
1504
+ });
1505
+ fronts.push(front);
1506
+ return front;
1507
+ };
1508
+ const listen = (server, portNumber) =>
1509
+ new Promise((done, failed) => {
1510
+ server.once("error", (error) => failed(error?.code === "EADDRINUSE" ? new GameServerError("bad_option", "port", `Port ${portNumber} is already in use.`, `Stop what's using ports ${port} to ${port + 3}, or give another first port: --port ${port + 10}.`) : error));
1511
+ server.listen(portNumber, host, () => done());
1512
+ });
1513
+ const listening = [[benchServer, ports.bench], [gameServer, ports.game], [roomsServer, ports.rooms]].map(([server, portNumber]) => [frontFor(server), portNumber]);
1514
+ try {
1515
+ for (const [server, portNumber] of listening) await listen(server, portNumber);
1516
+ } catch (error) {
1517
+ for (const [server] of listening) if (server.listening) server.close();
1518
+ watcher?.close();
1519
+ throw error;
1520
+ }
1521
+ await startRunner();
1522
+
1523
+ const benchUrl = localhostOrigin(ports.bench);
1524
+ const networkUrl = networkOrigin;
1525
+ return {
1526
+ benchUrl,
1527
+ gameSiteUrl: localhostOrigin(ports.game),
1528
+ roomsUrl: issuerUrl,
1529
+ networkUrl,
1530
+ get runnerStartCount() {
1531
+ return runnerStartCount;
1532
+ },
1533
+ async close() {
1534
+ isClosing = true;
1535
+ clearTimeout(timer);
1536
+ watcher?.close();
1537
+ await restartChain.catch(() => {});
1538
+ await stopRunner();
1539
+ for (const front of fronts) front.close();
1540
+ await Promise.all([benchServer, gameServer, roomsServer, ...secureServers].map((server) => new Promise((done) => {
1541
+ server.close(() => done());
1542
+ server.closeAllConnections();
1543
+ })));
1544
+ for (const client of sockets.clients) client.terminate();
1545
+ },
1546
+ };
1547
+ }
1548
+
1549
+ /** The bench's QR code for a phone, drawn with half blocks so two rows of modules take one line of the terminal. */
1550
+ export function terminalQr(text) {
1551
+ const modules = qrCodeModules(text);
1552
+ const marginModuleCount = 2;
1553
+ const sideModuleCount = modules.length + marginModuleCount * 2;
1554
+ const dark = (columnIndex, rowIndex) => modules[rowIndex - marginModuleCount]?.[columnIndex - marginModuleCount] === true;
1555
+ const lines = [];
1556
+ for (let rowIndex = 0; rowIndex < sideModuleCount; rowIndex += 2) {
1557
+ let line = "";
1558
+ for (let columnIndex = 0; columnIndex < sideModuleCount; columnIndex++) {
1559
+ const top = dark(columnIndex, rowIndex);
1560
+ const bottom = dark(columnIndex, rowIndex + 1);
1561
+ line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
1562
+ }
1563
+ lines.push(line);
1564
+ }
1565
+ // Dark modules print as the terminal's own light on dark, so the code is drawn inverted on a white block.
1566
+ return lines.map((line) => [...line].map((each) => ({ "█": " ", "▀": "▄", "▄": "▀", " ": "█" })[each]).join("")).join("\n");
1567
+ }