@homie-rocks/studio 0.53.0 → 0.55.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 (90) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/app/SKILL.md +2 -2
  4. package/guides/shop/SKILL.md +6 -0
  5. package/lib/app-needs.mjs +8 -1
  6. package/lib/background-queue.mjs +31 -0
  7. package/lib/build.mjs +5 -2
  8. package/lib/check.mjs +10 -2
  9. package/lib/cloudflare.mjs +16 -1
  10. package/lib/dev.mjs +8 -1
  11. package/lib/read-replication.mjs +26 -0
  12. package/lib/scaffold.mjs +46 -9
  13. package/lib/shop-links.mjs +37 -19
  14. package/lib/shop-mcp.mjs +7 -1
  15. package/lib/shop.mjs +7 -0
  16. package/lib/static-assets.mjs +27 -0
  17. package/lib/template-history.json +84 -0
  18. package/lib/worker-build.mjs +13 -4
  19. package/lib/worker-config.mjs +3 -1
  20. package/package.json +1 -1
  21. package/rules/view.ts +1 -1
  22. package/shop/SHOP.md +13 -9
  23. package/worker/analytics.mjs +84 -0
  24. package/worker/app-frame.mjs +1 -1
  25. package/worker/app-graphics.mjs +1 -1
  26. package/worker/app-records.mjs +4 -2
  27. package/worker/app-shell.mjs +9 -4
  28. package/worker/app-streams.mjs +27 -10
  29. package/worker/app-tools.mjs +1 -0
  30. package/worker/asset-paths.mjs +2 -0
  31. package/worker/background-queue.mjs +30 -0
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/built-metadata.mjs +2 -0
  34. package/worker/catalogue.mjs +18 -0
  35. package/worker/chat-page.mjs +1 -1
  36. package/worker/chat-store.mjs +5 -4
  37. package/worker/embed.mjs +1 -1
  38. package/worker/event-resources.mjs +29 -12
  39. package/worker/function-registry.mjs +4 -0
  40. package/worker/functions.mjs +7 -5
  41. package/worker/gate-routing.mjs +61 -0
  42. package/worker/gate.mjs +12 -68
  43. package/worker/hosted-registry.mjs +11 -0
  44. package/worker/hosted.mjs +3 -17
  45. package/worker/index.mjs +58 -2772
  46. package/worker/lazy-objects.mjs +40 -0
  47. package/worker/lobby-limits.mjs +2 -0
  48. package/worker/lobby.mjs +251 -0
  49. package/worker/lounge.mjs +3 -3
  50. package/worker/mcp-event-streams.mjs +3 -3
  51. package/worker/mcp-events.mjs +4 -4
  52. package/worker/mcp-store.mjs +12 -1
  53. package/worker/mcp.mjs +5 -1
  54. package/worker/office.mjs +105 -34
  55. package/worker/operational-schema.mjs +44 -0
  56. package/worker/page-policy.mjs +29 -0
  57. package/worker/pages.mjs +15 -29
  58. package/worker/parts.mjs +2 -1
  59. package/worker/payment-recovery.mjs +33 -19
  60. package/worker/player-registry.mjs +9 -0
  61. package/worker/players.mjs +8 -7
  62. package/worker/purchase-machine.mjs +5 -3
  63. package/worker/reconcile-guard.mjs +22 -0
  64. package/worker/request-db.mjs +78 -0
  65. package/worker/responses.mjs +18 -0
  66. package/worker/room-chat.mjs +216 -0
  67. package/worker/room-controls.mjs +296 -0
  68. package/worker/room-lifecycle.mjs +10 -0
  69. package/worker/room-protocol.mjs +197 -0
  70. package/worker/room.mjs +38 -731
  71. package/worker/rooms-cache.mjs +19 -0
  72. package/worker/routes.mjs +667 -0
  73. package/worker/runtime-support.mjs +445 -0
  74. package/worker/seats.mjs +1 -1
  75. package/worker/selling/index.mjs +1 -0
  76. package/worker/selling/runtime.mjs +7 -5
  77. package/worker/servers.mjs +5 -4
  78. package/worker/service-run.mjs +1 -1
  79. package/worker/shop-links.mjs +2 -2
  80. package/worker/shop-page.mjs +2 -0
  81. package/worker/shop-rules.mjs +3 -2
  82. package/worker/shop-store.mjs +4 -3
  83. package/worker/shop.mjs +89 -27
  84. package/worker/stats.mjs +7 -6
  85. package/worker/stream-objects.mjs +18 -0
  86. package/worker/stripe-intake.mjs +79 -0
  87. package/worker/stripe.mjs +3 -0
  88. package/worker/table.mjs +1212 -0
  89. package/worker/tool-registry.mjs +10 -0
  90. package/worker/version.mjs +1 -1
package/worker/index.mjs CHANGED
@@ -1,654 +1,82 @@
1
- import {appGraphicsFallback} from './app-graphics.mjs';
2
- import {JOIN_AREA_FRAME_JS} from './join-area.mjs';
1
+ /** Studio Worker event boundaries and public toolkit exports. */
2
+ import { catalogue } from './catalogue.mjs';
3
+ import {trackBackground,requestDatabase,MUTATION_VERSION} from './request-db.mjs';
4
+
3
5
  import {loadShopLinks} from './shop-links.mjs';
4
- import { directoryUrl } from './directory-choice.mjs';
5
- import {APP_STORAGE_JS} from './app-frame.mjs';
6
- import {recordsPage} from './records-page.mjs';
7
- import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
8
- import { characterStore, characterRequest } from './characters.mjs';
9
- export { useFunctions } from './functions.mjs';
10
- import { appAccess, appRecordsRoute } from './app-records.mjs';
11
- import { appRole, openPath } from './app-format.mjs';
12
- /**
13
- * @homie-rocks/studio/worker — a studio's own site, on the studio's own Cloudflare.
14
- *
15
- * One Worker serves the studio's pages and its public rooms (site/SITE.md: the sections, the landings, posts and
16
- * what a studio's site/ folder overrides):
17
- *
18
- * / Home: the featured game, live rooms, games, latest posts, videos, music
19
- * /games/ /rooms/ /posts/ the sections, as on homie.rocks (a section with nothing in it answers 404)
20
- * /music/ /videos/ songs and videos (music/manifest.json, videos/manifest.json)
21
- * /posts/<slug>/ a post (posts/*.md); /posts/feed.xml (Atom) and /posts/feed.json (JSON Feed)
22
- * /<game>/ the game's landing: hero, Play, phone / computer / TV, live rooms, how to play, credits
23
- * /<game>/credits the licence texts the game ships with
24
- * /<game>/live the landing's live line and rooms (JSON)
25
- * /<game>/play the play shell: asks the Lobby for a public room and boots the game in a
26
- * sandboxed frame, seated at once; the room goes into the address, and a small
27
- * room button shares it (Invite, Big screen, the room code)
28
- * /<game>/tv the big screen: the same room as a spectator, with a QR code phones scan to join
29
- * (also /<game>/play?screen=1)
30
- * /<game>/watch?room= watch a live room from any player's view: the game itself, rendered by this browser
31
- * as a watcher that never takes a seat, with a strip of the players to switch between
32
- * (keys 1-9, A for Auto, O for the overview); no room: the busiest public room
33
- * (NETPLAY.md section 16; game.json "watch": "overview" or false narrows it)
34
- * /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
35
- * /<game>/__net?room= the room's netplay socket (Table Durable Object)
36
- * /<game>/__watch?room= the room's facts, for the shell
37
- * /<game>/api/lobby which public room to join (Lobby Durable Object); ?server=<id>: a room of that server;
38
- * the one answer a standalone copy's app may read (worker/standalone.mjs)
39
- * /<game>/api/watch which public room to watch: the busiest one now (nothing is reserved)
40
- * /<game>/servers/ the game's servers (worker/servers.mjs): named, lasting room pools with their own
41
- * policy (open, humans-only, hybrid, beginner) and door; /<game>/api/servers as JSON
42
- * /<game>/s/<id>/ a server's page; /<game>/s/<id>/play its door, then the play page in its pool;
43
- * POST /<game>/s/<id>/home joins it, leaves it or makes it home (a signed-in player)
44
- * /<game>/api/agent an AI's seat (POST, Bearer agent pass; worker/agents.mjs): a room with people in it,
45
- * its ticket and socket, and the frame to load (NETPLAY.md section 17)
46
- * /api/games, /api/rooms the catalogue plus live counts; every public room playing now (/api/rooms may be
47
- * cached for 15 s)
48
- * /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
49
- * knocks for a Homie box, so it stops knocking (a studio site is not a box)
50
- * /.well-known/homie-studio.json what the homie.rocks directory reads
51
- * /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt for search engines and AI agents, made from the public
52
- * catalogue (worker/discover.mjs); every generated page also carries schema.org JSON-LD
53
- * (worker/schema.mjs)
54
- * /games/<id>/source.json 410, for a client that still asks: remix was retired, no game's source is served whole
55
- * /media/<key> a loose file in the studio's R2 (`media put`), with byte ranges
56
- * /music/<slug>/<file>, /videos/<slug>/<file> a song's or video's files: from the site's own files, or, for a
57
- * file `media move` put in the studio's R2 (the catalogue names its key), from R2 at the
58
- * same address, with byte ranges, HEAD, ETag and the site's own cache headers
59
- * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
60
- * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
61
- * /_studio/office the owner's back office: live rooms and who is in them, kick, mute, announce, close,
62
- * each game's launch state (private, invite-only beta, public), room size
63
- * and invites (worker/office.mjs); /_studio/api/* is the same as JSON
64
- * /<game>/invite an invite code spent for this browser's pass to an invite-only game
65
- * /_homie/site.js the pages' one script
66
- * /account/ a player's account: a passkey, a name, their data (worker/players.mjs, saves/SAVES.md)
67
- * /api/player/... sign in and up, saves, lifetime stats and memorials (the game's saves bridge, via the
68
- * play shell), export and delete
69
- * /<game>/api/chat/report a player reports one chat line (POST, this site's pages): the room's own copy of it is kept
70
- * for the owner, 30 days (worker/chat-store.mjs); room chat itself rides the sockets above
71
- * (NETPLAY.md section 19) and is never stored
72
- * /shop/, /api/shop/... the studio's shop (worker/shop.mjs, shop/SHOP.md): its items in real money, the studio's
73
- * OWN Stripe Checkout, the signed webhook, what a player owns (/api/player/owns), refunds;
74
- * closed until the owner's restricted key is in; never on a kids server or a television
75
- *
76
- * A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
77
- * `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
78
- *
79
- * It counts, and never tracks (worker/stats.mjs): a page opened, a Play press, a room opened, a round finished,
80
- * the peak of players, a song or video started, and which site sent the visitor (a host name), as daily counters
81
- * in the studio's own D1. No cookie on a visitor, nothing sent anywhere.
82
- *
83
- * Bindings (site/wrangler.jsonc, written by `homie-studio new`): ASSETS (the
84
- * built site), TABLE and LOBBY (SQLite-backed Durable Objects, free plan
85
- * friendly), DB (D1: plays, finished rounds, the directory claim) and, only
86
- * after `homie-studio storage add` (R2 needs a payment method on the account),
87
- * MEDIA. Everything else runs on Cloudflare's free Workers plan.
88
- *
89
- * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 7), run
90
- * unchanged inside the Table, so a game that plays in `homie-studio dev`
91
- * plays the same way here. A beginner server whose AI may talk also gets the
92
- * Table's own house guides (worker/agents.mjs HouseAgents), whose brains run
93
- * on the Table's alarm: Workers AI through the optional AI binding (deploy adds
94
- * it when a server uses it), or the owner's own key (secret HOMIE_BRAIN_KEY).
95
- */
96
- import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
97
- import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
98
- import { hostedGame, hostedBuild, compiledFor, prepareHost, startHost } from './hosted.mjs';
99
- import { roomStore, restoreDelay, LOOSE_SAVE } from './room-store.mjs';
6
+
7
+ import { errorLine } from './room-protocol.mjs';
8
+
100
9
  import { appCors, isAppOrigin } from './standalone.mjs';
101
- import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
102
- import {
103
- PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
104
- setMembership,
105
- } from './servers.mjs';
106
- import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
107
- import { CLEF, DECIDE, clefRun, costOf, localAiOf, picksOf, talks } from './brain.mjs';
108
- import { REACTIONS, reviewChat } from './chat.mjs';
109
- import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
110
- import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs';
111
- import { isLoungePath, loungeRoutes } from './lounge.mjs';
112
- import { doorPage, serverPage, serversPage } from './site.mjs';
113
- import { isLocalOrigin, qrSvg } from './qr.mjs';
114
- import { acceptMultiplex, diagnosticsOf, gateFor, gatePlayers, nextGate } from './gate.mjs';
115
- import { worldSource } from './world-wire.mjs';
116
- export { Gate, Concentrator } from './gate.mjs';
117
- import { seatCount, paramsFrom, perAddress, seatsOf } from './seats.mjs';
118
- import {
119
- SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homeLd, homePage, jsonFeed, landingLd, mediaArt, mediaIndexPage,
120
- notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
121
- } from './site.mjs';
122
- import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
123
- import { ownerRoutes } from './stats-page.mjs';
124
- import { playerRoutes, players as playerAccounts } from './players.mjs';
125
- import {
126
- appAssetTicket, accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
127
- ticketFor, ticketSub, usePlayers, verifyControl,
128
- } from './office.mjs';
129
10
 
130
- // The back office knows the studio's player accounts: a signed-in player is named in the office and held by a kick on
131
- // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
132
- usePlayers(playerAccounts);
133
- import { STUDIO_BUILD } from './build-id.mjs';
134
- import { STUDIO_VERSION_TAG } from './version.mjs';
135
- import { reconcileOrders, readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
136
- import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
137
- import { ldScript, studioNode } from './schema.mjs';
138
- import { licenseOf } from './license.mjs';
139
- // GAME PARTS (parts/PARTS.md section 4): three call-outs below, everything else is worker/parts.mjs.
140
- import { PART_FILES, isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
141
11
 
12
+
13
+ import {json,finish} from './responses.mjs';
14
+
15
+ export { useFunctions } from './function-registry.mjs';
16
+ export { Gate, Concentrator } from './gate.mjs';
142
17
  export { seatCount, SEAT_MAX } from './seats.mjs';
143
- // Server-hosted games (NETPLAY.md section 29): the studio's site/src/worker.mjs hands the build's rules over with this.
144
- export { hostRules } from './hosted.mjs';
145
- /** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
146
- export { usePlayers } from './office.mjs';
147
- /** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
18
+ export { hostRules } from './hosted-registry.mjs';
19
+ export {usePlayers} from './player-registry.mjs';
148
20
  export { countVisit } from './stats.mjs';
149
- /** Players and cloud saves (saves/SAVES.md): `players.list`, `players.get`, `players.isOwner`… for the back office. */
150
21
  export { players, cleanName } from './players.mjs';
151
- /** How a guest's saves combine with the account's when they sign in (saves/SAVES.md): the studio's Worker calls it once, at load. */
152
22
  export { mergeSaves } from './saves.mjs';
153
-
154
- const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
155
- const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
156
- /** A game's own files, framed by its play page: never x-frame-options DENY. */
157
- const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/(?:__game|__assets)(?:\/|$)/;
158
- const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
159
- /** A browser's room key (random, kept in the play page's own storage) and a play ticket, as they may appear in an address. */
160
- const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
161
- const TICKET_TEXT = /^[a-z0-9]{6,12}\.[A-Za-z0-9_~-]{1,140}\.[A-Za-z0-9_-]{32}$/;
162
-
163
- const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
164
- status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
165
- });
166
-
167
- /** The catalogue `homie-studio build` writes into the site (games.json). */
168
- const catalogueCache = new WeakMap();
169
- async function catalogue(env, origin) {
170
- try {
171
- const res = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
172
- if (!res.ok) return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] };
173
- const tag = res.headers.get('etag');
174
- const hit = catalogueCache.get(env.ASSETS);
175
- if (tag && hit?.tag === tag && hit.origin === origin) { await res.body?.cancel(); return hit.value; }
176
- const value = await res.json();
177
- if (tag) catalogueCache.set(env.ASSETS, { tag, origin, value });
178
- return value;
179
- } catch { return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] }; }
180
- }
181
-
182
- /** The posts with their HTML (site/dist/_site/posts.json), for a post's page and the feeds. */
183
- /** The studio's shop when it really sells (shop.json checked, the key, the webhook secret and its tables in): its items
184
- * are a landing's offers (worker/schema.mjs). Else null: a landing never names a price nobody can pay. */
185
- async function sellingShop(env, cat) {
186
- const shop = shopOf(cat);
187
- if (!shop || shop.broken) return null;
188
- try { return (await readiness(env, shop)).ready ? shop : null; } catch { return null; }
189
- }
190
-
191
- async function availableShellShop(env, cat, game, options) {
192
- // The shell can show a disabled catalog before payment setup is complete.
193
- // Checkout readiness is enforced by the shop API, separately from visibility.
194
- const preview = (cat.games ?? []).find(g => g.id === game)?.screen?.shopPreview === true;
195
- const shop = shopOf(cat);
196
- // Free services must retain their bridge in a mixed free/paid catalogue.
197
- // Paid readiness, policy and atomic booking capacity remain API decisions.
198
- const free = shop?.open && !shop.broken && shop.items.some(i => i.price === 0 && !i.interval && (!i.game || i.game === game));
199
- return (preview && !shop?.broken || free || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
200
- }
201
-
202
- async function postsOf(env, origin) {
203
- try {
204
- const res = await env.ASSETS.fetch(new Request(`${origin}/_site/posts.json`));
205
- return res.ok ? ((await res.json()).posts ?? []) : [];
206
- } catch { return []; }
207
- }
208
-
209
- /*
210
- * THE SITE CLAIMS ITSELF IN THE DIRECTORY (0.10.0). The homie.rocks directory lists a studio only when its site
211
- * serves the claim the directory handed out for that exact address, which proves the studio controls the site.
212
- * Before 0.10.0 `homie-studio deploy` fetched the claim and stored it in D1 from the person's computer; a site built
213
- * by Cloudflare's Workers Builds has no such step. So the site asks for its own claim the first time its manifest
214
- * is read (by the directory publishing it, or `deploy` reading it once), for the address it is being read at, and
215
- * keeps it in its D1 (`meta`, `homie_claim:<origin>`).
216
- *
217
- * It never asks from a Preview (HOMIE_PREVIEW: a Preview is a branch under review, not the studio), from a studio
218
- * whose studio.json says `homie.directory: false`, or without its D1. A directory that does not answer is asked
219
- * again at most once a minute per address; the page answers without a claim meanwhile.
220
- */
221
- const CLAIM = /^[a-f0-9]{16,128}$/;
222
- const claimed = new Map();
223
- const claimMissed = new Map();
224
-
225
- async function storedClaim(env, origin) {
226
- if (!env.DB) return null;
227
- try {
228
- const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2, ?3)').bind(`homie_claim:${origin}`, 'homie_claim', `homie_repo:${origin}`).all()).results ?? [];
229
- const of = (k) => rows.find((r) => r.key === k)?.value ?? null;
230
- return { own: of(`homie_claim:${origin}`), older: of('homie_claim'), repo: of(`homie_repo:${origin}`) };
231
- } catch { return null; }
232
- }
233
-
234
- /*
235
- * THE STUDIO'S GITHUB REPOSITORY (HOMIE_REPO, set by `homie-studio deploy` from the studio's own git remote or
236
- * studio.json "github"; lib/repo.mjs). The site tells its directory which repository it is built from, with its
237
- * claim, so the Claude app's hand-off opens Claude Code on the studio's own repository. It is never put in a public
238
- * page: only the directory hears it, from this Worker (Cloudflare's CF-Worker header names the zone it runs on),
239
- * and it is said again only when it changes.
240
- */
241
- const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
242
- const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return REPO.test(r) && r.toLowerCase() !== 'homie-rocks/homie' ? r : null; };
243
- const repoSaid = new Map();
244
-
245
- export function directoryOf(cat) {
246
- return directoryUrl(cat?.studio?.directory);
247
- }
248
-
249
- /** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
250
- async function claimOf(env, cat, url) {
251
- const origin = url.origin;
252
- const repo = repoOf(env);
253
- if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
254
- const kept = await storedClaim(env, origin);
255
- if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
256
- if (kept?.older) return kept.older;
257
- const directory = directoryOf(cat);
258
- const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
259
- const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
260
- && Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
261
- if (may) {
262
- try {
263
- // Asked when there is no claim yet, or to say a repository the directory has not heard from this site.
264
- const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}${repo ? `&repo=${encodeURIComponent(repo)}` : ''}`, {
265
- headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
266
- });
267
- const body = await res.json().catch(() => null);
268
- if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
269
- await env.DB.batch([
270
- env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim),
271
- ...(repo ? [env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_repo:${origin}`, repo)] : []),
272
- ]);
273
- claimed.set(origin, body.claim);
274
- if (repo) repoSaid.set(origin, repo);
275
- return body.claim;
276
- }
277
- } catch { /* the directory is optional for a live site */ }
278
- claimMissed.set(origin, Date.now());
279
- }
280
- return kept?.own ?? kept?.older ?? null;
281
- }
282
-
283
- /** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
284
- function named(cat, env) {
285
- if (!cat?.studio?.template || !env.STUDIO_NAME) return cat;
286
- return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
287
- }
288
-
289
- /**
290
- * Every public room with people in it, per game (the Lobby's list), and the number playing per game. A room of a
291
- * server (section 17) says which, its policy, and how many AI are in it; a server that is not listed, closed, or has
292
- * a door is left out of the lists (its own page shows its rooms to whoever it lets in).
293
- */
294
- async function roomsOf(env, games, { servers = null } = {}) {
295
- const rooms = [];
296
- const live = {};
297
- // Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
298
- const chatRows = await chatRowsOf(env);
299
- await Promise.all(games.map(async (g) => {
300
- let list = [];
301
- try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
302
- const busy = list.filter((r) => (r.players || 0) > 0);
303
- live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
304
- const known = busy.some((r) => r.server && r.server !== 'public') ? (servers?.[g.id] ?? await serversOf(env, g).catch(() => [])) : [];
305
- for (const r of busy) {
306
- const sid = r.server ?? roomServer(r.name);
307
- const srv = sid === 'public' ? (known.find((x) => x.id === 'public') ?? PUBLIC_SERVER) : known.find((x) => x.id === sid);
308
- if (!srv || (sid !== 'public' && (!srv.listed || srv.state !== 'open' || srv.door !== 'open'))) continue;
309
- const chat = chatOf(g, srv, chatRows);
310
- rooms.push(roomView(g, r, seatsOf(g), srv, { chat: chat.mode !== 'off' && chat.hub }));
311
- }
312
- }));
313
- rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
314
- return { rooms, live };
315
- }
316
-
317
- /** Kept for a studio's own wrapper Worker that reads `/api/games`'s `live`. */
318
- async function liveCounts(env, games) { return (await roomsOf(env, games)).live; }
319
-
320
- const MEDIA_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
321
-
322
- /** `bytes=a-b`, `bytes=a-`, `bytes=-n` against a length, or null (a malformed or multi-range header is served whole). */
323
- function parseRange(header, size) {
324
- const m = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim());
325
- if (!m || (m[1] === '' && m[2] === '')) return null;
326
- let start; let end;
327
- if (m[1] === '') { const n = Number(m[2]); start = Math.max(0, size - n); end = size - 1; }
328
- else { start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1); }
329
- if (!(start <= end) || start >= size) return { unsatisfiable: true };
330
- return { start, end };
331
- }
332
-
333
- /** A media file from the site's own assets, answering a byte range (phones seek audio and video with them; Safari needs them to play a video at all). */
334
- async function assetWithRange(request, env, url) {
335
- const res = await env.ASSETS.fetch(new Request(url.toString(), { method: 'GET' }));
336
- const range = request.headers.get('range');
337
- if (!res.ok || !range || res.status === 206) return res;
338
- const body = new Uint8Array(await res.arrayBuffer());
339
- const r = parseRange(range, body.byteLength);
340
- const headers = new Headers(res.headers);
341
- headers.set('accept-ranges', 'bytes');
342
- if (!r) return new Response(body, { status: 200, headers });
343
- if (r.unsatisfiable) { headers.set('content-range', `bytes */${body.byteLength}`); return new Response(null, { status: 416, headers }); }
344
- headers.set('content-range', `bytes ${r.start}-${r.end}/${body.byteLength}`);
345
- headers.set('content-length', String(r.end - r.start + 1));
346
- return new Response(body.subarray(r.start, r.end + 1), { status: 206, headers });
347
- }
348
-
349
- /** Does an If-None-Match header name this ETag (or `*`)? Weak and strong forms compare alike for a GET. */
350
- function etagMatches(header, etag) {
351
- if (!header || !etag) return false;
352
- const bare = (t) => String(t).trim().replace(/^W\//, '');
353
- return header.split(',').some((t) => t.trim() === '*' || bare(t) === bare(etag));
354
- }
355
-
356
- /**
357
- * A file from the studio's R2 bucket, with byte ranges (206), HEAD, and If-None-Match (304).
358
- * `cache`: the Cache-Control it is served with. /media/<key> keeps its hour; a song or video served at its own
359
- * address (media move) gets exactly what the site's static files get, `public, max-age=0, must-revalidate` with an
360
- * ETag, so moving a file to R2 changes nothing a browser or a phone sees. `type`: the catalogue's type, for an
361
- * object stored without one.
362
- */
363
- async function mediaObject(request, env, key, { cache = 'public, max-age=3600', type = null } = {}) {
364
- if (key.startsWith('paid-parts/')) return new Response('not found', { status: 404 });
365
- const range = request.headers.get('range');
366
- const inm = request.headers.get('if-none-match');
367
- const headOnly = request.method === 'HEAD';
368
- let head = null;
369
- if (range || inm || headOnly) {
370
- head = await env.MEDIA.head(key);
371
- if (!head) return new Response('not found', { status: 404 });
372
- }
373
- const base = (obj) => {
374
- const headers = new Headers();
375
- obj.writeHttpMetadata?.(headers);
376
- if (!headers.get('content-type') && type) headers.set('content-type', type);
377
- if (obj.httpEtag) headers.set('etag', obj.httpEtag);
378
- if (obj.uploaded) headers.set('last-modified', new Date(obj.uploaded).toUTCString());
379
- headers.set('cache-control', cache);
380
- headers.set('accept-ranges', 'bytes');
381
- return headers;
382
- };
383
- if (head && inm && etagMatches(inm, head.httpEtag)) return new Response(null, { status: 304, headers: base(head) });
384
- const r = head && range ? parseRange(range, head.size) : null;
385
- if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}`, 'accept-ranges': 'bytes' } });
386
- if (headOnly) {
387
- const headers = base(head);
388
- headers.set('content-length', String(head.size));
389
- return new Response(null, { headers });
390
- }
391
- const obj = await env.MEDIA.get(key, r ? { range: { offset: r.start, length: r.end - r.start + 1 } } : undefined);
392
- if (!obj) return new Response('not found', { status: 404 });
393
- const headers = base(obj);
394
- if (r) {
395
- headers.set('content-range', `bytes ${r.start}-${r.end}/${head.size}`);
396
- headers.set('content-length', String(r.end - r.start + 1));
397
- return new Response(obj.body, { status: 206, headers });
398
- }
399
- headers.set('content-length', String(obj.size));
400
- return new Response(obj.body, { headers });
401
- }
402
-
403
- /** The same Cache-Control the site's static files are served with (Workers static assets' default). */
404
- const ASSET_CACHE = 'public, max-age=0, must-revalidate';
405
-
406
- /**
407
- * A song's or video's file that `media move` put in R2, by the address it always had (/music/<slug>/x.mp3,
408
- * /videos/<slug>/x.mp4): the catalogue names its key (`r2`). Only files the catalogue lists as public are served this
409
- * way, never any other key of the bucket.
410
- */
411
- async function heldInR2(env, getCat, path) {
412
- if (!env.MEDIA) return null;
413
- const cat = await getCat();
414
- let want;
415
- try { want = decodeURIComponent(path); } catch { return null; }
416
- for (const e of [...(cat.songs ?? []), ...(cat.videos ?? [])]) {
417
- for (const f of e.files ?? []) {
418
- if (typeof f.r2 !== 'string' || !f.r2 || (f.r2.startsWith('players/') || f.r2.startsWith('paid-parts/')) || typeof f.url !== 'string') continue;
419
- let at;
420
- try { at = decodeURIComponent(f.url); } catch { continue; }
421
- if (at === want) return f;
422
- }
423
- }
424
- return null;
425
- }
426
-
427
- /**
428
- * A catalogue media row as the directory reads it (absolute addresses). A song's cover (a video's poster) is its
429
- * own, else the manifest's default, else the landing still of the game it was made for (site.mjs mediaArt).
430
- */
431
- function mediaRow(e, origin, kind, cat) {
432
- const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u ?? null);
433
- const main = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'audio' : 'video'));
434
- return {
435
- slug: e.slug, kind: e.kind, title: e.title, blurb: e.blurb ?? '', duration: e.duration ?? null,
436
- page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(mediaArt(e, kind, cat)),
437
- credits: e.credits ?? null, rights: e.rights ?? null, for: e.for ?? null,
438
- };
439
- }
440
-
441
- /**
442
- * THE ARCADE KNOCK, ANSWERED AT THE ROOT. A game made with Homie's arcade controls (@homie-rocks/arcade, and the
443
- * older builds of it that Homie's own games carry) asks its page's own origin for a Homie box with a GET on
444
- * /__homie/call, and a score goes there as a POST. The game's frame is /<game>/__game/, so that absolute path lands
445
- * on the site's ROOT, not under the game. `not-a-homie` (JSON, CORS-open, because the frame is an opaque origin) makes the knock stop for
446
- * good and refuses a call in words; it answers 200, so no browser logs a failed load for it, and a studio needs no
447
- * wrapper Worker of its own for this. A preflight (a POST's content-type) is answered too.
448
- */
449
- const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
450
- /**
451
- * Who a browser is to a room's chat (section 19): signed in with a passkey (an account, not a guest), and a member of the
452
- * server (on the public server, any signed-in account). One D1 read for a signed-in player, none for a guest.
453
- */
454
- async function chatWho(env, game, srv, holder) {
455
- const pid = holders(holder).find((x) => x.startsWith('p-'))?.slice(2) ?? null;
456
- if (!pid || !env.DB) return { acct: false, member: false };
457
- let acct = false;
458
- try { acct = Number((await env.DB.prepare('SELECT guest FROM players WHERE id = ?1').bind(pid).first())?.guest) === 0; } catch { acct = false; }
459
- const member = acct && (!srv || srv.id === 'public' || Boolean(await memberOf(env, game, srv.id, pid)));
460
- return { acct, member };
461
- }
462
-
463
- /** Reports a browser (and its address) may make: 8 in 10 minutes, counted in this Worker's memory and forgotten. */
464
- const reportsMade = new Map();
465
- function reportAllowed(key, now = Date.now()) {
466
- const list = (reportsMade.get(key) ?? []).filter((at) => now - at < 10 * 60_000);
467
- if (list.length >= 8) { reportsMade.set(key, list); return false; }
468
- list.push(now);
469
- reportsMade.set(key, list);
470
- if (reportsMade.size > 5000) for (const [k, v] of reportsMade) if (!v.some((at) => now - at < 10 * 60_000)) reportsMade.delete(k);
471
- return true;
472
- }
473
-
474
- function notAHomie(request) {
475
- if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
476
- return json({ ok: false, error: 'not-a-homie', message: 'this is a studio site, not a Homie box; a game here plays on its own' }, 200, KNOCK_CORS);
477
- }
478
-
479
- /**
480
- * The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). `agent`: the
481
- * frame was opened with an agent's ticket (section 17): the game plays as that AI, named "<label> · AI".
482
- */
483
- async function gameDocument(request, env, url, game, meta, cat, { agent = null } = {}) {
484
- const embedded = url.searchParams.get('embed') === '1' && playerEnabled(cat, meta);
485
- const asked = url.searchParams.get('room');
486
- // A player card's game joins a public room, or `main`: the room Play itself falls back to when the lobby does not answer.
487
- if (embedded && (!/^(?:pub-[1-9][0-9]*|main)$/.test(asked || '') || url.searchParams.has('t') || url.searchParams.has('watch') || url.searchParams.get('want') === 'screen')) return new Response('Player cards join public rooms through the player address.\n', { status: 400 });
488
- // A room code the relay cannot use is refused here too, never swapped for another room.
489
- if (asked !== null && !ROOM_ID.test(asked)) return new Response('That room link does not work: a room code is 1 to 32 letters, digits, - or _.\n', { status: 400, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
490
- const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
491
- if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`, cat);
492
- const room = asked ?? 'main';
493
- // A watcher (section 16): a screen that never takes a seat, following the player the watch page asks for.
494
- const policy = watchOf(meta);
495
- const watching = !agent && url.searchParams.get('watch') === '1';
496
- if (watching && policy === 'off') return new Response(`${meta?.name ?? game} cannot be watched; play it instead.\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
497
- const follow = /^(?:auto|overview|\d+)$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
498
- const want = !agent && (watching || url.searchParams.get('want') === 'screen') ? 'screen' : 'play';
499
- const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
500
- const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
501
- // The browser's room key (b: what a kick holds out) and the play page's ticket (t: a game that is not public) ride
502
- // in the socket's address, which every netplay helper opens as it is given.
503
- const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : null;
504
- const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
505
- // The game's revision (game.json `netplay.version`, NETPLAY.md section 23) as this page is served: it rides in the
506
- // socket's address (`gv`), so a build whose helper predates revisions is still known for the build it is.
507
- const ver = versionOf(meta?.netplay?.version);
508
- // The play page's switches for the game (section 24): the allowed names only, checked again here.
509
- const params = paramsFrom(url.searchParams, meta);
510
- if (meta.kind === 'app' && !params.role) params.role = appRole(meta, url);
511
- const cfg = {
512
- ...(meta.kind === 'app' ? {kind: 'app'} : {}),
513
- v: 1,
514
- url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
515
- room,
516
- ...(ver ? { ver } : {}),
517
- ...(Object.keys(params).length ? { params } : {}),
518
- // The page around this frame keeps net.prefs (the play page says so; a watch page or an older page does not).
519
- ...(url.searchParams.get('pf') === '1' ? { prefs: true } : {}),
520
- ...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
521
- ...(agent ? { name: agent.name } : url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
522
- // An AI that runs the game itself (hands `self`): its hello says so; the relay knows it from its ticket anyway.
523
- ...(agent ? { agent: { hands: agent.hands, role: agent.role } } : {}),
524
- // The play page's "Quiet AI": this browser hides AI speech (the helper's `net.hushed`).
525
- ...(url.searchParams.get('hush') === '1' ? { hush: true } : {}),
526
- // Room chat (section 19): this browser hides chat ("Show chat" off), or its player's own lines stay off their character.
527
- ...(url.searchParams.get('chat') === '0' ? { chatOff: true } : {}),
528
- ...(url.searchParams.get('bub') === '0' ? { bubbleOff: true } : {}),
529
- ...(device ? { device } : {}),
530
- want,
531
- ...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
532
- debug: url.searchParams.get('debug') === '1',
533
- ...(meta?.movement ? { movement: meta.movement } : {}),
534
- // game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
535
- ...(meta?.saves && want === 'play' ? { saves: true } : {}),
536
- // The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
537
- // watcher has no shop; the shell follows the studio shop policy for kids servers and television checkout.
538
- ...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
539
- };
540
- let html = await res.text();
541
- const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
542
- const assetBase = meta.kind === 'app' && !url.pathname.includes('/__assets/') && launchOf(meta, await settingsOf(env, {fresh:true}), env) !== 'public' ? `<base href="/${game}/__assets/${await appAssetTicket(env,game)}/">` : '';
543
- const head = assetBase + `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
544
- html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
545
- return new Response(html, {
546
- headers: {
547
- 'content-type': 'text/html; charset=utf-8',
548
- 'cache-control': 'no-store, no-transform',
549
- 'access-control-allow-origin': '*',
550
- // The game runs in an opaque origin: it cannot read this site's storage or cookies.
551
- 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${meta.kind === 'app' ? ' allow-downloads' : ''}; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
552
- },
553
- });
554
- }
555
-
556
- async function gameAsset(request, env, url, game, rest) {
557
- if (rest.includes('..')) return new Response('not found', { status: 404 });
558
- const target = new URL(`${url.origin}/games/${game}/${rest}`);
559
- const res = MEDIA_FILE.test(rest) && request.headers.get('range') ? await assetWithRange(request, env, target) : await env.ASSETS.fetch(new Request(target));
560
- const out = new Response(res.body, res);
561
- // Module scripts from the opaque-origin frame are CORS requests.
562
- out.headers.set('access-control-allow-origin', '*');
563
- return out;
564
- }
565
-
566
- /**
567
- * A studio that shares its "played this week" (studio.json `stats.share`) asks the directory to re-read its
568
- * manifest once a day per Worker instance: the directory reads the number from the studio's own site, so nothing
569
- * it is told can be forged by anyone else. Nothing else is ever sent.
570
- */
571
- let sharedDay = null;
572
- function shareDaily(cat, url, ctx) {
573
- if (!cat.studio?.stats?.share || url.protocol !== 'https:' || sharedDay === today()) return;
574
- sharedDay = today();
575
- const directory = String(cat.studio.directory ?? 'https://homie.rocks').replace(/\/+$/, '');
576
- const ping = fetch(`${directory}/api/studio/played`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url.origin }) }).catch(() => {});
577
- if (ctx?.waitUntil) ctx.waitUntil(ping);
578
- }
579
-
580
- /** A song's or video's player started (the page's one beacon, sent once per page): one counter. */
581
- async function mediaBeat(request, env, ctx, url) {
582
- const site = request.headers.get('sec-fetch-site');
583
- const origin = request.headers.get('origin');
584
- if (origin && origin !== 'null' ? origin !== url.origin : site !== 'same-origin') return new Response(null, { status: 403 });
585
- if (Number(request.headers.get('content-length') ?? 0) > 256) return new Response(null, { status: 413 });
586
- let body = null;
587
- try { body = JSON.parse(await request.text()); } catch { return new Response(null, { status: 400 }); }
588
- const kind = body?.k === 'song' || body?.k === 'video' ? body.k : null;
589
- const slug = typeof body?.s === 'string' && MEDIA_SLUG.test(body.s) ? body.s : null;
590
- if (!kind || !slug) return new Response(null, { status: 400 });
591
- const cat = await catalogue(env, url.origin);
592
- const known = (kind === 'song' ? cat.songs : cat.videos) ?? [];
593
- if (!known.some((e) => e.slug === slug)) return new Response(null, { status: 404 });
594
- if (!isQa(request)) await count(env, ctx, { metric: kind, subject: slug }); // house QA is never counted
595
- return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });
596
- }
597
-
598
- /**
599
- * EVERY HTML ANSWER IS no-transform, AND NEVER FRAMED BY ANOTHER SITE. A custom domain on a zone with Cloudflare Web
600
- * Analytics' automatic setup gets a beacon injected into every HTML answer not marked no-transform, and the beacon
601
- * posts to /cdn-cgi/rum, which a Worker custom domain does not serve. Pages that name no frame rule get
602
- * x-frame-options DENY (the game's own files are framed by its play page, so they keep theirs).
603
- */
604
- function finish(res, path) {
605
- if (!res || res.status === 101 || res.webSocket) return res;
606
- if (!/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
607
- const cache = res.headers.get('cache-control') ?? '';
608
- const addNt = !/no-transform/i.test(cache);
609
- // GAME PARTS: a shared part's preview page is framed by the part's page and the hub, sandboxed (worker/parts.mjs).
610
- const playerFrame = /^\/[a-z0-9][a-z0-9-]{0,39}\/play\/embed$/.test('/' + path.split('/').filter(Boolean).join('/')) && res.status === 200 && /frame-ancestors/.test(res.headers.get('content-security-policy') ?? '');
611
- const addXfo = !playerFrame && !res.headers.has('x-frame-options') && !GAME_FILES.test(path) && !PART_FILES.test(path);
612
- if (!addNt && !addXfo) return res;
613
- const headers = new Headers(res.headers);
614
- if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
615
- if (addXfo) headers.set('x-frame-options', 'DENY');
616
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
617
- }
618
-
619
- // Repeat after migration: an older isolate may recreate these during a rolling deploy.
620
- async function removeReferralLeftovers(env) {
621
- if(env.DB)await env.DB.prepare("DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*'").run();
622
- }
623
-
23
+ export {Table} from './table.mjs';
24
+ export {roomEndAt} from './room-lifecycle.mjs';
25
+ export {Lobby} from './lobby.mjs';
26
+ export {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
27
+ export {AppRecordHub, EventHub} from './stream-objects.mjs';
28
+ export {directoryOf,useTools,getStudioTools} from './tool-registry.mjs';
29
+ const route = async (...args) => (await import('./routes.mjs')).default(...args);
624
30
  export default {
31
+ async queue(batch,env) {return (await import('./background-queue.mjs')).consumeBackground(batch,await loadShopLinks(env));},
625
32
  async scheduled(event, env, ctx) {
626
33
  env=await loadShopLinks(env);
34
+ const analytics=event.cron === '17 * * * *' ? (await import('./analytics.mjs')).rollupAnalytics(env) : Promise.resolve();
35
+ ctx.waitUntil(analytics);
627
36
  const maintenance=(async () => {
628
- await removeReferralLeftovers(env);
37
+ const [{notifyTools},{reconcileOrders,shopOf}]=await Promise.all([import('./runtime-support.mjs'),import('./shop.mjs')]);
629
38
  const cat = await catalogue(env, 'https://studio.invalid');
630
- await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
631
- await reconcileOrders(env, shopOf(cat));
632
- await (await import('./functions.mjs')).scheduleFunctions(event,env,cat,cat.studio?.url??'https://studio.invalid');
39
+ // Each independent maintenance task settles before its errors are reported.
40
+ // Owner schedules must run even if pruning or payment recovery fails.
41
+ const tasks=[
42
+ ['tool catalogue',()=>notifyTools(env,cat,cat.studio?.url??'https://studio.invalid')],
43
+ ['orders',()=>reconcileOrders(env,shopOf(cat))],
44
+ ['Stripe retention',async()=>{if(env.DB)await (await import('./stripe-intake.mjs')).pruneStripeEvents(env);}],
45
+ ['MCP retention',async()=> (await import('./event-resources.mjs')).pruneMcpKeys(env)],
46
+ ['guest retention',async()=>{if(event.cron==='17 * * * *')await (await import('./players.mjs')).pruneGuests(env);}],
47
+ ['Stripe recovery',async()=>{if(env.BACKGROUND && event.cron==='17 * * * *')await (await import('./stripe-intake.mjs')).sweepStripeEvents(env,cat);}],
48
+ ['owner functions',async()=> (await import('./functions.mjs')).scheduleFunctions(event,env,cat,cat.studio?.url??'https://studio.invalid')],
49
+ ];
50
+ await Promise.all(tasks.map(async([name,run])=>{try{await run();}catch(error){console.error('Scheduled '+name+' failed:',error.message);}}));
633
51
  })();
634
52
  ctx.waitUntil(maintenance);
635
- await maintenance;
53
+ await Promise.all([maintenance,analytics]);
636
54
  },
637
55
  async fetch(request, env, ctx) {
56
+ const staticPath=new URL(request.url).pathname;
57
+ if(['GET','HEAD'].includes(request.method)&&staticPath==='/_homie/site.js')return finish(await route(request,env,ctx),staticPath);
638
58
  env=await loadShopLinks(env,new URL(request.url).origin);
639
- await removeReferralLeftovers(env);
59
+ env={...env,DB:requestDatabase(env.DB,request,{mutated:false,summaries:Boolean(env.BACKGROUND)})};
640
60
  const url = new URL(request.url);
641
61
  if(env.HOMIE_CONCEPT==='1' && url.pathname==='/robots.txt')return new Response('User-agent: *\nDisallow: /\n',{headers:{'content-type':'text/plain','x-robots-tag':'noindex, nofollow'}});
642
62
  if(env.HOMIE_CONCEPT==='1' && url.pathname==='/.well-known/homie-studio.json')return json({ok:false,error:'concept-unlisted'},404,{'x-robots-tag':'noindex, nofollow'});
643
- await (await import('./functions.mjs')).syncFunctions(env);
644
- const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
63
+ const dispatch=()=>import('./background-queue.mjs').then(m=>m.enqueueChanges(env,url.origin));
645
64
  // Some routes reconcile shop state in waitUntil after returning their page.
646
65
  // Keep dispatch behind those writes too, preserving the runtime context's
647
66
  // methods and OAuth verification symbols through the proxy.
648
- const requestCtx=ctx?.waitUntil?new Proxy(ctx,{get(target,key){
649
- if(key==='waitUntil')return work=>ctx.waitUntil(Promise.resolve(work).finally(dispatch));
650
- const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
651
- }}):ctx;
67
+ let delivered=0;
68
+ let delivery=Promise.resolve();
69
+ const background=trackBackground(ctx,()=>{
70
+ // Serialize late completions and remember the generation acknowledged by
71
+ // Queues. A read-only waitUntil never replays an earlier mutation.
72
+ delivery=delivery.catch(error=>{console.error('Background enqueue retry',error);}).then(async()=>{
73
+ const version=env.DB?.[MUTATION_VERSION]??0;
74
+ if(version===delivered)return;
75
+ await dispatch();delivered=version;
76
+ });
77
+ return delivery;
78
+ });
79
+ const requestCtx=background.context;
652
80
  // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
653
81
  // unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
654
82
  const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
@@ -660,11 +88,9 @@ export default {
660
88
  try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
661
89
  res = json({ ok: false, error: 'failed' }, 500);
662
90
  } finally {
663
- // Refresh after the route has populated the catalogue cache.
664
- if(env.DB&&env.LOBBY&&ctx?.waitUntil)ctx.waitUntil(catalogue(env,url.origin).then(cat=>notifyTools(env,cat,url.origin)).catch(()=>{}));
665
91
  // A route can commit a write and then fail. Its outbox still needs delivery.
666
92
  if(env.DB) {
667
- const delivery=dispatch();
93
+ const delivery=background.finish();
668
94
  if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
669
95
  }
670
96
  }
@@ -678,2149 +104,9 @@ export default {
678
104
  }
679
105
  res=new Response(body,{status:res.status,headers});
680
106
  }
107
+ const bookmark=env.DB?.getBookmark?.();
108
+ if(bookmark&&res.status!==101){const headers=new Headers(res.headers);headers.set('x-studio-bookmark',bookmark);res=new Response(res.body,{status:res.status,statusText:res.statusText,headers});}
681
109
  if (appLobby || appRecords) return appCors(request, res);
682
110
  return res;
683
111
  },
684
112
  };
685
-
686
- async function notifyTools(env,cat,origin) {
687
- if(!env.DB||!env.LOBBY)return;
688
- const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
689
- await pushToolList(env,cat,[...builtins(env,cat,origin),...await studioTools()],origin);
690
- }
691
- let studioTools = async () => [];
692
- /** Studio-authored Worker code, loaded only on the MCP surface. */
693
- export function useTools(load) { studioTools = load; }
694
- export function getStudioTools() { return studioTools(); }
695
-
696
- async function route(request, env, ctx) {
697
- const url = new URL(request.url);
698
- const path = url.pathname;
699
- const parts = path.split('/').filter(Boolean);
700
- const read = request.method === 'GET' || request.method === 'HEAD';
701
- let catP = null;
702
- let pubP = null;
703
- // Every game (for a game's own pages and the owner's), and the public ones (every list, the rooms, the manifest).
704
- const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
705
- const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
706
-
707
- // Keep tool transports and discovery on the alias; redirect browser pages only.
708
- if (read && url.hostname.endsWith('.workers.dev') && env.HOMIE_PREVIEW !== '1'
709
- && request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
710
- && !/\/(?:api|__[a-z]+)(?:\/|$)/.test(path)
711
- && !/^\/(?:\.well-known(?:\/|$)|api(?:\/|$)|mcp(?:\/|$)|oauth(?:\/|$)|hooks(?:\/|$)|_studio(?:\/|$))/.test(path)) {
712
- const canonical = (await getAll()).studio?.canonical;
713
- if (canonical && canonical !== url.origin) return Response.redirect(canonical + url.pathname + url.search, 308);
714
- }
715
-
716
- if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
717
-
718
- if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
719
- const { remoteMcp } = await import('./mcp.mjs');
720
- return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
721
- }
722
-
723
- // "Connect this chat" (the template's first-run band): the directory's setup page, for this site's address.
724
- if (path === '/_studio/connect' && read) {
725
- const directory = directoryOf(await getCat());
726
- if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
727
- return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
728
- }
729
- if (path.startsWith('/_studio/')) return (await officeRoutes(request, env, url, { catalogueOf: getAll })) ?? ownerRoutes(request, env, url, { catalogueOf: getAll });
730
- if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
731
- // The shop (0.24.0) before player accounts: /api/player/owns is the shop's.
732
- {
733
- const shopped = await shopRoutes(request, env, ctx, url, { catalogueOf: getAll });
734
- if (shopped) return shopped;
735
- }
736
- if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
737
- // The Lounge (0.29.0, chat/LOUNGE.md): the studio's own community room, when studio.json turns it on.
738
- if (isLoungePath(path)) {
739
- const lounged = await loungeRoutes(request, env, ctx, url, { catalogueOf: getCat, roomsOf });
740
- if (lounged) return lounged;
741
- }
742
- if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
743
- if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
744
- if (path === '/api/stats') {
745
- if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
746
- if (!(await ownerAllowed(request, env, { kinds: ['read', 'office'] }))) {
747
- return json({ ok: false, error: 'owner-only', message: 'The studio\'s numbers are for its owner. In the studio folder: `npx --no-install homie-studio stats` prints them; `npx --no-install homie-studio stats key` gives a read key for the Homie MCP tool studio_stats.' }, 401, { 'www-authenticate': 'Bearer' });
748
- }
749
- const cat = await getAll();
750
- const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
751
- try {
752
- return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
753
- } catch (error) {
754
- return json({ ok: false, error: 'no-stats', message: `The counters are not in this studio's D1 yet: \`npm run deploy\` applies migration 0002_studio_stats.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
755
- }
756
- }
757
- // GAME PARTS: /.well-known/homie-parts.json, /parts/<id>/<version>/… and the studio's own parts pages.
758
- if (isPartsPath(path)) {
759
- const shared = await partsRoutes(request, env, url, { catalogueOf: getCat });
760
- if (shared) return shared;
761
- }
762
- if (path === '/.well-known/homie-studio.json') {
763
- const cat = await getCat();
764
- const canonical = cat.studio?.canonical;
765
- if (canonical && env.HOMIE_PREVIEW !== '1' && url.protocol === 'https:') {
766
- const address = new URL(canonical); url.protocol = address.protocol; url.host = address.host;
767
- }
768
- const sharing = Boolean(cat.studio?.stats?.share);
769
- const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
770
- // studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
771
- const shareRooms = cat.studio?.rooms?.share !== false;
772
- return json({
773
- v: 1,
774
- kind: 'homie-studio',
775
- toolkit: STUDIO_BUILD,
776
- name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
777
- slug: cat.studio?.slug ?? null,
778
- ...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
779
- site: url.origin,
780
- studio: cat.studio?.version ?? null,
781
- claim: await claimOf(env, cat, url),
782
- // What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
783
- // its mark is here). A Preview says it is one.
784
- ...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
785
- // Only the public games (a private or invite-only game leaves the directory the next time it reads this).
786
- games: await Promise.all((cat.games ?? []).filter((g) => g.kind !== 'app').map(async (g) => {
787
- // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
788
- const cover = gameCover(g);
789
- // The licence the game names for itself (an SPDX id), when it names one (worker/license.mjs). Nothing here
790
- // offers the game to be taken whole: `remix`, `source` and `remixOf` left this manifest with remix.
791
- const license = licenseOf(g.license);
792
- return {
793
- id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
794
- page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
795
- ...(license ? { license } : {}),
796
- // Which build of the game is live: its digest (what `homie-studio build` printed for it, and what
797
- // site/dist/_site/build.json says) and the bundle its page loads. "Is the live game the one I built?" is
798
- // this against that, with no file to fetch and no edge cache to wait out.
799
- ...(g.built?.hash ? { build: { hash: g.built.hash, ...(g.built.bundle ? { bundle: `${url.origin}/games/${g.id}/${g.built.bundle}` } : {}) } } : {}),
800
- // Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
801
- ...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
802
- };
803
- })),
804
- ...((cat.games ?? []).some((g) => g.kind === 'app') ? { apps: cat.games.filter((g) => g.kind === 'app').map((g) => ({ id: g.id, kind: 'app', name: g.name, blurb: g.blurb, page: `${url.origin}/${g.id}/`, open: `${url.origin}/${g.id}/open`, wall: `${url.origin}/${g.id}/tv`, surfaces: Object.keys(g.surfaces ?? {}), ...(g.built ? { build: g.built } : {}) })) } : {}),
805
- songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
806
- videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
807
- // The studio's posts, for the hub (the full text is in /posts/feed.json).
808
- posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
809
- ...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
810
- // The studio's Lounge (0.29.0), for a directory that shows lounges: its page and its public facts.
811
- ...(cat.studio?.lounge ? { lounge: { name: cat.studio.lounge.name, page: `${url.origin}/lounge/`, now: `${url.origin}/lounge/api/now` } } : {}),
812
- // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
813
- ...(played ? { played } : {}),
814
- // The shop: whether it sells and its till, never a key or a sale.
815
- ...(shopOf(cat)?.open ? { shop: { open: true, till: shopOf(cat).till, currency: shopOf(cat).currency, page: `${url.origin}/shop/` } } : {}),
816
- }, 200, { 'access-control-allow-origin': '*' });
817
- }
818
- if (path === '/api/games') {
819
- const cat = await getCat();
820
- return json({ studio: cat.studio, games: (cat.games ?? []).map(g => g.room?.host === 'server' && !hostedGame(g.id) ? { ...g, rulesError: RULES_WIRING_MESSAGE } : g), live: await liveCounts(env, cat.games ?? []) });
821
- }
822
- if (path === '/api/rooms') {
823
- const cat = await getCat();
824
- const { rooms } = await roomsOf(env, cat.games ?? []);
825
- // Short-lived and cacheable: the hub reads every studio's rooms at most every 45 s, and a room's count is a
826
- // few seconds stale at worst. (The site's own pages ask with cache: 'no-store'.)
827
- return json({ ok: true, playing: rooms.reduce((n, r) => n + r.players, 0), rooms }, 200, { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=15' });
828
- }
829
- if (path.startsWith('/media/')) {
830
- if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
831
- const key = decodeURIComponent(path.slice('/media/'.length));
832
- // A player's large saves live under players/ in the same bucket: never served here (saves/SAVES.md).
833
- if (!key || key.includes('..') || (key.startsWith('players/') || key.startsWith('paid-parts/'))) return new Response('not found', { status: 404 });
834
- return mediaObject(request, env, key);
835
- }
836
- if (path === '/posts/feed.xml' || path === '/posts/feed.json') {
837
- const cat = await getCat();
838
- if (!(cat.posts ?? []).length) return notFoundPage('This studio has no posts yet.', cat);
839
- const posts = await postsOf(env, url.origin);
840
- return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
841
- }
842
-
843
- // REMIX WAS RETIRED: no game's source is served whole. An older toolkit's `game remix`, the directory's copy of a
844
- // studio, or a link somebody kept may still ask for one, so the address answers 410 (gone, on purpose, for good)
845
- // with where to go instead: /parts/, the pieces of games this studio chose to share. The same answer for every
846
- // id, whether or not such a game exists, and before the site's files are looked at: a source.json an older build
847
- // left in the deployed folder is never reachable.
848
- if (/^\/games\/[a-z0-9][a-z0-9-]{0,39}\/source\.json$/.test(path)) return json({ retired: 'remix', see: '/parts/' }, 410, { 'access-control-allow-origin': '*' });
849
-
850
- // Search engines and AI agents (0.27.0, worker/discover.mjs): robots.txt, sitemap.xml, llms.txt and llms-full.txt,
851
- // made from the public catalogue (a private or invite-only game is in none of them); a file of the studio's own in
852
- // site/public wins.
853
- if (read && DISCOVERY_FILES.includes(path)) {
854
- const own = await env.ASSETS.fetch(new Request(`${url.origin}${path}`));
855
- if (own.ok) return own;
856
- const cat = await getCat();
857
- const method = request.method;
858
- if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' || env.HOMIE_CONCEPT === '1' }), 'text/plain', { method });
859
- const all = await getAll();
860
- if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
861
- const full = path === '/llms-full.txt';
862
- return discoveryResponse(llmsTxt(cat, url.origin, { directory: directoryOf(cat), full, posts: full ? await postsOf(env, url.origin) : null, all }), 'text/plain', { method });
863
- }
864
-
865
- // A page the studio made itself (site/pages) wins at its address.
866
- if (read && (path.endsWith('/') || !path.includes('.'))) {
867
- const cat = await getCat();
868
- const pages = cat.site?.pages ?? [];
869
- const want = path.endsWith('/') ? path : `${path}/`;
870
- if (pages.includes(want)) {
871
- if (want !== path) return Response.redirect(`${url.origin}${want}${url.search}`, 301);
872
- const res = await env.ASSETS.fetch(new Request(`${url.origin}/_site/pages${want}index.html`));
873
- if (res.ok) {
874
- const top = parts[0] ?? '';
875
- const game = (cat.games ?? []).find((g) => g.id === top);
876
- await countVisit(request, env, ctx, parts.length === 0 ? 'home' : game && parts.length === 1 ? game.id : parts.join('/').slice(0, 80));
877
- const active = game ? 'games' : sectionsOf(cat).some((s) => s.key === top) ? top : null;
878
- const html = await res.text();
879
- // <!-- homie:schema -->: the structured data the generated page here would carry (Home's, a landing's), else the
880
- // studio's own Organization (an About page).
881
- const schema = !/<!--\s*homie:schema\s*-->/.test(html) ? ''
882
- : want === '/' ? ldScript(homeLd(cat, url.origin))
883
- : game && parts.length === 1 ? ldScript(landingLd(cat, game, url.origin, { shop: await sellingShop(env, cat) }))
884
- : ldScript([studioNode(cat, url.origin, { full: true })]);
885
- return customPage(cat, html, { active, schema, origin: url.origin, playerGame: game && parts.length === 1 ? game : null });
886
- }
887
- }
888
- }
889
-
890
- if (parts[0] === 'music' || parts[0] === 'videos') {
891
- const kind = parts[0];
892
- const cat = await getCat();
893
- const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
894
- // A studio with no songs has no Music: the tab is gone and the page is not found (the same for videos).
895
- if (!list.length) return notFoundPage(kind === 'music' ? 'This studio has no music yet.' : 'This studio has no videos yet.', cat);
896
- if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
897
- if (parts.length === 1) { await countVisit(request, env, ctx, kind); return mediaIndexPage(cat, kind, { origin: url.origin }); }
898
- const slug = parts[1];
899
- const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
900
- if (parts.length === 2) {
901
- if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`, cat);
902
- if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
903
- await countVisit(request, env, ctx, `${kind}/${slug}`);
904
- return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
905
- }
906
- if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.', cat);
907
- const file = await assetWithRange(request, env, url);
908
- if (file.status === 404) {
909
- // Not in the site's files: a song's or video's file that lives in the studio's R2 (media move), same address.
910
- const held = await heldInR2(env, getCat, path);
911
- if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
912
- return notFoundPage('Nothing here.', cat);
913
- }
914
- return file;
915
- }
916
- if (path === '/' || path === '/index.html') {
917
- const cat = await getCat();
918
- await countVisit(request, env, ctx, 'home');
919
- shareDaily(cat, url, ctx);
920
- const { rooms, live } = await roomsOf(env, cat.games ?? []);
921
- return homePage(cat, { origin: url.origin, rooms, live });
922
- }
923
- if (['games', 'apps', 'rooms', 'posts'].includes(parts[0]) && parts.length === 1) {
924
- const cat = await getCat();
925
- const has = sectionsOf(cat).some((s) => s.key === parts[0]);
926
- if (!has) return notFoundPage(parts[0] === 'posts' ? 'This studio has no posts yet.' : 'This studio has no games yet.', cat);
927
- if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${parts[0]}/`, 301);
928
- await countVisit(request, env, ctx, parts[0]);
929
- if (parts[0] === 'posts') return postsPage(cat, { origin: url.origin });
930
- const { rooms, live } = await roomsOf(env, cat.games ?? []);
931
- return ['games', 'apps'].includes(parts[0]) ? gamesPage(cat, { origin: url.origin, live, kind: parts[0] === 'apps' ? 'app' : 'game' }) : roomsPage(cat, { origin: url.origin, rooms });
932
- }
933
- if (parts[0] === 'posts' && parts.length === 2 && POST_SLUG.test(parts[1])) {
934
- const cat = await getCat();
935
- const posts = (cat.posts ?? []).length ? await postsOf(env, url.origin) : [];
936
- const post = posts.find((p) => p.slug === parts[1]);
937
- if (!post) return notFoundPage(`No post called "${parts[1]}" here.`, cat);
938
- if (!path.endsWith('/')) return Response.redirect(`${url.origin}/posts/${post.slug}/`, 301);
939
- await countVisit(request, env, ctx, `posts/${post.slug}`);
940
- return postPage(cat, post, { origin: url.origin });
941
- }
942
-
943
- if(parts[0]==='games' && parts.length>2) {
944
- const all=await getAll(),app=(all.games??[]).find(g=>g.id===parts[1]&&g.kind==='app');
945
- if(app && launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return json({ok:false,error:'not-found'},404);
946
- }
947
- const game = parts[0];
948
- if (game && GAME_ID.test(game)) {
949
- const all = await getAll();
950
- const meta = (all.games ?? []).find((g) => g.id === game);
951
- if (!meta) return fallback(request, env, url, getCat);
952
- if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
953
- const cat = await getCat();
954
- let sub = parts.slice(1).join('/');
955
- if (meta.kind === 'app' && /^open(?:\/(embed|preview))?$/.test(sub)) sub = sub.replace(/^open/, 'play');
956
- if (sub === '__restart') {
957
- if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
958
- const room = url.searchParams.get('room') ?? '';
959
- if (!ROOM_ID.test(room)) return new Response('invalid room', { status: 400 });
960
- return env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__restart?game=${game}&room=${room}`, { method: 'POST' });
961
- }
962
- // Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
963
- const settings = await settingsOf(env, { fresh: true });
964
- // The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
965
- // with access, and the owner may have made its rooms smaller than the game's own seats.
966
- const launch = launchOf(meta, settings, env);
967
- const max = seatsFor(meta, settings);
968
- if (meta.kind === 'app') {
969
- const asset=/^__assets\/([^/]+)\/(.+)$/.exec(sub);
970
- if(asset) {
971
- if(!(await appAssetTicket(env,game,asset[1])))return json({ok:false,error:'not-found'},404);
972
- const response=asset[2]==='index.html'?await gameDocument(request,env,url,game,meta,cat):await gameAsset(request,env,url,game,asset[2]);
973
- response.headers.set('cache-control','private, no-store');
974
- response.headers.set('referrer-policy','no-referrer');
975
- return response;
976
- }
977
- const frameTicket = ['__game','__game/','__game/index.html','__net','__watch'].includes(sub) && await ticketAllows(env, await ticketSub(env,game,url.searchParams.get('t')), launch);
978
- if (launch !== 'public' && !frameTicket && !(await accessOf(request, env, game, launch)).ok && (await (await import('./local-test.mjs')).localSession(request,env))?.app!==game) return json({ ok: false, error: 'not-found' }, 404);
979
- if(launch==='public') {
980
- const web=await (await import('./app-web.mjs')).appWeb(request,env,meta,url,sub,()=>gameDocument(request,env,url,game,meta,cat));
981
- if(web)return web;
982
- }
983
- if(sub==='api/app/live') {
984
- if(request.method!=='GET')return new Response('GET required',{status:405});
985
- const {appHub}=await import('./app-streams.mjs');
986
- return appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'open',meta,url:request.url,headers:Object.fromEntries(request.headers),collections:url.searchParams.getAll('collection')})});
987
- }
988
- if(sub==='api/app/tools')return (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools());
989
- const api = await appRecordsRoute(request, env, meta, url, sub);
990
- if (api) return api;
991
- if (['play', 'tv', 'watch'].includes(sub)) {
992
- let a;
993
- try { a = await appAccess(request, env, meta, url); } catch { return json({ ok: false, error: 'app roles unavailable; apply studio migrations' }, 503); }
994
- if (!a.ok) {
995
- if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
996
- return json({ ok: false, error: a.error }, a.status);
997
- }
998
- if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game):null);
999
- }
1000
- }
1001
- const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
1002
- if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
1003
-
1004
- // SERVERS (worker/servers.mjs, NETPLAY.md section 17): which server a request is for — its path (/s/<id>/), its
1005
- // room (s-<id>-<n>; pub-N and any named room are the public server's), or for a bare Play the signed-in player's
1006
- // home server, else public. Every door follows the game's launch state first, as it always did.
1007
- const servers = await serversOf(env, meta);
1008
- const serverById = (id) => servers.find((x) => x.id === id) ?? null;
1009
- const pathServer = /^s\/([a-z0-9][a-z0-9-]{1,19})(?:\/(play|tv|home))?\/?$/.exec(sub);
1010
- if (/^s(?:\/|$)/.test(sub) && !pathServer) return notFoundPage(`Nothing here in ${meta.name}.`, cat);
1011
- if (pathServer && (!serverById(pathServer[1]) || pathServer[1] === 'public' || serverById(pathServer[1]).state === 'archived')) return notFoundPage(`${meta.name} has no server called "${pathServer[1]}".`, cat);
1012
- if (pathServer && !pathServer[2] && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/s/${pathServer[1]}/${url.search}`, 301);
1013
- const policyFor = (srv, named = false) => policyOf(srv, { seats: max, named });
1014
- const humanSeats = (pol) => Math.max(1, max - (pol.aiSeats + pol.guides));
1015
- const askedRoom = url.searchParams.get('room');
1016
- if (pathServer) sub = pathServer[2] === 'play' ? 'play' : pathServer[2] === 'tv' ? 'tv' : pathServer[2] === 'home' ? 's-home' : 's-page';
1017
-
1018
- const doorSubs = ['', 'live', 'credits', 'tv', 'play', 'watch', 'api/lobby', 'api/watch', 'servers', 'api/servers', 's-page', 's-home'];
1019
- const door = launch !== 'public' && doorSubs.includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
1020
- const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
1021
- /** A page's own door to a server: who this browser is (owner, invited, signed in) and whether it may come in. */
1022
- const pageDoor = async (srv, { watching = false } = {}) => {
1023
- const owner = door.owner || (launch === 'public' && await isOwner(request, env));
1024
- const acct = await accountSub(request, env);
1025
- const sPass = srv && srv.door === 'invite' && !owner ? await serverPassOf(request, env, game, srv.id, cookieValues) : null;
1026
- const list = [owner ? 'o' : null, door.sub, sPass ? `i-${sPass.invite}` : null, acct].filter(Boolean).flatMap(holders);
1027
- const access = srv ? await serverAccess(env, { game, server: srv, holders: list, watching }) : { ok: true };
1028
- return { ...access, owner, acct, holder: joinHolders(door.sub ?? (owner ? 'o' : null), sPass ? `i-${sPass.invite}` : null, acct) };
1029
- };
1030
- /** The public server is hidden (closed by its owner): Play shows the other servers instead, and named rooms are off. */
1031
- const publicHidden = () => (serverById('public') ?? PUBLIC_SERVER).state !== 'open';
1032
-
1033
- if (sub === '') {
1034
- if (!door.ok) return shut();
1035
- await countVisit(request, env, ctx, game);
1036
- shareDaily(cat, url, ctx);
1037
- const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
1038
- const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
1039
- const band = servers.some((x) => x.id !== 'public' && x.listed && x.state === 'open') ? await serverLive(env, meta, servers, url.origin, lobby) : null;
1040
- // GAME PARTS: the landing's "Parts from this game" band (shared parts only).
1041
- return withPartsBand(gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, servers: band, listed: launch === 'public', shop: launch === 'public' ? await sellingShop(env, cat) : null }), env, url, meta);
1042
- }
1043
- if (sub === 'live') {
1044
- if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
1045
- const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
1046
- const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
1047
- // The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
1048
- return json({ ok: true, game, counted: true, playing: live[game] ?? 0, waiting: 0, max, rooms, road: { seats: 'any', start: 'now', room: launch === 'public' ? 'public' : launch, big: true }, ...(week ? { week } : {}) }, 200, { 'cache-control': 'no-store' });
1049
- }
1050
- if (sub === 'credits') {
1051
- if (!door.ok) return shut();
1052
- let texts = [];
1053
- try { const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/_landing/credits.json`)); if (res.ok) texts = (await res.json()).texts ?? []; } catch { texts = []; }
1054
- if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
1055
- return creditsPage(cat, meta, texts, { origin: url.origin });
1056
- }
1057
- if (sub === 'servers') {
1058
- if (!door.ok) return shut();
1059
- if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/servers/`, 301);
1060
- await countVisit(request, env, ctx, `${game}/servers`);
1061
- return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: publicHidden() });
1062
- }
1063
- if (sub === 'api/servers') {
1064
- if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
1065
- const list = (await serverLive(env, meta, servers, url.origin, lobby)).filter((x) => x.id === 'public' || (x.listed && x.state === 'open'));
1066
- return json({ ok: true, game, servers: list }, 200, { 'cache-control': 'public, max-age=15' });
1067
- }
1068
- if (sub === 's-page') {
1069
- if (!door.ok) return shut();
1070
- const srv = serverById(pathServer[1]);
1071
- const d = await pageDoor(srv);
1072
- const pid = d.acct?.startsWith('p-') ? d.acct.slice(2) : null;
1073
- const member = pid ? await memberOf(env, game, srv.id, pid) : null;
1074
- await countVisit(request, env, ctx, `${game}/s/${srv.id}`);
1075
- const [row] = await serverLive(env, meta, [srv], url.origin, lobby);
1076
- const { rooms } = await roomsOf(env, [meta], { servers: { [game]: servers } });
1077
- return serverPage(cat, meta, row, { origin: url.origin, rooms: rooms.filter((r) => r.server?.id === srv.id), door: d, member, signedIn: Boolean(pid) });
1078
- }
1079
- if (sub === 's-home') {
1080
- // Join this server, leave it, or make it home: the signed-in player's own choice (a same-origin JSON POST).
1081
- if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin' }, 403);
1082
- if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
1083
- const srv = serverById(pathServer[1]);
1084
- const acct = await accountSub(request, env);
1085
- if (!acct?.startsWith('p-')) return json({ ok: false, error: 'sign-in', message: 'Sign in to the studio first (your account page).' }, 401);
1086
- let body = {};
1087
- try { body = JSON.parse((await request.text()) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
1088
- const d = await pageDoor(srv);
1089
- if (body.join !== false && !d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
1090
- try { return json(await setMembership(env, game, srv.id, acct.slice(2), { join: body.join !== false, home: body.home === true })); } catch { return json({ ok: false, error: 'not-migrated', message: 'This studio\'s database has no servers yet (migration 0006).' }, 503); }
1091
- }
1092
- if (sub === 'play/preview' && env.HOMIE_EMBED_PREVIEW === '1' && isLocalOrigin(url.origin)) return embedPreview(cat, meta, url.origin);
1093
- if (sub === 'tv' || sub === 'play' || sub === 'play/embed') {
1094
- const embed = sub === 'play/embed';
1095
- if (embed && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage(meta.kind === 'app' ? 'This app does not open inside a post. Open its studio page.' : 'This game does not play inside a post. Open its studio page to play.', cat);
1096
- const screen = !embed && (sub === 'tv' || url.searchParams.get('screen') === '1');
1097
- const asked = embed ? null : askedRoom;
1098
- if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
1099
- if (!door.ok) return shut();
1100
- // Which server: the path's, the room's, else (Play) the player's home server, else public.
1101
- let srv = pathServer ? serverById(pathServer[1]) : asked !== null ? serverById(roomServer(asked)) : null;
1102
- if (asked !== null && !srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
1103
- const named = asked !== null && !pooledRoom(asked);
1104
- if (!srv) {
1105
- const acct = embed ? null : await accountSub(request, env);
1106
- const home = acct?.startsWith('p-') ? await homeOf(env, game, acct.slice(2)) : null;
1107
- const h = home ? serverById(home) : null;
1108
- srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
1109
- }
1110
- const d = await pageDoor(srv);
1111
- if (meta.room?.characters && !screen && !embed && (!d.acct || !(await chatWho(env, game, srv, d.acct)).acct)) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 303);
1112
- // The owner hid the public server: Play shows the servers to pick from, and a named room is off (owner aside).
1113
- if (srv.id === 'public' && publicHidden() && !d.owner) {
1114
- if (named) return doorPage(cat, meta, srv, { why: 'closed' });
1115
- return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: true, pick: true });
1116
- }
1117
- if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, days: d.days, origin: url.origin, next: `${url.pathname}${url.search}`, code: url.searchParams.get('invite') ?? '' });
1118
- let recordsGuest=null;
1119
- if(meta.room?.records&&!embed&&!screen&&!d.acct&&env.DB){
1120
- const made=await playerAccounts.recordsGuest(request,env,url);if(made.error)return made.error;
1121
- recordsGuest=made.session;d.acct='p-'+recordsGuest.player.id;d.holder=joinHolders(d.holder,d.acct);
1122
- }
1123
- if (d.acct?.startsWith('p-') && srv.id !== 'public') ctx?.waitUntil?.(noteMember(env, game, srv.id, d.acct.slice(2)));
1124
- const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
1125
- const pol = policyFor(srv, named);
1126
- const server = srv.id === 'public' && !pol.server ? null : { ...serverView(srv, { origin: url.origin, game }), mentor: Boolean(d.mentor) };
1127
- if (screen) {
1128
- await countVisit(request, env, ctx, game, 'screen');
1129
- // The big screen picks its room now, so the QR it shows puts every phone in that same room.
1130
- let room = asked;
1131
- if (!room) {
1132
- try { room = (await (await lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
1133
- }
1134
- const joinUrl = `${url.origin}${openPath(meta)}${room ? `?room=${encodeURIComponent(room)}` : ''}`;
1135
- // Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
1136
- const local = isLocalOrigin(url.origin);
1137
- let qr = null;
1138
- if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
1139
- // Television checkout and kids-server visibility follow the studio shop policy.
1140
- const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
1141
- let shopQr = null;
1142
- if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
1143
- return playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
1144
- }
1145
- await countVisit(request, env, ctx, game, 'play');
1146
- shareDaily(cat, url, ctx);
1147
- const page=playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
1148
- for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
1149
- }
1150
- if (sub === 'watch') {
1151
- // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
1152
- const asked = askedRoom;
1153
- if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { watch: true });
1154
- if (!door.ok) return shut();
1155
- const policy = watchOf(meta);
1156
- if (policy === 'off') return noWatchPage(cat, meta);
1157
- // A server's door is its watch door too (an invite or an account); a beginner server is watched by anyone.
1158
- const srv = asked !== null ? serverById(roomServer(asked)) : serverById('public') ?? PUBLIC_SERVER;
1159
- if (!srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
1160
- const d = await pageDoor(srv, { watching: true });
1161
- if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, origin: url.origin, next: `${url.pathname}${url.search}`, watch: true });
1162
- const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
1163
- await countVisit(request, env, ctx, game, 'watch');
1164
- shareDaily(cat, url, ctx);
1165
- return watchPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
1166
- }
1167
- if (sub === 'api/watch') {
1168
- if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
1169
- // The busiest room now, to watch, of a server anyone may watch; nothing is reserved (a watcher takes no seat).
1170
- const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
1171
- const pools = servers.filter((x) => x.state === 'open' && x.door === 'open').map((x) => x.id).join(',');
1172
- let best = null;
1173
- const wver = versionOf(meta?.netplay?.version);
1174
- try { best = await (await lobby().fetch(`https://lobby/busiest?pools=${encodeURIComponent(pools)}${not ? `&not=${encodeURIComponent(not)}` : ''}${wver ? `&ver=${encodeURIComponent(wver)}` : ''}`)).json(); } catch { best = null; }
1175
- return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
1176
- }
1177
- if (sub === 'api/lobby') {
1178
- // A standalone copy's page reads this answer from another origin: the Worker's own `fetch` adds its header to
1179
- // every answer of this address (worker/standalone.mjs), whichever line here made it.
1180
- const app = isAppOrigin(request);
1181
- if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
1182
- // ?server=<id>: a room of that server's pool (its door first); none: public. Strangers never meet across servers.
1183
- const sid = url.searchParams.get('server') ?? 'public';
1184
- // An app plays on the public server only: it has no account and no invite to show a server's door.
1185
- if (app && sid !== 'public') return json({ ok: false, error: 'app-public', message: 'A standalone copy plays on the public server.' }, 403);
1186
- const srv = SERVER_ID.test(sid) || sid === 'public' ? serverById(sid) : null;
1187
- if (!srv) return json({ ok: false, error: 'no-server', message: `${meta.name} has no server called ${String(sid).slice(0, 24)}.` }, 404);
1188
- if (srv.id === 'public' && publicHidden() && !(await pageDoor(srv)).owner) return json({ ok: false, error: 'closed', message: 'Pick a server to play on.' }, 403);
1189
- const d = await pageDoor(srv);
1190
- if (!d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
1191
- // A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
1192
- const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
1193
- const pol = policyFor(srv);
1194
- // Strangers on different builds of the game never meet (section 23): the Lobby matches within the live build.
1195
- // A standalone copy is the build it was made from, whatever is live now: it says its own (`gv`, or none for a
1196
- // game that named none then) and is matched with copies of that build. What it says is its own word, so the
1197
- // Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
1198
- const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
1199
- if (app && meta?.room?.host === 'server' && ver !== meta.room.build) return json({ ok: false, error: 'stale', message: 'Update the app to play online.' }, 409);
1200
- if (meta.room?.lifetime === 'persistent') {
1201
- const room = srv.id === 'public' ? 'pub-1' : `s-${srv.id}-1`;
1202
- if (not.split(',').includes(room)) return json({ ok: false, error: 'world-unavailable', message: 'This world is unavailable. Choose another named world.' }, 409);
1203
- return json({ room, players: 0, max: humanSeats(pol), server: srv.id });
1204
- }
1205
- return lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
1206
- }
1207
- if (sub === 'api/agent') {
1208
- // An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
1209
- return sitRoute(request, env, url, {
1210
- meta, launch,
1211
- serverOf: (id) => serverById(id),
1212
- policyOf: (srv) => policyFor(srv),
1213
- ticketFor: (holder) => ticketFor(env, game, holder),
1214
- join: async (srv, pol) => {
1215
- try {
1216
- const reserve = pol.aiSeats + pol.guides;
1217
- const r = await (await lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&max=${max}&server=${srv.id}&agent=1&ai=${reserve}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json();
1218
- return r.room ?? null;
1219
- } catch { return null; }
1220
- },
1221
- });
1222
- }
1223
- if (sub === 'api/chat/report') {
1224
- // A player reports one chat line (section 19): the room's own copy of it is what is kept, never the reporter's words.
1225
- if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'a report comes from this site\'s own pages' }, 403);
1226
- if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
1227
- let body = {};
1228
- try { body = JSON.parse((await request.text()).slice(0, 2048) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
1229
- const room = String(body.room ?? '');
1230
- const id = String(body.id ?? '');
1231
- if (!ROOM_ID.test(room) || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return json({ ok: false, error: 'bad-request', message: 'room and id name the message' }, 400);
1232
- const reason = REPORT_REASONS.includes(body.reason) ? body.reason : 'other';
1233
- const key = `${BROWSER_KEY.test(String(body.b ?? '')) ? body.b : ''}|${request.headers.get('cf-connecting-ip') ?? ''}`;
1234
- if (!reportAllowed(key)) return json({ ok: false, error: 'rate', message: 'That is a lot of reports: the studio has them. Try again in a few minutes.' }, 429);
1235
- let line = null;
1236
- try { line = (await (await env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__chat?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}&op=line&id=${encodeURIComponent(id)}`)).json()).line ?? null; } catch { line = null; }
1237
- if (!line) return json({ ok: false, error: 'gone', message: 'That message is no longer in the room.' }, 404);
1238
- if (line.by === 'studio') return json({ ok: false, error: 'studio', message: 'That is the studio\'s own line.' }, 400);
1239
- try { await fileReport(env, { game, room, line, reason }); } catch (error) {
1240
- return json({ ok: false, error: 'not-migrated', message: `Reports need migration 0007_studio_chat.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
1241
- }
1242
- return json({ ok: true, message: 'Thanks for telling the studio. They will look at it.' });
1243
- }
1244
- if (sub === '__net' || sub === '__watch') {
1245
- if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
1246
- const room = url.searchParams.get('room') || 'main';
1247
- if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
1248
- // Who the ticket names: the door of a game that is not public, and in any game a signed-in player's account.
1249
- const who = url.searchParams.get('t') ? await ticketSub(env, game, url.searchParams.get('t')) : null;
1250
- if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('this game is not open to you', { status: 403 });
1251
- const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : '';
1252
- // A watcher's socket (section 16): what the game lets watchers see goes with every socket, so a helper that only
1253
- // says `watch` in its hello is held to it too; a game that cannot be watched refuses a watch door's socket here.
1254
- const policy = watchOf(meta);
1255
- const w = sub === '__net' && url.searchParams.get('w') === '1';
1256
- if (w && policy === 'off') return new Response('this game cannot be watched', { status: 403 });
1257
- // The room's server (section 17): its door for a person, and the policy the room applies. An AI's ticket
1258
- // (a-<pass>) is checked against its pass and the server: a humans-only server refuses it here and again at the relay.
1259
- const srv = serverById(roomServer(room));
1260
- if (!srv) return new Response('this server does not exist', { status: 404 });
1261
- const parts = holders(who);
1262
- const agentPart = parts.find((x) => x.startsWith('a-'));
1263
- const pol = policyFor(srv, !pooledRoom(room));
1264
- let ag = null;
1265
- if (agentPart && sub === '__net') {
1266
- const pass = await passById(env, agentPart.slice(2));
1267
- const why = passRefusal(pass, { game, server: srv.id, policy: pol, launch });
1268
- if (why) return new Response(why.error === 'agents-off' ? 'This server is for humans only\n' : `${why.message}\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } });
1269
- ag = { ...agentFacts(pass), name: aiName(pass.label) };
1270
- } else if (srv.state !== 'open' || srv.door !== 'open' || (srv.policy === 'beginner' && !w && sub === '__net')) {
1271
- const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
1272
- if (!access.ok) return new Response('this server is not open to you', { status: 403 });
1273
- }
1274
- // Room chat (section 19): the room's rules, and who this socket is to them: an account with a passkey (not a
1275
- // guest), a member of this server, or another site's page watching from elsewhere (homie.rocks's room page).
1276
- const chat = chatOf(meta, srv, await chatRowsOf(env));
1277
- const { acct, member } = await chatWho(env, game, srv, who);
1278
- const origin = request.headers.get('origin');
1279
- const hub = sub === '__watch' && Boolean(origin) && origin !== 'null' && origin !== url.origin;
1280
- const lean = { ...chat, emoji: chat.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
1281
- // A supporter's badge (the shop, 0.24.0): what the player's account owns, looked up here, so a hello can never claim
1282
- // one; none on a kids server, none for an AI or a watcher.
1283
- const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
1284
- const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
1285
- const stub = sub === '__net' ? gateFor(env, game, room, max, b) : env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
1286
- // Revision 9 (NETPLAY.md sections 22 and 23): the build that is live now (`cur`, always said, empty when the game
1287
- // names none), the build this socket's page was served with (`gv`, from the socket's own address), and the game's
1288
- // own stall time.
1289
- const cur = versionOf(meta?.netplay?.version) ?? '';
1290
- const gv = versionOf(url.searchParams.get('gv')) ?? '';
1291
- const stall = Number(meta?.netplay?.stallMs) || 0;
1292
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
1293
- // The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
1294
- // tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
1295
- // uncaught error with nothing on it.
1296
- try { return await stub.fetch(new Request(target, request)); } catch (error) {
1297
- const gone = departure(error);
1298
- try { console[gone ? 'log' : 'error'](JSON.stringify({ at: new Date().toISOString(), ev: gone ? 'socket-gone' : 'room-unreachable', game, room, sub, error: errorLine(error) })); } catch { /* no console */ }
1299
- return new Response(gone ? 'gone' : 'the room did not answer; try again', { status: gone ? 499 : 503 });
1300
- }
1301
- }
1302
- // The same knock, relative to the game's own page: the same answer (see notAHomie).
1303
- if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
1304
- if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
1305
- if (url.searchParams.get('embed') === '1' && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('Embedding is off for this game.', cat);
1306
- const who = await ticketSub(env, game, url.searchParams.get('t'));
1307
- // A play page left open across a deploy (0.46.5). One that names what it was made for (`sh`) and is behind is
1308
- // told so, and reloads itself whole, once, to the same room and seat. One from before that handshake is sent
1309
- // to a fresh page only when this game cannot work inside it: the game keeps verified results for a player, and
1310
- // the old page has no player ticket to give its frame (a result would have nobody to be written for).
1311
- if (url.searchParams.get('pf') === '1' && url.searchParams.get('embed') !== '1' && url.searchParams.get('want') !== 'screen' && !url.searchParams.has('watch')) {
1312
- const said = url.searchParams.get('sh'), now = shellKey(meta);
1313
- const ticketless = Boolean(meta.room?.records && env.DB) && !holders(who).some((x) => x.startsWith('p-'));
1314
- if (said !== null ? said !== now : ticketless) {
1315
- return new Response(shellStandIn(url.searchParams.get('room') ?? '', said !== null ? now : null), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
1316
- }
1317
- }
1318
- if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
1319
- // An agent's frame (hands `self`): the game plays as that AI.
1320
- const agentPart = holders(who).find((x) => x.startsWith('a-'));
1321
- const pass = agentPart ? await passById(env, agentPart.slice(2)) : null;
1322
- return gameDocument(request, env, url, game, meta, cat, { agent: pass?.live ? { name: aiName(pass.label), hands: pass.hands, role: pass.role } : null });
1323
- }
1324
- if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
1325
- }
1326
- return fallback(request, env, url, getCat);
1327
- }
1328
-
1329
- /**
1330
- * A game's servers with what is happening on them now (the Lobby's rooms by server, one request) and how many
1331
- * belong to each: the landing's Servers band, /<game>/servers/, a server's page and /<game>/api/servers.
1332
- */
1333
- async function serverLive(env, meta, servers, origin, lobby) {
1334
- let list = [];
1335
- try { list = (await (await lobby().fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
1336
- const by = {};
1337
- for (const r of list) {
1338
- if (!(r.players > 0)) continue;
1339
- const sid = r.server ?? roomServer(r.name);
1340
- const b = (by[sid] ??= { playing: 0, ai: 0, rooms: 0 });
1341
- b.playing += r.players; b.ai += Number(r.ai ?? r.agents ?? 0) || 0; b.rooms += 1;
1342
- }
1343
- const members = env.DB ? await memberCounts(env, meta.id) : {};
1344
- return servers.map((s) => ({ ...serverView(s, { origin, game: meta.id }), playing: by[s.id]?.playing ?? 0, ai: by[s.id]?.ai ?? 0, live: by[s.id]?.rooms ?? 0, members: members[s.id] ?? 0 }));
1345
- }
1346
-
1347
- /** A game's "played this week" on its landing, when the studio shares its numbers: Play presses and rounds with people. */
1348
- async function weekOf(env, game) {
1349
- const w = await playedThisWeek(env, game);
1350
- if (!w) return null;
1351
- let peak = 0;
1352
- try { peak = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
1353
- return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
1354
- }
1355
-
1356
- /** Everything else is a file of the built site (site/public, the games' files), with byte ranges for media. */
1357
- async function fallback(request, env, url, getCat) {
1358
- const asset = MEDIA_FILE.test(url.pathname) && request.headers.get('range') ? await assetWithRange(request, env, url) : await env.ASSETS.fetch(request);
1359
- if (asset.status === 404) {
1360
- // A manifest file outside music/ and videos/ that media move put in R2 keeps its address too.
1361
- const held = (request.method === 'GET' || request.method === 'HEAD') ? await heldInR2(env, getCat, url.pathname) : null;
1362
- if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
1363
- return notFoundPage('Nothing here.', await getCat());
1364
- }
1365
- return asset;
1366
- }
1367
-
1368
-
1369
- /** A server-hosted room in play keeps the alarm this far ahead; a paused one ends this long after its pause (the relay's `forgetMs`). */
1370
- const ROOM_ALARM_MS = 10 * 60_000;
1371
- const ROOM_PAUSE_MS = 60_000;
1372
-
1373
- /** Watchers, AI seats and restoration never extend an absence. */
1374
- export function roomEndAt({ people, pausedAt, createdAt, restoredAt = null, savedAt = null, alarmAt = null }) {
1375
- // Only people keep play alive. With no durable alarm, the last save bounds an unrecorded departure.
1376
- const absence = pausedAt ?? (restoredAt !== null && alarmAt === null ? savedAt : null) ?? restoredAt ?? createdAt;
1377
- return people > 0 ? Infinity : absence + ROOM_PAUSE_MS;
1378
- }
1379
-
1380
-
1381
- /**
1382
- * TABLE — one public room: the netplay relay (room.mjs) for every browser in it.
1383
- * It keeps nothing per frame; seats and a checkpoint go to storage (a deploy
1384
- * keeps the room), and each finished round is one D1 row.
1385
- */
1386
- export class Table {
1387
- constructor(ctx, env) {
1388
- this.ctx = ctx;
1389
- this.env = env;
1390
- this.room = null;
1391
- this.timer = null;
1392
- this.saved = null;
1393
- this.game = null;
1394
- this.code = null;
1395
- this.recorded = 0;
1396
- this.lastReport = -1;
1397
- /** This room was counted as opened (worker/stats.mjs `room`); cleared when its seats are all forgotten. */
1398
- this.openCounted = false;
1399
- /** The owner's holds, banner and closed door (they outlive an empty room), and the controls already used. */
1400
- this.officeSaved = null;
1401
- this.seen = new Map();
1402
- /** Revision 7: the game's vocabulary (agents.json, read once from the build), and the room's house guides. */
1403
- this.vocabRead = null;
1404
- this.house = null;
1405
- /** The AI brains' day (worker/agents.mjs): what the studio allows a day and has used, and what is not counted yet. */
1406
- this.brainDay = null;
1407
- this.brainCap = { neurons: BRAIN_BUDGET.neurons, micros: BRAIN_BUDGET.usd * 1e6 };
1408
- this.brainUsed = { neurons: 0, micros: 0 };
1409
- this.brainPending = { calls: {}, neurons: 0, micros: 0 };
1410
- this.brainReadAt = 0;
1411
- /** Room chat's review (section 19): the studio's day (budget and use, read at most once a minute) and what is not counted yet. */
1412
- this.chatCap = null;
1413
- this.chatUsed = 0;
1414
- this.chatReadAt = 0;
1415
- this.chatPend = { neurons: 0, ok: 0, held: 0, error: 0 };
1416
- this.chatSent = { chatLines: 0, chatReacts: 0, held: {} };
1417
- /**
1418
- * Revision 10 (NETPLAY.md section 29): the host runtime of a server-hosted room (rules/host.ts), and when this
1419
- * room next needs the object's one alarm (the house guides and the day's counters share it).
1420
- */
1421
- this.hostRt = null;
1422
- this.characterGrants = new Map();
1423
- this.accountTokens = {};
1424
- this.inputTransit = [];
1425
- this.relayed = false;
1426
- this.gateHeld = new Map();
1427
- this.gatePromised = new Map();
1428
- this.roomAlarmAt = 0;
1429
- this.durable = null;
1430
- this.roomSave = null;
1431
- this.endedRoom = null;
1432
- this.pausedAt = null;
1433
- ctx.blockConcurrencyWhile(async () => {
1434
- // Only a confirmed missing alarm permits the save-age fallback. A read failure is not evidence of absence.
1435
- this.recoveredAlarmAt = Infinity;
1436
- try { this.recoveredAlarmAt = (await ctx.storage.getAlarm?.()) ?? null; }
1437
- catch (error) { this.storageProblem(error); }
1438
- this.saved = (await ctx.storage.get('net')) ?? null;
1439
- if (this.saved?.retired === true) this.cleanupPending = true;
1440
- this.recorded = (await ctx.storage.get('recorded')) ?? 0;
1441
- this.officeSaved = (await ctx.storage.get('office')) ?? null;
1442
- // A big match room's last unconfirmed checkpoint (worker/room-store.mjs), read once as the object wakes.
1443
- try { this.looseSave = (await ctx.storage.list?.({ prefix: LOOSE_SAVE })) ?? null; }
1444
- catch (error) { this.looseSave = null; this.storageProblem(error); }
1445
- });
1446
- }
1447
-
1448
- roomFor(game, code, max) {
1449
- if (this.room) return this.room;
1450
- const storage = this.ctx.storage;
1451
- this.game = game;
1452
- this.code = code;
1453
- this.room = new NetRoom({
1454
- code,
1455
- maxPlayers: max,
1456
- perIp: perAddress(max),
1457
- store: {
1458
- save: (o) => {
1459
- if (this.hostRt && !this.relayFrame) this.saveRoom(this.hostRt.save());
1460
- else if (this.hostRt) {
1461
- // All admissions in one relay turn share a checkpoint. The microtask
1462
- // runs before the relay's timer flush, retaining the storage output gate.
1463
- if (!this.admissionSaveQueued) {
1464
- this.admissionSaveQueued = true;
1465
- const host = this.hostRt;
1466
- queueMicrotask(() => {
1467
- this.admissionSaveQueued = false;
1468
- if (this.hostRt === host) { try { this.saveRoom(host.save()); } catch (error) { this.storageProblem(error); } }
1469
- });
1470
- }
1471
- } else if (!hostedGame(game)) storage.put('net', o).catch(() => {});
1472
- },
1473
- clear: () => { this.clearRoom(); },
1474
- },
1475
- // The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
1476
- // election, a build change, and `failed` (a room operation that threw). A departure and a failure seconds apart
1477
- // are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
1478
- log: (line) => this.say(line),
1479
- });
1480
- this.room.gateFacts = () => [...(this.gateMetrics?.values() ?? [])];
1481
- if (this.saved && !hostedGame(game)) this.room.restore(this.saved);
1482
- if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
1483
- this.vocabRead = this.readVocab(game).catch(() => false);
1484
- // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
1485
- this.room.review = (text, opts) => this.reviewChat(text, opts);
1486
- // Kept chat (0.29.0): a room whose rules keep `history` writes what was said to D1 and deletes what is taken down,
1487
- // one after another (a line removed the moment it went out is never written after its delete).
1488
- this.room.onPublished = (rec) => this.keptQueue(() => keepLines(this.env, this.game, this.code, [rec]));
1489
- this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
1490
- // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
1491
- this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
1492
- // A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
1493
- this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
1494
- if (this.isPersistent()) this.room.forgetMs = Infinity;
1495
- return this.room;
1496
- }
1497
-
1498
- /* ------------------------------------------------------------ the server as host (NETPLAY.md section 29) */
1499
-
1500
- /**
1501
- * A room of a server-hosted game gets its host runtime before its first socket is attached, so no browser is ever
1502
- * elected. `hosted`: the Worker's word that this game's settings say `host: server`. Rules that are missing from
1503
- * this build, or do not fit the contract, do not start: the room refuses joins and says why.
1504
- */
1505
- isPersistent() { return this.persistentStored === true || hostedBuild(this.game)?.settings?.lifetime === 'persistent'; }
1506
-
1507
- ensureHost(room, hosted) {
1508
- if (this.hostRt || !this.game) return;
1509
- if (!hostedGame(this.game)) {
1510
- if (hosted) room.failServerHost(RULES_WIRING_MESSAGE);
1511
- return;
1512
- }
1513
- let restoring = false;
1514
- let pauseWriteFailed = false;
1515
- try {
1516
- const build = hostedBuild(this.game);
1517
- const persistent = this.isPersistent();
1518
- try { this.durable ??= this.openStore(); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
1519
- let saved = this.cleanupPending ? null : this.readRoom();
1520
- restoring = Boolean(saved);
1521
- if (this.persistentStored && build.settings.lifetime !== 'persistent') throw new Error('This room holds a lasting world. Keep room.lifetime persistent; its save is retained.');
1522
- let boot = { epoch: 0, count: 0 };
1523
- try { boot = this.durable?.boots() ?? boot; } catch (error) { if (saved) throw error; this.storageProblem(error); }
1524
- if (!Number.isSafeInteger(boot.epoch) || boot.epoch < 0 || !Number.isInteger(boot.count) || boot.count < 0) {
1525
- const error = new Error('saved boot counter is invalid');
1526
- if (saved) throw error;
1527
- this.storageProblem(error); this.clearRoom(); boot = { epoch: 0, count: 0 };
1528
- }
1529
- if (saved && saved.buildHash !== build.build) boot.count = 0;
1530
- // A restore's provisional pause is not a recorded departure. A stored alarm,
1531
- // even one now due, has not yet confirmed expiry. The alarm handler owns that decision.
1532
- const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
1533
- const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
1534
- const expired = Date.now() >= restoredEnd;
1535
- if (saved && persistent && saved.stateHash !== build.stateHash) throw new Error('This lasting world needs its previous state schema. Restore the compatible build; the saved world has been retained.');
1536
- if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
1537
- const why = saved.stateHash !== build.stateHash ? 'state-changed' : boot.count >= 2 ? 'restore loop' : 'nobody-returned';
1538
- const tokens = new Set((saved.net.seats ?? []).map((s) => s[1]));
1539
- this.finishRoom(why);
1540
- room.forget(); room.ended = null;
1541
- // Only a holder returning to the old match is rematched. A new visitor uses this name immediately.
1542
- if (!expired && pooledRoom(this.code)) room.endedMatch = { why, ver: build.build, tokens, until: Date.now() + ROOM_PAUSE_MS };
1543
- this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
1544
- }
1545
- this.roomSave = saved;
1546
- this.accountTokens = saved?.accountTokens ?? {};
1547
- if (saved) {
1548
- // The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
1549
- room.seats.clear(); room.state.clear(); room.stateBytes = 0;
1550
- room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
1551
- this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
1552
- this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
1553
- saved.pausedAt = this.pausedAt;
1554
- // A failed pause write must never be mistaken for an invalid host save.
1555
- try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
1556
- }
1557
- const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
1558
- if (saved) this.durable.boot({ epoch, count: boot.count });
1559
- this.hostCreatedAt = Date.now();
1560
- let ticks = 0;
1561
- this.hostRt = startHost(this.game, {
1562
- ...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
1563
- record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
1564
- store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment, true) },
1565
- // A room behind Gates needs no snapshot as text: nothing is encoded that nobody is sent (room.mjs, hostSnap).
1566
- snapshotText: () => !this.relayed,
1567
- ...(build.settings.characters ? { characterFor: (peer) => {
1568
- const account = room.clients.get(peer.id)?.player;
1569
- return account ? this.characterGrants.get(account) ?? null : null;
1570
- } } : {}),
1571
- onTick: () => {
1572
- if (!saved) return;
1573
- ticks += 1;
1574
- if (ticks === 1) this.pendingBoot = { epoch, count: saved.buildHash !== build.build ? 0 : boot.count + 1 };
1575
- if (ticks === 10 * build.settings.tickHz) this.pendingBoot = { epoch, count: 0 };
1576
- if (this.pendingBoot && Date.now() >= (this.bootRetryAt ?? 0)) { try { this.durable.boot(this.pendingBoot); this.pendingBoot = null; this.bootFailures = 0; } catch (error) { this.bootRetryAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(this.bootFailures ?? 0, 6)); this.bootFailures = (this.bootFailures ?? 0) + 1; this.storageProblem(error); } }
1577
- },
1578
- send: (m, text) => room.hostFrame(m, text),
1579
- log: (line) => { if (line.ev === 'persist-failed') this.storageProblem(new Error(line.error)); else this.say(line); },
1580
- onPause: () => { this.pauseFromRestore = false; this.pausedAt ??= Date.now(); this.armRoom(this.pausedAt + ROOM_PAUSE_MS); },
1581
- onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.armRoom(Date.now() + ROOM_ALARM_MS); },
1582
- onEnd: (why) => this.endHosted(why),
1583
- });
1584
- room.setServerHost(this.hostRt);
1585
- room.gameVer = build.build;
1586
- room.setCurrent(build.build);
1587
- if (pauseWriteFailed) { try { this.saveRoom(this.hostRt.save()); } catch { /* the save retry retains the original absence */ } }
1588
- if (saved) {
1589
- room.hostFrame({ t: 'state', k: 'shared', d: this.hostRt.core.shared() });
1590
- room.hostFrame({ t: 'snap', from: null, e: epoch, k: this.hostRt.tick, st: Date.now(), d: this.hostRt.core.snapshot(), c: [] });
1591
- }
1592
- this.armRoom(roomEndAt({ people: 0, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt }));
1593
- } catch (error) {
1594
- this.hostRt?.stop(); this.hostRt = null;
1595
- if (restoring && !this.isPersistent()) {
1596
- this.storageProblem(error);
1597
- room.forget(); this.clearRoom();
1598
- this.skipSave = true;
1599
- this.ensureHost(room, hosted);
1600
- return;
1601
- }
1602
- room.failServerHost(`This game's rules could not start: ${String(error?.message ?? error).slice(0, 160)}`);
1603
- }
1604
- }
1605
-
1606
- /** This object's room store, with the unconfirmed checkpoint it woke to (if any). */
1607
- openStore(persistent = this.isPersistent()) {
1608
- const loose = this.looseSave; this.looseSave = null;
1609
- return roomStore(this.ctx.storage, { persistent, loose, onProblem: (error) => this.storageProblem(error) });
1610
- }
1611
-
1612
- /** `periodic`: the rules host's own checkpoint of a tick, as against a save an admission or a pause asked for. */
1613
- saveRoom(bytes, commitment, periodic = false) {
1614
- if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
1615
- const now = Date.now();
1616
- if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
1617
- try {
1618
- this.durable ??= this.openStore();
1619
- const build = hostedBuild(this.game);
1620
- const host = new TextDecoder().decode(bytes);
1621
- const net = this.room.saved();
1622
- if (build.settings.characters) for (const client of this.room.clients.values()) {
1623
- if (client.player && client.seat !== null && client.token) this.accountTokens[client.player] = client.token;
1624
- }
1625
- if (build.settings.characters) {
1626
- const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
1627
- for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
1628
- }
1629
- const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, stateHash: build.stateHash, buildHash: build.build, host, net };
1630
- const clearing = this.cleanupPending;
1631
- if (clearing) this.durable.clear();
1632
- if (this.isPersistent()) {
1633
- const c = compiledFor(this.game);
1634
- commitment ??= { force: false, seconds: c.settings.durability.movementSeconds, tickHz: c.settings.tickHz,
1635
- fields: { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) } };
1636
- this.durable.commit(saved, commitment);
1637
- } else if (this.relayed && periodic && !clearing) {
1638
- // A room behind Gates: its once-a-second checkpoint holds none of its frames (worker/room-store.mjs). A save
1639
- // an admission asks for is still confirmed before the welcome that names the seat can leave.
1640
- this.durable.write(saved, { unconfirmed: true });
1641
- } else this.durable.write(saved);
1642
- if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
1643
- this.roomSave = saved;
1644
- if (build.settings.characters) this.ctx.waitUntil(this.flushCharacters().catch(error => this.storageProblem(error)));
1645
- this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
1646
- this.saveFailures = 0; this.saveRetryAt = 0; this.saveError = null;
1647
- clearTimeout(this.saveRetry); this.saveRetry = null;
1648
- this.storageHealth = { ok: !this.pendingBoot };
1649
- } catch (error) {
1650
- this.saveError = error;
1651
- const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.saveFailures ?? 0, 6));
1652
- this.saveFailures = (this.saveFailures ?? 0) + 1; this.saveRetryAt = now + delay;
1653
- this.storageProblem(error);
1654
- if (this.isPersistent()) this.endHosted('storage-failed');
1655
- if (!this.isPersistent() && !this.saveRetry) {
1656
- this.saveRetry = setTimeout(() => {
1657
- this.saveRetry = null;
1658
- if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* saveRoom scheduled the next attempt */ } }
1659
- }, delay);
1660
- this.saveRetry?.unref?.();
1661
- }
1662
- throw error;
1663
- }
1664
- }
1665
-
1666
- storageProblem(error) {
1667
- this.storageHealth = { ok: false, message: String(error?.message ?? error).slice(0, 160), since: this.storageHealth?.ok === false ? this.storageHealth.since : Date.now() };
1668
- if (Date.now() < (this.storageLogAt ?? 0)) return;
1669
- this.storageLogAt = Date.now() + 1000;
1670
- this.say({ ev: 'persist-failed', build: hostedBuild(this.game)?.build, error: this.storageHealth.message });
1671
- }
1672
-
1673
- readRoom(validateHost = false, alreadyRead) {
1674
- if (this.skipSave) { this.skipSave = false; return null; }
1675
- try {
1676
- const boot = this.durable?.boots();
1677
- this.persistentStored = boot?.persistent === true;
1678
- if (boot?.retired === true) { this.clearRoom(); return null; }
1679
- const saved = alreadyRead ?? this.durable?.read();
1680
- if (!saved) return null;
1681
- if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
1682
- this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
1683
- !Number.isFinite(saved.at) || saved.at < 0 || saved.at > Date.now() + 5000 || saved.pausedAt !== null && (!Number.isFinite(saved.pausedAt) || saved.pausedAt < 0 || saved.pausedAt > Date.now() + 5000) ||
1684
- saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' ||
1685
- saved.host?.v !== 1 || !saved.net || typeof saved.stateHash !== 'string' || typeof saved.buildHash !== 'string') throw new Error('save envelope does not fit this room');
1686
- saved.at = Math.min(saved.at, Date.now());
1687
- if (saved.pausedAt !== null) saved.pausedAt = Math.min(saved.pausedAt, Date.now());
1688
- if (hostedGame(saved.game) && saved.stateHash !== hostedBuild(saved.game).stateHash) return saved;
1689
- this.game ??= saved.game; this.code ??= saved.room;
1690
- const seats = saved.net.seats;
1691
- const cap = hostedBuild(saved.game)?.seats ?? 32;
1692
- if (saved.net.v !== 1 || saved.net.room !== saved.room || !Array.isArray(seats) || seats.length > cap ||
1693
- new Set(seats.map((s) => s?.[0])).size !== seats.length || new Set(seats.map((s) => s?.[1])).size !== seats.length ||
1694
- seats.some((s) => !Array.isArray(s) || !Number.isInteger(s[0]) || s[0] < 0 || s[0] >= cap || typeof s[1] !== 'string' || s[1].length > 128 || typeof s[2] !== 'string' || s[2].length > 40 || s[4] !== null && (!Number.isSafeInteger(s[4]) || s[4] < 0) || s[5] !== null && !Number.isFinite(s[5]))) throw new Error('saved seats do not fit this room');
1695
- if (validateHost && hostedGame(saved.game) && saved.stateHash === hostedBuild(saved.game).stateHash) {
1696
- const probe = startHost(saved.game, { restore: new TextEncoder().encode(saved.hostText ?? JSON.stringify(saved.host)), send() {} });
1697
- probe.stop();
1698
- } else if (!hostedGame(saved.game)) throw new Error('saved game is not in this build');
1699
- return saved;
1700
- } catch (error) { this.storageProblem(error); if (this.isPersistent()) throw error; this.clearRoom(); return null; }
1701
- }
1702
-
1703
- clearRoom() {
1704
- if (this.isPersistent()) throw new Error('A lasting world cannot be cleared by match cleanup');
1705
- this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
1706
- this.roomSave = null; this.saved = null; this.pendingBoot = null;
1707
- try { this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
1708
- catch (error) { this.retryCleanup(error); }
1709
- this.queueNetCleanup(this.cleanupPending);
1710
- }
1711
-
1712
- queueNetCleanup(retired) {
1713
- // The key-value record can preserve retirement when SQL writes, not just deletes, are unavailable.
1714
- // Serialize it with a later successful save so an older failure cannot retire the new match.
1715
- const generation = this.cleanupGeneration;
1716
- this.netCleanup = (this.netCleanup ?? Promise.resolve()).then(() => {
1717
- if (generation !== this.cleanupGeneration) return;
1718
- return retired ? this.ctx.storage.put('net', { retired: true }) : this.ctx.storage.delete('net');
1719
- }).then(() => {
1720
- if (generation !== this.cleanupGeneration) return;
1721
- this.netCleanupFailed = false;
1722
- clearTimeout(this.netCleanupRetry); this.netCleanupRetry = null;
1723
- if (!this.cleanupPending) this.cleanupFailures = 0;
1724
- }).catch((error) => {
1725
- if (generation !== this.cleanupGeneration) return;
1726
- if (retired) { this.retryCleanup(error); return; }
1727
- // Removing an old key marker is not permission to delete a newer SQL match.
1728
- this.netCleanupFailed = true; this.storageProblem(error);
1729
- const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.cleanupFailures ?? 0, 6));
1730
- this.cleanupFailures = (this.cleanupFailures ?? 0) + 1;
1731
- this.armRoom(Date.now() + delay);
1732
- if (!this.netCleanupRetry) {
1733
- this.netCleanupRetry = setTimeout(() => { this.netCleanupRetry = null; if (generation === this.cleanupGeneration) this.queueNetCleanup(false); }, delay);
1734
- this.netCleanupRetry?.unref?.();
1735
- }
1736
- });
1737
- this.ctx.waitUntil(this.netCleanup);
1738
- }
1739
-
1740
- retryCleanup(error) {
1741
- this.cleanupPending = true; this.storageProblem(error);
1742
- const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.cleanupFailures ?? 0, 6));
1743
- this.cleanupFailures = (this.cleanupFailures ?? 0) + 1;
1744
- this.armRoom(Date.now() + delay);
1745
- // An alarm write can fail as well. Keep the end pending for this object's lifetime.
1746
- if (!this.cleanupRetry) {
1747
- this.cleanupRetry = setTimeout(() => { this.cleanupRetry = null; if (this.cleanupPending) this.clearRoom(); }, delay);
1748
- this.cleanupRetry?.unref?.();
1749
- }
1750
- }
1751
-
1752
- finishRoom(why) {
1753
- if (this.isPersistent()) { this.hostRt?.stop(); this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`); return; }
1754
- this.hostRt?.stop(); this.hostRt = null;
1755
- clearInterval(this.timer); this.timer = null;
1756
- clearTimeout(this.saveRetry); this.saveRetry = null; this.saveRetryAt = 0;
1757
- this.clearRoom();
1758
- const room = this.room;
1759
- if (room) {
1760
- this.officeSaved = room.officeSaved();
1761
- room.ended = { why, ver: hostedBuild(this.game)?.build ?? null };
1762
- for (const w of room.watchers) { try { w.close(1000, 'room-over'); } catch { /* gone */ } }
1763
- room.watchers.clear();
1764
- }
1765
- this.tellLobby({ ended: true });
1766
- this.say({ ev: 'host-over', why, build: hostedBuild(this.game)?.build, ...(why === 'restore loop' ? { message: 'This room shares its isolate and may not be the cause.' } : {}) });
1767
- this.room = null; this.endedRoom = null; this.pausedAt = null;
1768
- }
1769
-
1770
- /** The room wants the alarm at `at` (ms): set it unless an earlier one is already waiting. */
1771
- armRoom(at) {
1772
- if (this.isPersistent()) return;
1773
- this.roomAlarmAt = at;
1774
- this.alarmWrite = (this.alarmWrite ?? Promise.resolve()).then(async () => {
1775
- const cur = await this.ctx.storage.getAlarm();
1776
- if (cur === null || cur > at) await this.ctx.storage.setAlarm(at);
1777
- this.alarmFailures = 0;
1778
- }).catch((error) => {
1779
- this.storageProblem(error);
1780
- if (this.alarmRetry) return;
1781
- const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.alarmFailures ?? 0, 6));
1782
- this.alarmFailures = (this.alarmFailures ?? 0) + 1;
1783
- this.alarmRetry = setTimeout(() => { this.alarmRetry = null; this.armRoom(this.roomAlarmAt); }, delay);
1784
- this.alarmRetry?.unref?.();
1785
- });
1786
- this.ctx.waitUntil(this.alarmWrite);
1787
- }
1788
-
1789
- /**
1790
- * The rules failed again and again (rules/host.ts: the budget on every tick for two seconds, or slow ticks for
1791
- * five): the room ends. Its players are told, their seats are freed, and the next visitor starts a fresh room.
1792
- */
1793
- endHosted(why) {
1794
- if (this.isPersistent()) {
1795
- this.persistentFailed = true;
1796
- // The host's own stop would checkpoint a healthy world; this one is refused by `persistentFailed`, and the committed save stands.
1797
- try { this.hostRt?.stop(); } catch { /* stopped all the same */ }
1798
- clearTimeout(this.saveRetry); this.saveRetry = null;
1799
- this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`);
1800
- return;
1801
- }
1802
- const room = this.room;
1803
- if (!room) return;
1804
- this.finishRoom(why);
1805
- this.hostRt = null;
1806
- for (const c of [...room.clients.values()]) {
1807
- if (c.helloed) room.error(c, 'room-over', 'This room ended. Joining a fresh room.', { rematch: true, ver: room.ended.ver });
1808
- room.clients.delete(c.id);
1809
- try { c.conn.close(1011, 'room-over'); } catch { /* gone */ }
1810
- }
1811
- room.emptySince = Date.now();
1812
- room.forget();
1813
- this.report();
1814
- }
1815
-
1816
- /**
1817
- * The room's part of the alarm (section 4.5 of the design). A room nobody came back to within a minute of its
1818
- * pause ends: the relay forgets it and its stored seats go (`office` and `recorded` stay). A room in play keeps an
1819
- * alarm ahead of it, so one that Cloudflare restarted and nobody returned to is still ended. After a restart there
1820
- * is no room in memory: stored seats of a server-hosted room that nobody reconnected to are deleted.
1821
- */
1822
- async roomAlarm() {
1823
- try { await this.runRoomAlarm(); this.roomAlarmFailures = 0; }
1824
- catch (error) {
1825
- this.storageProblem(error);
1826
- const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.roomAlarmFailures ?? 0, 6));
1827
- this.roomAlarmFailures = (this.roomAlarmFailures ?? 0) + 1;
1828
- this.armRoom(Date.now() + delay);
1829
- }
1830
- }
1831
-
1832
- async runRoomAlarm() {
1833
- if (this.netCleanupFailed) this.queueNetCleanup(false);
1834
- if (this.cleanupPending) this.clearRoom();
1835
- const now = Date.now();
1836
- const room = this.room;
1837
- if (!room || !this.hostRt) {
1838
- if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
1839
- this.durable ??= this.openStore(false);
1840
- let saved = this.cleanupPending ? null : this.readRoom();
1841
- if (saved) {
1842
- const generation = this.cleanupGeneration;
1843
- const preparing = prepareHost(saved.game);
1844
- if (preparing) {
1845
- await preparing;
1846
- // A join or office action can arrive while the optional engine loads.
1847
- // Re-read that lifecycle instead of writing the pre-await checkpoint.
1848
- if (this.room !== room || this.hostRt || this.cleanupGeneration !== generation) return this.runRoomAlarm();
1849
- }
1850
- saved = this.readRoom(true, saved);
1851
- }
1852
- if (saved) {
1853
- this.game = saved.game; this.code = saved.room;
1854
- if (this.isPersistent()) return;
1855
- if (saved.stateHash !== hostedBuild(saved.game)?.stateHash) { this.finishRoom('state-changed'); return; }
1856
- // No saved pause means people were present. The first cold wake starts the absence period.
1857
- if (saved.pausedAt === null) {
1858
- this.pausedAt ??= now; saved.pausedAt = this.pausedAt; this.durable.write(saved);
1859
- }
1860
- const endAt = roomEndAt({ people: 0, pausedAt: saved.pausedAt, createdAt: now });
1861
- if (now < endAt) { this.armRoom(endAt); return; }
1862
- this.finishRoom('nobody-returned');
1863
- }
1864
- }
1865
- if (this.saved?.hosted === 'server') { const was = this.saved.room ?? null; this.saved = null; await this.ctx.storage.delete('net').catch(() => {}); this.say({ ev: 'host-over', why: 'nobody-returned', room: was }); }
1866
- return;
1867
- }
1868
- if (!this.hostRt) return;
1869
- if (this.isPersistent()) { await this.flushCharacters(); return; }
1870
- const endAt = roomEndAt({ people: this.hostRt.people, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt });
1871
- if (endAt === Infinity) { if (now >= this.roomAlarmAt - 1000) this.armRoom(now + ROOM_ALARM_MS); else this.armRoom(this.roomAlarmAt); return; }
1872
- if (now < endAt) { this.armRoom(endAt); return; }
1873
- // Nobody returned: neither watchers nor AI seats keep the paused room.
1874
- this.finishRoom('nobody-returned');
1875
- for (const c of [...room.clients.values()]) { if (c.helloed) room.error(c, 'room-over', 'Everyone left, so this room ended.', { rematch: true }); room.clients.delete(c.id); try { c.conn.close(1000, 'room-over'); } catch { /* gone */ } }
1876
- room.forget();
1877
- this.report();
1878
- }
1879
-
1880
- /**
1881
- * One log line of this room: `{ at, ev, game, room, … }`. Never a seat token, a ticket, a browser key or an address;
1882
- * a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
1883
- */
1884
- say(line) {
1885
- if(line?.ev==='hello'&&line.seat!==null&&this.ctx.storage.sql){const c=this.room?.clients.get(line.id),owner='p'+this.room?.seats.get(line.seat)?.occ;if(c?.player){this.ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');this.ctx.storage.sql.exec('INSERT OR REPLACE INTO round_players VALUES(?,?)',owner,c.player);}}
1886
- if(line?.ev==='hello'&&!line.resumed&&line.seat!==null) this.queueFunctionEvent('player.joined',{game:this.game,room:line.room,player:this.room?.clients.get(line.id)?.conn?.player??null,seat:line.seat});
1887
- const bad = line?.ev === 'failed' || line?.ev === 'socket-error' || line?.ev === 'tick-failed' || line?.ev === 'persist-failed';
1888
- if (!bad && this.env.HOMIE_ROOM_LOG === '0') return;
1889
- try { console[bad ? 'error' : 'log'](JSON.stringify({ at: new Date().toISOString(), ...line, game: this.game ?? null, room: line?.room ?? this.code ?? null })); } catch { /* no console */ }
1890
- }
1891
-
1892
- queueRoundRecord(owner,key,value) {
1893
- const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
1894
- sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
1895
- const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
1896
- const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
1897
- if(!player){
1898
- // Never dropped in silence (0.46.5): the log says so, and the person is told on their own screen.
1899
- try{console.error(JSON.stringify({at:new Date().toISOString(),ev:'record-unsaved',why:'no-player',game:this.game??null,room:this.code??null,key,id:value?.id??null}));}catch{}
1900
- if(client)this.room.error(client,'unsaved','This round was not saved to your history. Reload the page to keep the next one.');
1901
- return;
1902
- }
1903
- sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
1904
- sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
1905
- this.ctx.waitUntil(this.flushRoundRecords());
1906
- }
1907
- async flushRoundRecords(){
1908
- const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
1909
- if(!sql.exec("SELECT name FROM sqlite_master WHERE name='round_outbox'").toArray().length)return;
1910
- const {appendServerRecord}=await import('./saves.mjs');
1911
- for(const row of sql.exec('SELECT * FROM round_outbox LIMIT 100').toArray()){
1912
- try{await appendServerRecord(this.env,row.player,this.game,row.key,JSON.parse(row.value));sql.exec('DELETE FROM round_outbox WHERE id=?',row.id);}
1913
- catch(error){this.say({ev:'record-retry',error:String(error)});this.armRoom(Date.now()+60000);return;}
1914
- }
1915
- }
1916
- queueFunctionEvent(type,data) {
1917
- if(!this.env.DB || this.env.HOMIE_PREVIEW==='1' || !this.ctx.storage.sql) return;
1918
- this.ctx.waitUntil((async()=>{
1919
- if(!await (await import('./functions.mjs')).hasFunctionType(type,this.env))return;
1920
- const sql=this.ctx.storage.sql;
1921
- sql.exec('CREATE TABLE IF NOT EXISTS function_outbox(id TEXT PRIMARY KEY,type TEXT,data TEXT,at INTEGER)');
1922
- sql.exec('INSERT INTO function_outbox VALUES(?,?,?,?)',crypto.randomUUID(),type,JSON.stringify(data),Date.now());
1923
- this.armRoom(Date.now()+60000);
1924
- await this.flushFunctionEvents();
1925
- })());
1926
- }
1927
-
1928
- async flushFunctionEvents() {
1929
- const sql=this.ctx.storage.sql;
1930
- if(!sql || !this.env.DB)return;
1931
- if(!sql.exec("SELECT name FROM sqlite_master WHERE name='function_outbox'").toArray().length)return;
1932
- const {emitEvent}=await import('./functions.mjs');
1933
- for(const row of sql.exec('SELECT * FROM function_outbox LIMIT 100').toArray()) {
1934
- try {await emitEvent(this.env,row.type,JSON.parse(row.data),{id:row.id,at:row.at});sql.exec('DELETE FROM function_outbox WHERE id=?',row.id);}
1935
- catch {this.armRoom(Date.now()+60000);await this.alarmWrite;return;}
1936
- }
1937
- const cat=await catalogue(this.env,'https://studio.invalid');
1938
- await (await import('./functions.mjs')).runFunctions(this.env,cat,cat.studio?.url??'https://studio.invalid');
1939
- }
1940
-
1941
- /**
1942
- * A socket's `error` event. The runtime raises one when the other end simply went away ("Network connection lost":
1943
- * a closed tab, a phone that slept, a browser the test harness shut): that is a departure, and the room handles it
1944
- * as one. Anything else is a failed socket, logged as such. Both lines carry the room, the client and its role.
1945
- */
1946
- socketError(kind, event, facts = {}) {
1947
- const error = event?.error ?? event?.message ?? event;
1948
- const gone = departure(error) || !error || typeof error === 'object' && !error.message;
1949
- this.say({ ev: gone ? 'socket-gone' : 'socket-error', kind, ...facts, error: errorLine(error) });
1950
- }
1951
-
1952
- /** Kept chat's writes, in order, each finished before the next (D1 calls from one room never pass each other). */
1953
- keptQueue(fn) {
1954
- if (!this.env.DB) return;
1955
- this.kept = (this.kept ?? Promise.resolve()).then(fn).catch((error) => { try { console.log(JSON.stringify({ ev: 'chat-history-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ } });
1956
- this.ctx.waitUntil(this.kept);
1957
- }
1958
-
1959
- /** A room that keeps history puts what it kept back in its window before its first page opens (once per opening). */
1960
- async hydrateHistory(room) {
1961
- const days = room.chatRules().history;
1962
- if (!(days > 0) || room.hydrated || !this.env.DB) return;
1963
- room.hydrated = true;
1964
- try { await this.kept; room.hydrate(await historyOf(this.env, this.game, this.code, { days })); } catch { /* the window alone (before migration 0009) */ }
1965
- }
1966
-
1967
- /** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
1968
- async decideOn() {
1969
- if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
1970
- let on = false;
1971
- try {
1972
- const res = this.env.ASSETS ? await this.env.ASSETS.fetch(new Request('https://assets.local/games.json')) : null;
1973
- const cat = res?.ok ? await res.json() : null;
1974
- const g = (cat?.games ?? []).find((x) => x.id === this.game);
1975
- on = Boolean(g && (g.decide === true || (g.decide && typeof g.decide === 'object' && g.decide.on !== false)));
1976
- } catch { on = false; }
1977
- this.decideRead = { at: Date.now(), on };
1978
- return on;
1979
- }
1980
-
1981
- /**
1982
- * One of a game's own decisions (worker/brain.mjs checkDecide, picksOf): Clef on the studio's Workers AI (model
1983
- * HOMIE_DECIDE_MODEL, else clef-flash), or the person's own Ollama under `homie-studio dev`, within the AI brains' day
1984
- * (the same budget the guides spend: decisions and guides are one day of neurons). Not opted in, no AI, over budget, too
1985
- * slow or failed: `{ ok: false, why }`, and the host's floor answers.
1986
- */
1987
- async decide(state, questions) {
1988
- if (!(await this.decideOn())) return { ok: false, why: 'off' };
1989
- const local = this.env.AI ? null : localAiOf(this.env);
1990
- if (!this.env.AI && !local) return { ok: false, why: 'no-ai' };
1991
- if (this.env.AI) {
1992
- if (!this.brainDay || Date.now() - this.brainReadAt > 60_000) await this.readBrainDay().catch(() => {});
1993
- if (this.brainCap.neurons - this.brainUsed.neurons <= 0) return { ok: false, why: 'budget' };
1994
- }
1995
- const model = CLEF.models[this.env.HOMIE_DECIDE_MODEL] ? this.env.HOMIE_DECIDE_MODEL : '@cf/cloudflare/clef-flash';
1996
- const t0 = Date.now();
1997
- try {
1998
- const r = await clefRun(this.env, { state, questions, model, ms: DECIDE.ms, local });
1999
- const ms = Date.now() - t0;
2000
- const usage = r.usage ?? { input_tokens: Math.ceil(JSON.stringify({ state, questions }).length / 4) + 400, output_tokens: 0 };
2001
- const cost = costOf({ provider: r.engine === 'local' ? 'local' : 'workers-ai', model: r.model, usage });
2002
- this.brainSpend({ neurons: cost.neurons, micros: 0, provider: r.engine === 'local' ? 'decide-local' : 'decide' });
2003
- this.flushSoon();
2004
- const { picks, p } = picksOf(r.answers, questions);
2005
- return { ok: true, by: r.engine === 'local' ? 'local' : 'ai', picks, p, ms, neurons: cost.neurons };
2006
- } catch (error) {
2007
- return { ok: false, why: /took over/.test(String(error?.message ?? error)) ? 'slow' : 'error' };
2008
- }
2009
- }
2010
-
2011
- /** The day's counters go to D1 on an alarm: set one a minute out when none is set (a house guide's alarm also flushes). */
2012
- flushSoon() {
2013
- this.ctx.waitUntil((async () => { if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + 60_000); })().catch(() => {}));
2014
- }
2015
-
2016
- /**
2017
- * One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
2018
- * budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
2019
- * floor alone has decided (`by` says which), and the message goes out.
2020
- */
2021
- async reviewChat(text, { links = 'block' } = {}) {
2022
- // Under `homie-studio dev` with no binding, the person's own Ollama reviews (free, so no budget).
2023
- if (!this.env.AI) {
2024
- if (!localAiOf(this.env)) return { ok: true, by: 'none' };
2025
- try { const v = await reviewChat(this.env, text, { links }); return { ok: v.ok, by: 'local', why: v.why, ms: v.ms, p: v.p }; } catch { return { ok: true, by: 'error' }; }
2026
- }
2027
- if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
2028
- if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
2029
- try {
2030
- const v = await reviewChat(this.env, text, { links });
2031
- this.chatUsed += v.neurons;
2032
- this.chatPend.neurons += v.neurons;
2033
- this.chatPend[v.ok ? 'ok' : 'held'] += 1;
2034
- return { ok: v.ok, by: 'ai', why: v.why, ms: v.ms, p: v.p };
2035
- } catch (error) {
2036
- this.chatPend.error += 1;
2037
- try { console.log(JSON.stringify({ ev: 'chat-review-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ }
2038
- return { ok: true, by: 'error' };
2039
- }
2040
- }
2041
-
2042
- async readChatDay() {
2043
- const day = await chatDay(this.env, today());
2044
- this.chatCap = day.budget.neurons;
2045
- this.chatUsed = day.used.neurons + this.chatPend.neurons;
2046
- this.chatReadAt = Date.now();
2047
- }
2048
-
2049
- /** Chat's counts into D1 (stats_daily): lines, reactions, held by why, reviews and neurons. Never a message or a sender. */
2050
- async flushChat() {
2051
- const s = this.room?.stats;
2052
- if (!s || !this.env.DB) return;
2053
- const game = this.game ?? '';
2054
- const rows = [];
2055
- for (const [k, metric] of [['chatLines', 'chat-lines'], ['chatReacts', 'chat-reacts']]) {
2056
- const d = (s[k] ?? 0) - (this.chatSent[k] ?? 0);
2057
- if (d > 0) { rows.push(counter(this.env, { metric, subject: game, n: d })); this.chatSent[k] = s[k]; }
2058
- }
2059
- for (const [why, n] of Object.entries(s.chatHeld ?? {})) {
2060
- const d = n - (this.chatSent.held[why] ?? 0);
2061
- if (d > 0) { rows.push(counter(this.env, { metric: 'chat-held', subject: game, source: why, n: d })); this.chatSent.held[why] = n; }
2062
- }
2063
- const p = this.chatPend;
2064
- for (const k of ['ok', 'held', 'error']) if (p[k]) rows.push(counter(this.env, { metric: 'chat-reviews', subject: game, source: k, n: p[k] }));
2065
- const neurons = Math.floor(p.neurons);
2066
- if (neurons) rows.push(counter(this.env, { metric: 'chat-neurons', subject: game, n: neurons }));
2067
- this.chatPend = { neurons: p.neurons - neurons, ok: 0, held: 0, error: 0 };
2068
- const list = rows.filter(Boolean);
2069
- if (list.length) await this.env.DB.batch(list).catch(() => {});
2070
- }
2071
-
2072
- /** The game's agents.json from the build (the only words an AI in this room may say), once. */
2073
- async readVocab(game) {
2074
- if (!this.env.ASSETS || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(game ?? ''))) return false;
2075
- const res = await this.env.ASSETS.fetch(new Request(`https://assets.local/games/${game}/agents.json`));
2076
- if (!res.ok) return false;
2077
- const ok = this.room.setVocabulary(await res.json());
2078
- if (ok) this.syncHouse();
2079
- return ok;
2080
- }
2081
-
2082
- /**
2083
- * The room's house guides (worker/agents.mjs): seated while the server's AI may talk, the game has a vocabulary and a
2084
- * person is playing; stood up otherwise. Called on the 1 s beat and whenever the policy changes. No brain runs here:
2085
- * decisions run on the alarm.
2086
- */
2087
- syncHouse() {
2088
- const room = this.room;
2089
- if (!room) return;
2090
- if (!this.house) {
2091
- if (!talks(room.policy) || !room.vocab || !(room.policy.guides > 0)) return;
2092
- this.house = new HouseAgents({
2093
- room, env: this.env,
2094
- // The room's own alarm (a paused server-hosted room ends on it) is never pushed back by a guide's.
2095
- setAlarm: (at) => { this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(() => {}); },
2096
- budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
2097
- log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
2098
- });
2099
- this.ctx.waitUntil(this.readBrainDay().catch(() => {}));
2100
- }
2101
- this.house.sync();
2102
- }
2103
-
2104
- /** What the studio allows its AI brains a day (meta `brain_budget`) and has used today (stats_daily), all games. */
2105
- async readBrainDay() {
2106
- const db = this.env.DB;
2107
- if (!db) return;
2108
- const day = today();
2109
- try {
2110
- const b = JSON.parse((await db.prepare("SELECT value FROM meta WHERE key = 'brain_budget'").first())?.value ?? 'null');
2111
- if (b && Number.isFinite(Number(b.neurons))) this.brainCap.neurons = Math.max(0, Number(b.neurons));
2112
- if (b && Number.isFinite(Number(b.usd))) this.brainCap.micros = Math.max(0, Number(b.usd)) * 1e6;
2113
- } catch { /* the defaults */ }
2114
- try {
2115
- const { results } = await db.prepare("SELECT metric, SUM(n) AS n FROM stats_daily WHERE day = ?1 AND metric IN ('brain-neurons', 'brain-microdollars') GROUP BY metric").bind(day).all();
2116
- const used = { neurons: 0, micros: 0 };
2117
- for (const r of results ?? []) used[r.metric === 'brain-neurons' ? 'neurons' : 'micros'] = Number(r.n) || 0;
2118
- // What this room spent and has not written yet is added on top.
2119
- this.brainUsed = { neurons: used.neurons + this.brainPending.neurons, micros: used.micros + this.brainPending.micros };
2120
- } catch { /* before migration 0002: nothing used */ }
2121
- this.brainDay = day;
2122
- this.brainReadAt = Date.now();
2123
- }
2124
-
2125
- brainSpend(c) {
2126
- if (this.brainDay && this.brainDay !== today()) { this.brainUsed = { neurons: 0, micros: 0 }; this.brainDay = today(); }
2127
- this.brainUsed.neurons += c.neurons ?? 0;
2128
- this.brainUsed.micros += c.micros ?? 0;
2129
- this.brainPending.neurons += c.neurons ?? 0;
2130
- this.brainPending.micros += c.micros ?? 0;
2131
- const k = c.provider ?? 'script';
2132
- this.brainPending.calls[k] = (this.brainPending.calls[k] ?? 0) + 1;
2133
- }
2134
-
2135
- /**
2136
- * The day's brain counters into D1 (stats_daily: brain-calls by provider, brain-neurons, brain-microdollars), and the
2137
- * day's budget and usage read again (an owner's new budget, other rooms' use): at most once a minute, on the alarm.
2138
- */
2139
- async flushBrain(force = false) {
2140
- const p = this.brainPending;
2141
- if (!this.env.DB || (!force && Date.now() - this.brainReadAt < 60_000)) return;
2142
- if (!Object.keys(p.calls).length && p.neurons < 1 && p.micros < 1) { await this.readBrainDay(); return; }
2143
- const neurons = Math.floor(p.neurons);
2144
- const micros = Math.floor(p.micros);
2145
- const rows = [
2146
- ...Object.entries(p.calls).map(([source, n]) => counter(this.env, { metric: 'brain-calls', subject: this.game ?? '', source, n })),
2147
- neurons ? counter(this.env, { metric: 'brain-neurons', subject: this.game ?? '', n: neurons }) : null,
2148
- micros ? counter(this.env, { metric: 'brain-microdollars', subject: this.game ?? '', n: micros }) : null,
2149
- ].filter(Boolean);
2150
- this.brainPending = { calls: {}, neurons: p.neurons - neurons, micros: p.micros - micros };
2151
- if (rows.length) await this.env.DB.batch(rows).catch(() => {});
2152
- await this.readBrainDay();
2153
- }
2154
-
2155
- /** The alarm: the house guides' brains decide (worker/agents.mjs), then the day's usage is written. */
2156
- async alarm() {
2157
- await this.flushRoundRecords();
2158
- await this.flushFunctionEvents();
2159
- // A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
2160
- try { if (this.house) await this.house.onAlarm(); } catch (error) { try { console.log(JSON.stringify({ ev: 'brain-alarm-failed', room: this.code, error: String(error?.stack ?? error).slice(0, 400) })); } catch { /* no console */ } }
2161
- try { await this.flushBrain(); } catch { /* counted next time */ }
2162
- try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
2163
- }
2164
-
2165
- async flushCharacters() {
2166
- if (this.characterFlush) { await this.characterFlush; return; }
2167
- const saved = this.roomSave;
2168
- if (!saved) return;
2169
- const host = typeof saved.host === 'string' ? JSON.parse(saved.host) : saved.host;
2170
- const returns = (host.characters ?? []).filter(c => c.returning);
2171
- if (!returns.length) return;
2172
- this.characterFlush = (async () => {
2173
- for (const item of returns) {
2174
- await characterRequest(this.env, item.account, { op: 'return', game: this.game, holder: this.code, lease: item.lease, character: item.character });
2175
- this.hostRt?.characterReturned(item.account, item.lease);
2176
- if (this.characterGrants.get(item.account)?.lease === item.lease) this.characterGrants.delete(item.account);
2177
- }
2178
- })();
2179
- try { await this.characterFlush; } finally { this.characterFlush = null; }
2180
- }
2181
-
2182
- // Network awaits may interleave another join or recall. Serialize each account
2183
- // within this room so a delayed acquisition cannot revive a returned lease.
2184
- async withCharacter(account, operation) {
2185
- this.characterOperations ??= new Map();
2186
- const previous = this.characterOperations.get(account) ?? Promise.resolve();
2187
- const current = previous.catch(() => {}).then(operation);
2188
- this.characterOperations.set(account, current);
2189
- try { return await current; }
2190
- finally { if (this.characterOperations.get(account) === current) this.characterOperations.delete(account); }
2191
- }
2192
-
2193
- acquireCharacter(account) { return this.withCharacter(account, () => this.acquireCharacterOnce(account)); }
2194
-
2195
- async acquireCharacterOnce(account) {
2196
- for (const c of [...this.room.clients.values()]) if (c.player === account && c.helloed) this.room.kick(c, 'character-moved', 4001);
2197
- await this.flushCharacters();
2198
- const body = { op: 'acquire', game: this.game, holder: this.code, schema: hostedBuild(this.game).stateHash };
2199
- let grant = await characterRequest(this.env, account, body);
2200
- if (grant.error === 'character-held') {
2201
- const old = this.env.TABLE.get(this.env.TABLE.idFromName(`${this.game}/${grant.holder}`));
2202
- const url = `https://room/__recall?game=${encodeURIComponent(this.game)}&room=${encodeURIComponent(grant.holder)}`;
2203
- const response = await old.fetch(url, { method: 'POST', body: JSON.stringify({ account }) });
2204
- if (!response.ok) throw new Error('Finishing your last session; its room is unavailable');
2205
- grant = await characterRequest(this.env, account, body);
2206
- }
2207
- if (!grant.ok) throw new Error(grant.error);
2208
- const held = { account, lease: grant.lease, seat: -1, character: grant.character };
2209
- this.characterGrants.set(account, held);
2210
- this.hostRt.reserveCharacter(held);
2211
- this.saveRoom(this.hostRt.save());
2212
- }
2213
-
2214
- async fetch(request) {
2215
- if (new URL(request.url).pathname === '/__character') {
2216
- const body = await request.json();
2217
- this.characters ??= characterStore(this.ctx.storage);
2218
- if (body.op === 'acquire') return json(this.characters.acquire(body.game, body.holder, body.schema));
2219
- if (body.op === 'return') return json(this.characters.return(body.game, body.holder, body.lease, body.character));
2220
- return json({ ok: false }, 400);
2221
- }
2222
- const path = new URL(request.url).pathname;
2223
- if (path === '/__gate') {
2224
- // The door asks which Gate takes the next player (worker/gate.mjs, nextGate): the lowest with room, counting
2225
- // players sent to a Gate in the last ten seconds who have not reached it yet.
2226
- const gates = Number(new URL(request.url).searchParams.get('gates')) || 1, now = Date.now(), promised = new Map();
2227
- for (const [index, due] of this.gatePromised) { const live = due.filter((at) => at > now); if (live.length) { this.gatePromised.set(index, live); promised.set(index, live.length); } else this.gatePromised.delete(index); }
2228
- const gate = nextGate(gates, this.gateHeld, promised, gatePlayers(this.env));
2229
- if (!this.gatePromised.has(gate)) this.gatePromised.set(gate, []);
2230
- this.gatePromised.get(gate).push(now + 10_000);
2231
- return json({ gate });
2232
- }
2233
- if (path === '/__multiplex') {
2234
- this.relayed = true;
2235
- const index = Number(new URL(request.url).searchParams.get('gate'));
2236
- // One encoding of each world for every Gate link (worker/world-wire.mjs).
2237
- return acceptMultiplex(request, (req, socket) => this.connect(req, socket), { source: (this.worldSource ??= worldSource()), onCount: (n) => {
2238
- // A player arrived: a promise to that Gate is kept. The Gate's count is what the next join is placed by.
2239
- if (n > (this.gateHeld.get(index) ?? 0)) this.gatePromised.get(index)?.shift();
2240
- if (n > 0) this.gateHeld.set(index, n); else this.gateHeld.delete(index);
2241
- } });
2242
- }
2243
- return this.connect(request);
2244
- }
2245
-
2246
- async connect(request, transport = null) {
2247
- const url = new URL(request.url);
2248
- const game = url.searchParams.get('game');
2249
- const code = url.searchParams.get('room');
2250
- const max = seatCount(url.searchParams.get('max'));
2251
- if (url.pathname === '/__restart') {
2252
- if (this.env.HOMIE_PREVIEW !== '1' || request.method !== 'POST') return new Response('not found', { status: 404 });
2253
- // No last-chance save: this measures exactly what a real abrupt restart can lose.
2254
- setTimeout(() => this.ctx.abort('Preview forced restart'), 25);
2255
- return json({ ok: true });
2256
- }
2257
- const room = this.roomFor(game, code, max);
2258
- if (url.pathname === '/__recall') {
2259
- const { account } = await request.json();
2260
- return this.withCharacter(account, async () => {
2261
- const preparing = prepareHost(game);
2262
- if (preparing) await preparing;
2263
- this.ensureHost(room, true);
2264
- if (!this.hostRt || this.persistentFailed || room.hostFailed) return json({ ok: false }, 503);
2265
- await this.flushCharacters();
2266
- if (!this.hostRt.characters.has(account)) {
2267
- const grant = await characterRequest(this.env, account, { op: 'acquire', game, holder: code, schema: hostedBuild(game).stateHash });
2268
- if (!grant.ok) return json({ ok: true });
2269
- this.hostRt.reserveCharacter({ account, lease: grant.lease, seat: -1, character: grant.character });
2270
- }
2271
- this.hostRt.releaseCharacter(account);
2272
- for (const c of [...room.clients.values()]) if (c.player === account) room.kick(c, 'character-moved', 4001);
2273
- await this.flushCharacters();
2274
- return json({ ok: true });
2275
- });
2276
- }
2277
- // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
2278
- // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
2279
- if (url.pathname === '/__facts') return json({ ...room.officeFacts(), gates: [...(this.gateMetrics?.values() ?? [])], durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
2280
- // A chat line as the room keeps it (section 19), for a report: the Worker files the room's own copy, never a reporter's.
2281
- if (url.pathname === '/__chat') {
2282
- const id = url.searchParams.get('id');
2283
- // A line older than the window is in the room's kept history, when it keeps one (0.29.0).
2284
- const r = room.chatLog.find((x) => x.id === id) ?? (room.chatRules().history > 0 ? await keptLine(this.env, this.game, this.code, id) : null);
2285
- return json({ ok: Boolean(r), line: r ? { id: r.id, kind: r.kind, text: r.text ?? null, glyph: r.glyph ?? null, name: r.name, seat: r.seat, at: r.at, by: r.by, player: r.from?.player ?? null, owner: Boolean(r.owner), mod: Boolean(r.mod) } : null });
2286
- }
2287
- if (url.pathname === '/__mcp-event') {
2288
- const ctl = await request.json().catch(() => null);
2289
- const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
2290
- if (why || ctl.op !== 'mcp-event' || !/^external:[a-z][a-z0-9_-]{0,40}$/.test(ctl.args?.event ?? '')) return json({ ok: false, error: 'refused' }, 403);
2291
- if (!room.live().length) return json({ ok: false, error: 'room empty; use records for lasting changes' }, 409);
2292
- const event = { t: 'ev', from: null, k: ctl.args.event, d: ctl.args.data, external: true };
2293
- if (room.server) room.toServer(event);
2294
- for (const client of room.live()) room.send(client, event);
2295
- return json({ ok: true });
2296
- }
2297
- if (url.pathname === '/__mcp') {
2298
- const ctl = await request.json().catch(() => null);
2299
- const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
2300
- if (why || ctl.op !== 'mcp-seat') return json({ ok: false, error: 'refused' }, 403);
2301
- const result = await (await import('./mcp-room.mjs')).roomSeat(this, room, ctl);
2302
- return json(result, result.ok ? 200 : 403);
2303
- }
2304
- if (url.pathname === '/__office') {
2305
- const ctl = await request.json().catch(() => null);
2306
- const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
2307
- if (why) return json({ ok: false, error: 'refused', why }, 403);
2308
- // A Lounge card waits for the studio's review (0.29.0); every other control answers at once.
2309
- const res = await room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
2310
- // AI talk turned off (a policy) or a guide kicked: the house guides follow at once.
2311
- if (this.house) this.house.sync();
2312
- await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
2313
- if (ctl.op === 'close' && res.ok) this.tellLobby({ closed: ctl.args?.reopen ? 0 : res.until });
2314
- this.report();
2315
- return json(res);
2316
- }
2317
- room.rules = url.searchParams.get('rules') === '1';
2318
- room.perIp = perAddress(max, room.rules);
2319
- // Any tick rate a game may declare (1 to 60): the relay's allowances are the page's, from the same table.
2320
- const hz = Number(url.searchParams.get('hz'));
2321
- room.tickHz = Number.isInteger(hz) && hz >= 1 && hz <= 60 ? hz : 20;
2322
- // The owner's room size: a room already open takes it from the next visitor on.
2323
- if (room.seatCap !== max || room.rules && room.maxPlayers !== max) room.setSeats(max, perAddress(max, room.rules));
2324
- // Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
2325
- const said = url.searchParams.has('cur');
2326
- if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
2327
- // Restore before applying the Worker's current policy: a save must not replace a newer policy.
2328
- const preparing = prepareHost(game);
2329
- if (preparing) await preparing;
2330
- this.ensureHost(room, url.searchParams.get('host') === 'server');
2331
- // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
2332
- const pol = decodeFacts(url.searchParams.get('pol'));
2333
- // The room's chat rules ride beside it (they may be longer than a policy): section 19.
2334
- const chatRules = decodeFacts(url.searchParams.get('chat'), 16384);
2335
- if (pol && chatRules && typeof chatRules === 'object') pol.chat = chatRules;
2336
- if (pol) room.setPolicy(pol);
2337
- if (this.house) this.house.sync();
2338
- const agent = decodeFacts(url.searchParams.get('ag'));
2339
- // Kept chat (0.29.0): the room's history is back in its window before this page hears it.
2340
- await this.hydrateHistory(room);
2341
- const account = holders(url.searchParams.get('via')).find(p => p.startsWith('p-'))?.slice(2) ?? null;
2342
- if (hostedBuild(game)?.settings.characters && url.pathname !== '/__watch') {
2343
- if (!account || url.searchParams.get('acct') !== '1') return new Response('Sign in to keep your character', { status: 401 });
2344
- try { await this.acquireCharacter(account); }
2345
- catch (error) { return new Response(String(error.message), { status: 409 }); }
2346
- }
2347
- const [client, server] = transport ? [null, transport] : Object.values(new WebSocketPair());
2348
- server.accept();
2349
- const via = url.searchParams.get('via');
2350
- const conn = {
2351
- ip: request.headers.get('cf-connecting-ip'),
2352
- // The browser's room key (what a kick holds out), and who the play page's ticket named (o, i-<invite>, p-<player>).
2353
- browser: url.searchParams.get('b') || null,
2354
- via: via || null,
2355
- player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
2356
- // A socket opened through the game's watch door, and what the game lets watchers see (section 16).
2357
- watch: url.searchParams.get('w') === '1',
2358
- // An AI with a pass the Worker verified (section 17): its pass, role, hands and name. Only the Worker sets it.
2359
- ...(agent && typeof agent === 'object' && url.pathname === '/__net' ? { agent } : {}),
2360
- // A badge the player's account owns (the shop): the Worker looked it up; a hello cannot set one.
2361
- ...(url.pathname === '/__net' && /^[\p{L}\p{N} .'&+-]{1,16}$/u.test(url.searchParams.get('bd') ?? '') ? { badge: url.searchParams.get('bd') } : {}),
2362
- watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
2363
- // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
2364
- qa: isQa(request),
2365
- // The build this socket's page was served with (section 23), from the socket's own address: only when the
2366
- // Worker speaks of builds at all (`cur`), so a relay behind an older Worker reads the hello's own.
2367
- ...(said && url.pathname === '/__net' ? { ver: url.searchParams.get('gv') || null } : {}),
2368
- // Room chat (section 19), the Worker's word: a signed-in account (a passkey), a member of this server, a page of
2369
- // another site (homie.rocks's room page) watching from elsewhere.
2370
- acct: url.searchParams.get('acct') === '1',
2371
- member: url.searchParams.get('mem') === '1',
2372
- hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
2373
- // The Lounge (0.29.0): the signed-in account's name and a moderator's mark, the Worker's word on a watch socket.
2374
- ...(url.pathname === '/__watch' && url.searchParams.get('nm') ? { name: String(url.searchParams.get('nm')).slice(0, 40) } : {}),
2375
- ...(url.pathname === '/__watch' && url.searchParams.get('mod') === '1' ? { mod: true } : {}),
2376
- send: (text) => { try { server.send(text); } catch { /* closed */ } },
2377
- close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
2378
- buffered: () => server.buffered?.() ?? 0,
2379
- ...(server.snapshotGroup ? { snapshotGroup: server.snapshotGroup, subscribe: seat => server.subscribe(seat) } : {}),
2380
- ...(server.snapshot ? { snapshot: (...args) => server.snapshot(...args), transportFacts: server.transportFacts } : {}),
2381
- };
2382
- if (url.pathname === '/__watch') {
2383
- // Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
2384
- // (room chat, 0.23.0: another site's page can open one too).
2385
- const same = conn.ip ? [...room.watchers].filter((x) => x.ip === conn.ip).length : 0;
2386
- if (room.watchers.size >= 256 || same >= 24) { try { server.close(1013, 'too many watching'); } catch { /* gone */ } return transport ? null : new Response(null, { status: 101, webSocket: client }); }
2387
- const w = room.watch(conn);
2388
- // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
2389
- server.addEventListener('message', (e) => {
2390
- if (typeof e.data !== 'string') return;
2391
- try { w.onMessage(e.data); } catch (error) { this.say({ ev: 'failed', op: 'watch-message', error: errorLine(error) }); }
2392
- });
2393
- server.addEventListener('close', () => w.onClose());
2394
- server.addEventListener('error', (e) => { this.socketError('watch', e); w.onClose(); });
2395
- } else {
2396
- const h = room.attach(conn);
2397
- // A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
2398
- // seats, so it is not counted twice). One counter write per room.
2399
- const fresh = room.seats.size === 0;
2400
- server.addEventListener('message', (e) => {
2401
- this.relayFrame = Boolean(server.snapshot);
2402
- try {
2403
- let text = typeof e.data === 'string' ? e.data : '';
2404
- if (hostedBuild(game)?.settings.characters && account) {
2405
- try { const message = JSON.parse(text); if (message.t === 'hello') { delete message.token; if (this.accountTokens[account]) message.token = this.accountTokens[account]; text = JSON.stringify(message); } } catch { /* normal parser diagnoses it */ }
2406
- }
2407
- if (Number.isFinite(e.receivedAt)) this.inputTransit.push(Math.max(0, Date.now() - e.receivedAt));
2408
- h.onMessage(text, this.relayFrame ? e.receivedAt : undefined);
2409
- if (hostedBuild(game)?.settings.characters && account) { const joined = room.clients.get(h.id); if (joined?.token) this.accountTokens[account] = joined.token; }
2410
- } finally { this.relayFrame = false; }
2411
- // A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
2412
- if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.scheduleReport();
2413
- if (fresh && !this.openCounted && room.seats.size > 0) {
2414
- this.openCounted = true;
2415
- if (conn.qa) return;
2416
- this.ctx.waitUntil(count(this.env, null, { metric: 'room', subject: this.game ?? '', source: /^pub-\d+$/.test(this.code ?? '') ? 'public' : 'named' }));
2417
- }
2418
- });
2419
- // An AI's time in a seat, counted once as it leaves (stats `agent-minutes`; never for house QA).
2420
- const left = (via) => {
2421
- const c = room.clients.get(h.id);
2422
- if (c?.agent && c.helloed && c.seat !== null && !conn.qa) {
2423
- const minutes = Math.round((Date.now() - c.joinedAt) / 60_000);
2424
- if (minutes > 0) this.ctx.waitUntil(count(this.env, null, { metric: 'agent-minutes', subject: this.game ?? '', source: c.agent.role, n: minutes }).catch(() => {}));
2425
- }
2426
- // The room says the departure itself (its `leave` line: who, which seat, host or not, how many are left).
2427
- h.onClose(via);
2428
- try { this.scheduleReport(); } catch (error) { this.say({ ev: 'failed', op: 'report', error: errorLine(error) }); }
2429
- };
2430
- server.addEventListener('close', () => left('close'));
2431
- // A lost network arrives here, not as a close: said as what it is, with the client it was, then handled as the
2432
- // departure it is. (An error with a listener is never the runtime's "Uncaught".)
2433
- server.addEventListener('error', (e) => {
2434
- const c = room.clients.get(h.id);
2435
- this.socketError('net', e, { id: h.id, seat: c?.seat ?? null, role: c && c.helloed ? room.roleOf(c) : null });
2436
- left('error');
2437
- });
2438
- }
2439
- this.start();
2440
- return transport ? null : new Response(null, { status: 101, webSocket: client });
2441
- }
2442
-
2443
- start() {
2444
- if (this.timer) return;
2445
- let n = 0;
2446
- this.timer = setInterval(() => {
2447
- const room = this.room;
2448
- if (!room) return;
2449
- // A beat that throws is one failed beat, said with its room; the next one still runs.
2450
- try { room.tick(); } catch (error) { this.say({ ev: 'tick-failed', clients: room.clients.size, error: errorLine(error) }); }
2451
- // A room that just opened reads its game's launch state once: a change made in the moment it opened (before the
2452
- // Lobby knew of it) still reaches it, after the current round as always.
2453
- if (room.openedAt && this.launchReadFor !== room.openedAt) { this.launchReadFor = room.openedAt; this.ctx.waitUntil(this.rereadLaunch().catch(() => {})); }
2454
- // A launch change the room applied by itself (after its round): its stored state says so too.
2455
- if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
2456
- n += 1;
2457
- if (n % 4 === 0) { this.publishDiagnostics(); room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
2458
- // Room chat's counts, once a minute (section 19).
2459
- if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
2460
- // A server-hosted room in play keeps its alarm ahead of it (re-armed every five minutes of play).
2461
- if (n % 1200 === 0 && this.hostRt?.running) this.armRoom(Date.now() + ROOM_ALARM_MS);
2462
- if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
2463
- if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); this.ctx.waitUntil(this.flushChat().catch(() => {})); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => {})); this.house = null; } }
2464
- }, 250);
2465
- }
2466
-
2467
- /**
2468
- * The game's launch state, read once when the room opens (one D1 read): a private or invite-only game whose room
2469
- * holds anyone the state leaves out re-gates after the current round. Never in a Preview (no launch states there).
2470
- */
2471
- async rereadLaunch() {
2472
- const room = this.room;
2473
- if (!room || !this.game || !this.env.DB || this.env.HOMIE_PREVIEW === '1' || room.regate) return null;
2474
- let launch = null;
2475
- try { launch = (await this.env.DB.prepare('SELECT launch FROM office_games WHERE game = ?1').bind(this.game).first())?.launch ?? null; } catch { return null; }
2476
- const args = regateArgs(launch);
2477
- if (!args.allow) return null;
2478
- const res = room.control('regate', args);
2479
- if (res.ok && res.leaving) await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
2480
- return res;
2481
- }
2482
-
2483
- /** Tell the Lobby this room closed (until when; 0 when it opened again), so it sends nobody here meanwhile. */
2484
- tellLobby(extra) {
2485
- if(extra.closed || extra.ended) this.queueFunctionEvent('room.closed',{game:this.game,room:this.code,...extra});
2486
- if (!this.game || !this.code) return;
2487
- const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
2488
- this.lastReport = -1;
2489
- this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players: 0, people: 0, ...extra }) }).catch(() => {}));
2490
- }
2491
-
2492
- /**
2493
- * A big room's diagnostics leave it once a second for the object that answers readers (worker/gate.mjs,
2494
- * `diagnosticsFetch`): nothing per player, one post outstanding at most, never awaited by a tick or an admission.
2495
- * A room that every player reaches directly (one Gate's worth of seats) answers for itself and posts nothing.
2496
- */
2497
- publishDiagnostics() {
2498
- const transit = this.inputTransit.sort((a, b) => a - b); this.inputTransit = [];
2499
- if (!this.env.GATE || !this.relayed || this.diagnosticsPending || !this.room) return;
2500
- const facts = this.room.diagnosticFacts(), now = Date.now(), last = this.diagnosticsLast;
2501
- const at = (p) => transit.length ? transit[Math.min(transit.length - 1, Math.floor(transit.length * p))] : null;
2502
- const tick = facts.ticks?.tick ?? null;
2503
- this.diagnosticsLast = { now, tick };
2504
- this.diagnosticsPending = true;
2505
- const body = JSON.stringify({
2506
- ...facts,
2507
- // Ticks the rules ran per second of this object's clock since the last post.
2508
- tickRate: last && tick !== null && last.tick !== null && now > last.now ? Math.round((tick - last.tick) / (now - last.now) * 100000) / 100 : null,
2509
- // A Gate's stamp on a player's message to this object handling it, ms.
2510
- inputTransitMs: { n: transit.length, p50: at(0.5), p95: at(0.95), max: transit.at(-1) ?? null },
2511
- durability: this.storageHealth ?? { ok: true },
2512
- // Checkpoints written without holding the room's frames for them (worker/room-store.mjs), so far.
2513
- save: { unconfirmed: this.durable?.unconfirmedWrites ?? 0 },
2514
- });
2515
- const work = diagnosticsOf(this.env, this.game, this.code).fetch('https://diagnostics/__diagnostics', { method: 'POST', body })
2516
- .then((r) => r.body?.cancel?.())
2517
- .catch((error) => { this.say({ ev: 'diagnostics-publish-failed', error: errorLine(error) }); })
2518
- .finally(() => { this.diagnosticsPending = false; });
2519
- this.ctx.waitUntil(work);
2520
- }
2521
-
2522
- scheduleReport() {
2523
- if (this.reportQueued) return;
2524
- this.reportQueued = true;
2525
- queueMicrotask(() => { this.reportQueued = false; this.report(); });
2526
- }
2527
-
2528
- /** Tell the Lobby how many players this room has, when it changes. */
2529
- report() {
2530
- const room = this.room;
2531
- if (!room || !this.game || this.endedRoom) return;
2532
- const counts = room.facts().counts;
2533
- // Players are people (an agent never counts as one, DESIGN D9); agents and AI bodies are reported apart.
2534
- const players = counts.players;
2535
- // AIs with a pass (a house guide makes way for one, so the Lobby counts only these when it seats an AI).
2536
- let agents = 0;
2537
- for (const c of room.clients.values()) if (c.helloed && c.agent && c.seat !== null && !c.conn?.loopback) agents += 1;
2538
- const ai = counts.ai ?? 0;
2539
- // People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
2540
- let people = 0;
2541
- for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.agent && !c.conn?.qa) people += 1;
2542
- // On a change, and once a minute while anybody is here (so the Lobby's "playing now" forgets a room that died).
2543
- if (players === this.lastReport && people === this.lastPeople && agents === this.lastAgents && ai === this.lastAi && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
2544
- this.lastReport = players;
2545
- this.lastPeople = people;
2546
- this.lastAgents = agents;
2547
- this.lastAi = ai;
2548
- this.lastReportAt = Date.now();
2549
- const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
2550
- // The build this room runs (section 23): the Lobby sends a visitor only to a room of the live build.
2551
- const ver = room.gameVer === undefined ? undefined : room.gameVer ?? '';
2552
- this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people, agents, ai, host: room.server ? 'server' : 'browser', ...(ver !== undefined ? { ver } : {}) }) }).catch(() => {}));
2553
- }
2554
-
2555
- /** A round the host called over becomes one D1 row (once per round number). */
2556
- recordRound() {
2557
- const r = this.room?.lastRound;
2558
- if (!r || r.phase !== 'over' || !Number.isFinite(r.n) || r.n <= this.recorded || !this.env.DB) return;
2559
- this.recorded = r.n;
2560
- this.ctx.storage.put('recorded', r.n).catch(() => {});
2561
- const results = Array.isArray(r.results) ? r.results : [];
2562
- // An AI's row is never a person's (the relay labelled it `agent`): rounds count agents as AI.
2563
- const humans = results.filter((row) => row && !row.bot && !row.agent).length;
2564
- // A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
2565
- const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null && !c.agent).every((c) => c.conn?.qa);
2566
- const db = this.env.DB;
2567
- const insert = db.prepare('INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
2568
- .bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
2569
- // The round and the people in it, on the studio's daily counters (worker/stats.mjs). One batch, per round.
2570
- const counters = qaOnly ? [] : [counter(this.env, { metric: 'round', subject: this.game ?? '', source: humans ? 'people' : 'bots' }), humans ? counter(this.env, { metric: 'humans', subject: this.game ?? '', n: humans }) : null].filter(Boolean);
2571
- this.ctx.waitUntil(db.batch([insert, ...counters]).catch(() => insert.run().catch(() => {})));
2572
- }
2573
- }
2574
-
2575
- /**
2576
- * LOBBY — one per game: which room a new visitor joins. Strangers meet: the fullest room that still has a seat
2577
- * wins, counting visitors it just sent there who have not connected yet, so two people pressing Play together land
2578
- * in the same room.
2579
- *
2580
- * SERVERS (section 17): one pool per server (`/join?server=`), never matched across: `public` is the game's pub-N
2581
- * rooms, a server's are s-<id>-<n>. A server whose `rooms_max` rooms are all full sends the visitor to its fullest
2582
- * room, where it waits as a spectator and is seated when a seat frees (`full: true`). An AI (`agent=1`) is only ever
2583
- * sent to a room with people in it; it never opens one. Players are people: agents are reported apart.
2584
- */
2585
- /** How many builds of a game besides the live one may have Lobby rooms on one server at once (standalone copies name their own). */
2586
- export const LOBBY_APP_VERSIONS = 8;
2587
- /** How many rooms a game's Lobby keeps in all. Its list is one stored value: 512 rooms is about a third of what one may hold. */
2588
- export const LOBBY_ROOMS_MAX = 512;
2589
-
2590
- export class Lobby {
2591
- constructor(ctx, env) {
2592
- this.ctx = ctx;
2593
- this.env = env;
2594
- this.rooms = new Map();
2595
- /** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
2596
- this.next = { public: 1 };
2597
- /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
2598
- this.live = new Map();
2599
- this.peak = { day: null, players: 0, room: 0 };
2600
- /** Every room with somebody seated, public or named, for the owner's office; and the rooms the owner closed (until when). */
2601
- this.seen = new Map();
2602
- this.closed = new Map();
2603
- ctx.blockConcurrencyWhile(async () => {
2604
- const saved = await ctx.storage.get('lobby');
2605
- if (saved) {
2606
- this.current = saved.current ?? null;
2607
- this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
2608
- for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
2609
- for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
2610
- }
2611
- });
2612
- }
2613
-
2614
- /**
2615
- * The day's peak for this game, all rooms together and in one room: a counter write only when a new high is
2616
- * reached (in memory per day; after an eviction the stored max still wins), so at most a few a day.
2617
- */
2618
- notePeak(room, players, game) {
2619
- const now = Date.now();
2620
- if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
2621
- for (const [name, r] of this.live) if (now - r.at > 180_000) this.live.delete(name); // a room that stopped reporting
2622
- const day = today();
2623
- if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
2624
- let all = 0;
2625
- for (const r of this.live.values()) all += r.players;
2626
- const writes = [];
2627
- if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
2628
- if (players > this.peak.room) { this.peak.room = players; writes.push(counter(this.env, { metric: 'peak-room', subject: game ?? this.game ?? '', n: players, day })); }
2629
- if (game) this.game = game;
2630
- const list = writes.filter(Boolean);
2631
- if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
2632
- }
2633
-
2634
- save() { this.ctx.storage.put('lobby', { current: this.current ?? null, next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver, host }) => ({ name, players, agents, ai, server, at, ...(host ? { host } : {}), ...(ver ? { ver } : {}) })), closed: [...this.closed] }).catch(() => {}); }
2635
-
2636
- prune(now) {
2637
- for (const [name, r] of this.rooms) {
2638
- r.pending = r.pending.filter((t) => now - t < 20_000);
2639
- r.agentPending = (r.agentPending ?? []).filter((t) => now - t < 20_000);
2640
- if (r.players === 0 && !r.pending.length && now - r.at > 120_000) this.rooms.delete(name);
2641
- }
2642
- }
2643
-
2644
- /** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
2645
- async fetch(request) {
2646
- if(new URL(request.url).pathname==='/__app_records') {
2647
- const {AppRecordStreams}=await import('./app-streams.mjs');
2648
- this.appStreams??=new AppRecordStreams(this.env,this.ctx);
2649
- const input=await request.json();
2650
- return input.op==='open'?this.appStreams.open(input):this.appStreams.wake(input.meta);
2651
- }
2652
- if(new URL(request.url).pathname==='/__studio_events') {
2653
- const {EventStreams}=await import('./event-resources.mjs');
2654
- this.eventStreams??=new EventStreams(this.env,this.ctx);
2655
- return this.eventStreams.fetch(request);
2656
- }
2657
- try { return await this.answer(request); } catch (error) {
2658
- let path = '';
2659
- try { path = new URL(request.url).pathname; } catch { /* not an address */ }
2660
- try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-failed', game: this.game ?? null, path, error: errorLine(error) })); } catch { /* no console */ }
2661
- return json({ ok: false, error: 'lobby' }, 500);
2662
- }
2663
- }
2664
-
2665
- async answer(request) {
2666
- const url = new URL(request.url);
2667
- const now = Date.now();
2668
- this.prune(now);
2669
- // REVISIONS (section 23): rooms are matched within one build of the game. A room that never said its build is
2670
- // the build of a game that names none ('').
2671
- const ver = versionOf(url.searchParams.get('ver')) ?? '';
2672
- if (url.pathname === '/join') {
2673
- // A server build can run only its current rules. Persisted browser rooms
2674
- // (including pre-kind records) and old server revisions must not consume
2675
- // the pool or become the fallback of a server with roomsMax=1.
2676
- const host = url.searchParams.get('host');
2677
- if ((host === 'server' || host === 'browser') && (this.current?.host !== host || this.current?.ver !== ver)) {
2678
- this.current = { host, ver };
2679
- for (const [name, r] of this.rooms) if (
2680
- host === 'server' ? (r.host ?? 'browser') !== host || (r.ver ?? '') !== ver : r.host === 'server'
2681
- ) { this.rooms.delete(name); this.seen.delete(name); this.live.delete(name); }
2682
- this.save();
2683
- }
2684
- const max = seatCount(url.searchParams.get('max'));
2685
- const sid = url.searchParams.get('server') ?? 'public';
2686
- const server = sid === 'public' || SERVER_ID.test(sid) ? sid : 'public';
2687
- const roomsMax = Math.max(1, Math.min(16, Math.floor(Number(url.searchParams.get('rooms'))) || 16));
2688
- // A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
2689
- const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
2690
- for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
2691
- // Every room of the server counts toward its `rooms_max` and its numbering; only this build's rooms are matched.
2692
- const everyRoom = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server);
2693
- const ofServer = everyRoom.filter((r) => !not.has(r.name) && !this.closed.has(r.name));
2694
- const pool = ofServer.filter((r) => (r.ver ?? '') === ver);
2695
- // A standalone copy names its own build (the Worker says `app=1`), and anybody can claim to be one: only a few
2696
- // builds besides the live one ever have rooms here, so a caller that invents a build a request makes no pool.
2697
- const app = url.searchParams.get('app') === '1';
2698
- if (app && !everyRoom.some((r) => (r.ver ?? '') === ver) && new Set(everyRoom.map((r) => r.ver ?? '')).size > LOBBY_APP_VERSIONS) {
2699
- return json({ ok: false, room: null, error: 'versions', message: 'Too many versions of this game are being played at once: play offline for now.' }, 503);
2700
- }
2701
- if (url.searchParams.get('agent') === '1') {
2702
- // An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
2703
- const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
2704
- let best = null;
2705
- for (const r of pool) {
2706
- const agents = (r.agents ?? 0) + (r.agentPending ?? []).length;
2707
- if (!(r.players > 0) || (reserve ? agents >= reserve : r.players + r.pending.length + agents >= max)) continue;
2708
- if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
2709
- }
2710
- if (!best) return json({ room: null, players: 0, max });
2711
- (best.agentPending ??= []).push(now);
2712
- return json({ room: best.name, players: best.players, max });
2713
- }
2714
- let best = null;
2715
- for (const r of pool) {
2716
- const fill = r.players + r.pending.length;
2717
- if (fill >= max) continue;
2718
- if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
2719
- }
2720
- let room = best?.r;
2721
- let full = false;
2722
- const fullest = (list) => list.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), list[0]);
2723
- if (!room && server !== 'public' && everyRoom.length >= roomsMax) {
2724
- // Every room this server may have exists, whatever build each runs: the fullest one of this build, where the
2725
- // visitor waits for a seat; with none of this build, the fullest of the server (its players are told a newer
2726
- // build is live, and the visitor is let in when they have it). Never a room past the server's own number.
2727
- const from = pool.length ? pool : ofServer;
2728
- if (!from.length) return json({ ok: false, room: null, error: 'full', message: 'Every room of this server is taken.' }, 503);
2729
- room = fullest(from);
2730
- full = true;
2731
- }
2732
- if (!room && this.rooms.size >= LOBBY_ROOMS_MAX) {
2733
- // The Lobby keeps a bounded list (it is one stored value): rooms nobody is seated in go first, the oldest
2734
- // first. When every room has people in it, nobody gets a new one: this build's fullest, or no room at all.
2735
- const empty = [...this.rooms.values()].filter((r) => r.players === 0).sort((a, b) => a.at - b.at);
2736
- for (const r of empty) { if (this.rooms.size < LOBBY_ROOMS_MAX) break; this.rooms.delete(r.name); }
2737
- if (this.rooms.size >= LOBBY_ROOMS_MAX) {
2738
- if (!pool.length) return json({ ok: false, room: null, error: 'busy', message: 'Every room is taken right now.' }, 503);
2739
- room = fullest(pool);
2740
- full = true;
2741
- }
2742
- }
2743
- if (!room) {
2744
- let n = this.next[server] ?? 1;
2745
- while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
2746
- room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, ...(host ? { host } : {}), pending: [], agentPending: [] };
2747
- this.next[server] = n + 1;
2748
- this.rooms.set(room.name, room);
2749
- }
2750
- room.pending.push(now);
2751
- this.save();
2752
- return json({ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) });
2753
- }
2754
- if (url.pathname === '/report' && request.method === 'POST') {
2755
- const body = await request.json().catch(() => ({}));
2756
- if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
2757
- if (body.ended === true) { this.rooms.delete(body.room); this.seen.delete(body.room); this.save(); return json({ ok: true }); }
2758
- // The owner closed this room: matched with nobody until then (and forgotten as a public room).
2759
- if (Number.isFinite(body.closed)) {
2760
- if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
2761
- this.seen.delete(body.room);
2762
- this.save();
2763
- return json({ ok: true, closed: body.closed > now });
2764
- }
2765
- // A retiring isolate may send its last report after the new pool was
2766
- // selected. It cannot resurrect an incompatible public room.
2767
- if (pooledRoom(body.room) && this.current && (this.current.host === 'server'
2768
- ? (body.host ?? 'browser') !== 'server' || (body.ver ?? '') !== this.current.ver
2769
- : body.host === 'server')) return json({ ok: true, retired: true });
2770
- const seated = Math.max(0, Math.floor(Number(body.players) || 0));
2771
- const agents = Math.max(0, Math.floor(Number(body.agents) || 0));
2772
- const ai = Math.max(agents, Math.floor(Number(body.ai) || 0));
2773
- if (seated > 0) this.seen.set(body.room, { room: body.room, players: seated, people: Math.max(0, Math.floor(Number(body.people ?? seated) || 0)), agents, ai, at: now });
2774
- else this.seen.delete(body.room);
2775
- if (this.closed.has(body.room)) return json({ ok: true, closed: true });
2776
- this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
2777
- // Only rooms of a pool are matched with strangers (pub-N, s-<id>-<n>); a named room (?room=) stays private.
2778
- if (!this.rooms.has(body.room) && !pooledRoom(body.room)) return json({ ok: true, private: true });
2779
- const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, ver: '', pending: [], agentPending: [] };
2780
- const grew = Math.max(0, seated - r.players);
2781
- // The room says which build it runs (it changes only when nobody on the old one is left).
2782
- const rver = typeof body.ver === 'string' ? versionOf(body.ver) ?? '' : r.ver ?? '';
2783
- const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
2784
- r.ver = rver;
2785
- if (body.host === 'server' || body.host === 'browser') r.host = body.host;
2786
- r.pending.splice(0, grew);
2787
- if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
2788
- r.players = seated;
2789
- r.agents = agents;
2790
- r.ai = ai;
2791
- r.at = now;
2792
- this.rooms.set(r.name, r);
2793
- if (changed) this.save(); // the minute's "still here" report writes nothing
2794
- return json({ ok: true });
2795
- }
2796
- if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at }) => ({ name, players, agents: agents ?? 0, ai: ai ?? 0, server: server ?? roomServer(name), at })) });
2797
- if (url.pathname === '/busiest') {
2798
- // The pooled room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
2799
- // no seat. A closed room, one named in `not`, or one of a pool not in `pools` (a server with a door) is never it.
2800
- const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
2801
- const pools = url.searchParams.has('pools') ? new Set(String(url.searchParams.get('pools')).split(',').filter(Boolean)) : null;
2802
- let best = null;
2803
- for (const r of this.rooms.values()) {
2804
- if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
2805
- // A watcher's page runs the live build: a room still on an older one would turn it away.
2806
- if (url.searchParams.has('ver') && (r.ver ?? '') !== ver) continue;
2807
- if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
2808
- }
2809
- return json({ room: best?.name ?? null, players: best?.players ?? 0 });
2810
- }
2811
- if (url.pathname === '/office') {
2812
- // The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
2813
- for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
2814
- const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
2815
- for (const r of this.rooms.values()) if (r.players > 0 && !rooms.has(r.name)) rooms.set(r.name, { room: r.name, players: r.players, people: r.players, agents: r.agents ?? 0, ai: r.ai ?? 0, at: r.at });
2816
- for (const [name, until] of this.closed) if (until > now && !rooms.has(name)) rooms.set(name, { room: name, players: 0, people: 0, at: now, closedUntil: until });
2817
- return json({ rooms: [...rooms.values()].map((r) => ({ ...r, server: roomServer(r.room) })).sort((a, b) => b.players - a.players).slice(0, 100) });
2818
- }
2819
- if (url.pathname === '/now') {
2820
- let players = 0;
2821
- for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }
2822
- return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
2823
- }
2824
- return json({ error: 'not found' }, 404);
2825
- }
2826
- }