@homie-rocks/studio 0.54.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 (73) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/lib/background-queue.mjs +31 -0
  3. package/lib/build.mjs +4 -1
  4. package/lib/cloudflare.mjs +16 -1
  5. package/lib/read-replication.mjs +26 -0
  6. package/lib/scaffold.mjs +28 -10
  7. package/lib/shop.mjs +7 -0
  8. package/lib/static-assets.mjs +27 -0
  9. package/lib/template-history.json +42 -0
  10. package/lib/worker-build.mjs +13 -4
  11. package/lib/worker-config.mjs +3 -1
  12. package/package.json +1 -1
  13. package/worker/analytics.mjs +84 -0
  14. package/worker/app-frame.mjs +1 -1
  15. package/worker/app-graphics.mjs +1 -1
  16. package/worker/app-streams.mjs +3 -3
  17. package/worker/asset-paths.mjs +2 -0
  18. package/worker/background-queue.mjs +30 -0
  19. package/worker/build-id.mjs +1 -1
  20. package/worker/built-metadata.mjs +2 -0
  21. package/worker/catalogue.mjs +18 -0
  22. package/worker/chat-page.mjs +1 -1
  23. package/worker/embed.mjs +1 -1
  24. package/worker/event-resources.mjs +23 -11
  25. package/worker/function-registry.mjs +4 -0
  26. package/worker/functions.mjs +7 -5
  27. package/worker/gate-routing.mjs +61 -0
  28. package/worker/gate.mjs +12 -68
  29. package/worker/hosted-registry.mjs +11 -0
  30. package/worker/hosted.mjs +3 -17
  31. package/worker/index.mjs +54 -2785
  32. package/worker/lazy-objects.mjs +40 -0
  33. package/worker/lobby-limits.mjs +2 -0
  34. package/worker/lobby.mjs +251 -0
  35. package/worker/lounge.mjs +3 -3
  36. package/worker/mcp-event-streams.mjs +3 -3
  37. package/worker/mcp-events.mjs +4 -4
  38. package/worker/mcp-store.mjs +12 -1
  39. package/worker/mcp.mjs +5 -1
  40. package/worker/office.mjs +16 -20
  41. package/worker/operational-schema.mjs +14 -0
  42. package/worker/page-policy.mjs +29 -0
  43. package/worker/pages.mjs +8 -28
  44. package/worker/parts.mjs +2 -1
  45. package/worker/payment-recovery.mjs +33 -19
  46. package/worker/player-registry.mjs +9 -0
  47. package/worker/players.mjs +8 -7
  48. package/worker/purchase-machine.mjs +5 -3
  49. package/worker/reconcile-guard.mjs +22 -0
  50. package/worker/request-db.mjs +35 -10
  51. package/worker/responses.mjs +18 -0
  52. package/worker/room-chat.mjs +216 -0
  53. package/worker/room-controls.mjs +296 -0
  54. package/worker/room-lifecycle.mjs +10 -0
  55. package/worker/room-protocol.mjs +197 -0
  56. package/worker/room.mjs +38 -731
  57. package/worker/rooms-cache.mjs +19 -0
  58. package/worker/routes.mjs +667 -0
  59. package/worker/runtime-support.mjs +445 -0
  60. package/worker/seats.mjs +1 -1
  61. package/worker/selling/index.mjs +1 -0
  62. package/worker/selling/runtime.mjs +7 -5
  63. package/worker/service-run.mjs +1 -1
  64. package/worker/shop-links.mjs +1 -1
  65. package/worker/shop-page.mjs +1 -0
  66. package/worker/shop.mjs +27 -20
  67. package/worker/stats.mjs +7 -6
  68. package/worker/stream-objects.mjs +18 -0
  69. package/worker/stripe-intake.mjs +79 -0
  70. package/worker/stripe.mjs +3 -0
  71. package/worker/table.mjs +1212 -0
  72. package/worker/tool-registry.mjs +10 -0
  73. package/worker/version.mjs +1 -1
@@ -0,0 +1,1212 @@
1
+ /** Extracted from the studio runtime; behavior is unchanged. */
2
+ import {RULES_WIRING_MESSAGE} from './rules-wiring.mjs';
3
+ import {characterStore,characterRequest} from './characters.mjs';
4
+ import {NetRoom,WATCH_POLICIES,departure,errorLine} from './room.mjs';
5
+ import {hostedGame,hostedBuild,compiledFor,prepareHost,startHost} from './hosted.mjs';
6
+ import {roomStore,restoreDelay,LOOSE_SAVE} from './room-store.mjs';
7
+ import {pooledRoom} from './servers.mjs';
8
+ import {BRAIN_BUDGET,HouseAgents,decodeFacts} from './agents.mjs';
9
+ import {CLEF,DECIDE,clefRun,costOf,localAiOf,picksOf,talks} from './brain.mjs';
10
+ import {reviewChat} from './chat.mjs';
11
+ import {chatDay} from './chat-store.mjs';
12
+ import {forgetLines,historyOf,keepLines,keptLine} from './lounge-store.mjs';
13
+ import {acceptMultiplex,diagnosticsOf,gatePlayers,nextGate} from './gate.mjs';
14
+ import {worldSource} from './world-wire.mjs';
15
+ import {seatCount,perAddress} from './seats.mjs';
16
+ import {count,counter,isQa,today} from './stats.mjs';
17
+ import {holders,regateArgs,verifyControl} from './office.mjs';
18
+ import {json} from './responses.mjs';
19
+ import {catalogue} from './catalogue.mjs';
20
+ import {ROOM_ALARM_MS,ROOM_PAUSE_MS,roomEndAt} from './room-lifecycle.mjs';
21
+
22
+ class Table {
23
+ constructor(ctx, env) {
24
+ this.ctx = ctx;
25
+ this.env = env;
26
+ this.room = null;
27
+ this.timer = null;
28
+ this.saved = null;
29
+ this.game = null;
30
+ this.code = null;
31
+ this.recorded = 0;
32
+ this.lastReport = -1;
33
+ /** This room was counted as opened (worker/stats.mjs `room`); cleared when its seats are all forgotten. */
34
+ this.openCounted = false;
35
+ /** The owner's holds, banner and closed door (they outlive an empty room), and the controls already used. */
36
+ this.officeSaved = null;
37
+ this.seen = new Map();
38
+ /** Revision 7: the game's vocabulary (agents.json, read once from the build), and the room's house guides. */
39
+ this.vocabRead = null;
40
+ this.house = null;
41
+ /** The AI brains' day (worker/agents.mjs): what the studio allows a day and has used, and what is not counted yet. */
42
+ this.brainDay = null;
43
+ this.brainCap = { neurons: BRAIN_BUDGET.neurons, micros: BRAIN_BUDGET.usd * 1e6 };
44
+ this.brainUsed = { neurons: 0, micros: 0 };
45
+ this.brainPending = { calls: {}, neurons: 0, micros: 0 };
46
+ this.brainReadAt = 0;
47
+ /** 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. */
48
+ this.chatCap = null;
49
+ this.chatUsed = 0;
50
+ this.chatReadAt = 0;
51
+ this.chatPend = { neurons: 0, ok: 0, held: 0, error: 0 };
52
+ this.chatSent = { chatLines: 0, chatReacts: 0, held: {} };
53
+ /**
54
+ * Revision 10 (NETPLAY.md section 29): the host runtime of a server-hosted room (rules/host.ts), and when this
55
+ * room next needs the object's one alarm (the house guides and the day's counters share it).
56
+ */
57
+ this.hostRt = null;
58
+ this.characterGrants = new Map();
59
+ this.accountTokens = {};
60
+ this.inputTransit = [];
61
+ this.relayed = false;
62
+ this.gateHeld = new Map();
63
+ this.gatePromised = new Map();
64
+ this.roomAlarmAt = 0;
65
+ this.durable = null;
66
+ this.roomSave = null;
67
+ this.endedRoom = null;
68
+ this.pausedAt = null;
69
+ ctx.blockConcurrencyWhile(async () => {
70
+ // Only a confirmed missing alarm permits the save-age fallback. A read failure is not evidence of absence.
71
+ this.recoveredAlarmAt = Infinity;
72
+ try { this.recoveredAlarmAt = (await ctx.storage.getAlarm?.()) ?? null; }
73
+ catch (error) { this.storageProblem(error); }
74
+ this.saved = (await ctx.storage.get('net')) ?? null;
75
+ if (this.saved?.retired === true) this.cleanupPending = true;
76
+ this.recorded = (await ctx.storage.get('recorded')) ?? 0;
77
+ this.officeSaved = (await ctx.storage.get('office')) ?? null;
78
+ // A big match room's last unconfirmed checkpoint (worker/room-store.mjs), read once as the object wakes.
79
+ try { this.looseSave = (await ctx.storage.list?.({ prefix: LOOSE_SAVE })) ?? null; }
80
+ catch (error) { this.looseSave = null; this.storageProblem(error); }
81
+ });
82
+ }
83
+
84
+ roomFor(game, code, max) {
85
+ if (this.room) return this.room;
86
+ const storage = this.ctx.storage;
87
+ this.game = game;
88
+ this.code = code;
89
+ this.room = new NetRoom({
90
+ code,
91
+ maxPlayers: max,
92
+ perIp: perAddress(max),
93
+ store: {
94
+ save: (o) => {
95
+ if (this.hostRt && !this.relayFrame) this.saveRoom(this.hostRt.save());
96
+ else if (this.hostRt) {
97
+ // All admissions in one relay turn share a checkpoint. The microtask
98
+ // runs before the relay's timer flush, retaining the storage output gate.
99
+ if (!this.admissionSaveQueued) {
100
+ this.admissionSaveQueued = true;
101
+ const host = this.hostRt;
102
+ queueMicrotask(() => {
103
+ this.admissionSaveQueued = false;
104
+ if (this.hostRt === host) { try { this.saveRoom(host.save()); } catch (error) { this.storageProblem(error); } }
105
+ });
106
+ }
107
+ } else if (!hostedGame(game)) storage.put('net', o).catch(() => {});
108
+ },
109
+ clear: () => { this.clearRoom(); },
110
+ },
111
+ // The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
112
+ // election, a build change, and `failed` (a room operation that threw). A departure and a failure seconds apart
113
+ // are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
114
+ log: (line) => this.say(line),
115
+ });
116
+ this.room.gateFacts = () => [...(this.gateMetrics?.values() ?? [])];
117
+ if (this.saved && !hostedGame(game)) this.room.restore(this.saved);
118
+ if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
119
+ this.vocabRead = this.readVocab(game).catch(() => false);
120
+ // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
121
+ this.room.review = (text, opts) => this.reviewChat(text, opts);
122
+ // Kept chat (0.29.0): a room whose rules keep `history` writes what was said to D1 and deletes what is taken down,
123
+ // one after another (a line removed the moment it went out is never written after its delete).
124
+ this.room.onPublished = (rec) => this.keptQueue(() => keepLines(this.env, this.game, this.code, [rec]));
125
+ this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
126
+ // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
127
+ this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
128
+ // A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
129
+ this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
130
+ if (this.isPersistent()) this.room.forgetMs = Infinity;
131
+ return this.room;
132
+ }
133
+
134
+ /* ------------------------------------------------------------ the server as host (NETPLAY.md section 29) */
135
+
136
+ /**
137
+ * A room of a server-hosted game gets its host runtime before its first socket is attached, so no browser is ever
138
+ * elected. `hosted`: the Worker's word that this game's settings say `host: server`. Rules that are missing from
139
+ * this build, or do not fit the contract, do not start: the room refuses joins and says why.
140
+ */
141
+ isPersistent() { return this.persistentStored === true || hostedBuild(this.game)?.settings?.lifetime === 'persistent'; }
142
+
143
+ ensureHost(room, hosted) {
144
+ if (this.hostRt || !this.game) return;
145
+ if (!hostedGame(this.game)) {
146
+ if (hosted) room.failServerHost(RULES_WIRING_MESSAGE);
147
+ return;
148
+ }
149
+ let restoring = false;
150
+ let pauseWriteFailed = false;
151
+ try {
152
+ const build = hostedBuild(this.game);
153
+ const persistent = this.isPersistent();
154
+ try { this.durable ??= this.openStore(); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
155
+ let saved = this.cleanupPending ? null : this.readRoom();
156
+ restoring = Boolean(saved);
157
+ if (this.persistentStored && build.settings.lifetime !== 'persistent') throw new Error('This room holds a lasting world. Keep room.lifetime persistent; its save is retained.');
158
+ let boot = { epoch: 0, count: 0 };
159
+ try { boot = this.durable?.boots() ?? boot; } catch (error) { if (saved) throw error; this.storageProblem(error); }
160
+ if (!Number.isSafeInteger(boot.epoch) || boot.epoch < 0 || !Number.isInteger(boot.count) || boot.count < 0) {
161
+ const error = new Error('saved boot counter is invalid');
162
+ if (saved) throw error;
163
+ this.storageProblem(error); this.clearRoom(); boot = { epoch: 0, count: 0 };
164
+ }
165
+ if (saved && saved.buildHash !== build.build) boot.count = 0;
166
+ // A restore's provisional pause is not a recorded departure. A stored alarm,
167
+ // even one now due, has not yet confirmed expiry. The alarm handler owns that decision.
168
+ const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
169
+ const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
170
+ const expired = Date.now() >= restoredEnd;
171
+ 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.');
172
+ if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
173
+ const why = saved.stateHash !== build.stateHash ? 'state-changed' : boot.count >= 2 ? 'restore loop' : 'nobody-returned';
174
+ const tokens = new Set((saved.net.seats ?? []).map((s) => s[1]));
175
+ this.finishRoom(why);
176
+ room.forget(); room.ended = null;
177
+ // Only a holder returning to the old match is rematched. A new visitor uses this name immediately.
178
+ if (!expired && pooledRoom(this.code)) room.endedMatch = { why, ver: build.build, tokens, until: Date.now() + ROOM_PAUSE_MS };
179
+ this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
180
+ }
181
+ this.roomSave = saved;
182
+ this.accountTokens = saved?.accountTokens ?? {};
183
+ if (saved) {
184
+ // The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
185
+ room.seats.clear(); room.state.clear(); room.stateBytes = 0;
186
+ room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
187
+ this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
188
+ this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
189
+ saved.pausedAt = this.pausedAt;
190
+ // A failed pause write must never be mistaken for an invalid host save.
191
+ try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
192
+ }
193
+ const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
194
+ if (saved) this.durable.boot({ epoch, count: boot.count });
195
+ this.hostCreatedAt = Date.now();
196
+ let ticks = 0;
197
+ this.hostRt = startHost(this.game, {
198
+ ...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
199
+ record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
200
+ store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment, true) },
201
+ // A room behind Gates needs no snapshot as text: nothing is encoded that nobody is sent (room.mjs, hostSnap).
202
+ snapshotText: () => !this.relayed,
203
+ ...(build.settings.characters ? { characterFor: (peer) => {
204
+ const account = room.clients.get(peer.id)?.player;
205
+ return account ? this.characterGrants.get(account) ?? null : null;
206
+ } } : {}),
207
+ onTick: () => {
208
+ if (!saved) return;
209
+ ticks += 1;
210
+ if (ticks === 1) this.pendingBoot = { epoch, count: saved.buildHash !== build.build ? 0 : boot.count + 1 };
211
+ if (ticks === 10 * build.settings.tickHz) this.pendingBoot = { epoch, count: 0 };
212
+ 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); } }
213
+ },
214
+ send: (m, text) => room.hostFrame(m, text),
215
+ log: (line) => { if (line.ev === 'persist-failed') this.storageProblem(new Error(line.error)); else this.say(line); },
216
+ onPause: () => { this.pauseFromRestore = false; this.pausedAt ??= Date.now(); this.armRoom(this.pausedAt + ROOM_PAUSE_MS); },
217
+ 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); },
218
+ onEnd: (why) => this.endHosted(why),
219
+ });
220
+ room.setServerHost(this.hostRt);
221
+ room.gameVer = build.build;
222
+ room.setCurrent(build.build);
223
+ if (pauseWriteFailed) { try { this.saveRoom(this.hostRt.save()); } catch { /* the save retry retains the original absence */ } }
224
+ if (saved) {
225
+ room.hostFrame({ t: 'state', k: 'shared', d: this.hostRt.core.shared() });
226
+ room.hostFrame({ t: 'snap', from: null, e: epoch, k: this.hostRt.tick, st: Date.now(), d: this.hostRt.core.snapshot(), c: [] });
227
+ }
228
+ this.armRoom(roomEndAt({ people: 0, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt }));
229
+ } catch (error) {
230
+ this.hostRt?.stop(); this.hostRt = null;
231
+ if (restoring && !this.isPersistent()) {
232
+ this.storageProblem(error);
233
+ room.forget(); this.clearRoom();
234
+ this.skipSave = true;
235
+ this.ensureHost(room, hosted);
236
+ return;
237
+ }
238
+ room.failServerHost(`This game's rules could not start: ${String(error?.message ?? error).slice(0, 160)}`);
239
+ }
240
+ }
241
+
242
+ /** This object's room store, with the unconfirmed checkpoint it woke to (if any). */
243
+ openStore(persistent = this.isPersistent()) {
244
+ const loose = this.looseSave; this.looseSave = null;
245
+ return roomStore(this.ctx.storage, { persistent, loose, onProblem: (error) => this.storageProblem(error) });
246
+ }
247
+
248
+ /** `periodic`: the rules host's own checkpoint of a tick, as against a save an admission or a pause asked for. */
249
+ saveRoom(bytes, commitment, periodic = false) {
250
+ if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
251
+ const now = Date.now();
252
+ if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
253
+ try {
254
+ this.durable ??= this.openStore();
255
+ const build = hostedBuild(this.game);
256
+ const host = new TextDecoder().decode(bytes);
257
+ const net = this.room.saved();
258
+ if (build.settings.characters) for (const client of this.room.clients.values()) {
259
+ if (client.player && client.seat !== null && client.token) this.accountTokens[client.player] = client.token;
260
+ }
261
+ if (build.settings.characters) {
262
+ const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
263
+ for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
264
+ }
265
+ 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 };
266
+ const clearing = this.cleanupPending;
267
+ if (clearing) this.durable.clear();
268
+ if (this.isPersistent()) {
269
+ const c = compiledFor(this.game);
270
+ commitment ??= { force: false, seconds: c.settings.durability.movementSeconds, tickHz: c.settings.tickHz,
271
+ fields: { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) } };
272
+ this.durable.commit(saved, commitment);
273
+ } else if (this.relayed && periodic && !clearing) {
274
+ // A room behind Gates: its once-a-second checkpoint holds none of its frames (worker/room-store.mjs). A save
275
+ // an admission asks for is still confirmed before the welcome that names the seat can leave.
276
+ this.durable.write(saved, { unconfirmed: true });
277
+ } else this.durable.write(saved);
278
+ if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
279
+ this.roomSave = saved;
280
+ if (build.settings.characters) this.ctx.waitUntil(this.flushCharacters().catch(error => this.storageProblem(error)));
281
+ this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
282
+ this.saveFailures = 0; this.saveRetryAt = 0; this.saveError = null;
283
+ clearTimeout(this.saveRetry); this.saveRetry = null;
284
+ this.storageHealth = { ok: !this.pendingBoot };
285
+ } catch (error) {
286
+ this.saveError = error;
287
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.saveFailures ?? 0, 6));
288
+ this.saveFailures = (this.saveFailures ?? 0) + 1; this.saveRetryAt = now + delay;
289
+ this.storageProblem(error);
290
+ if (this.isPersistent()) this.endHosted('storage-failed');
291
+ if (!this.isPersistent() && !this.saveRetry) {
292
+ this.saveRetry = setTimeout(() => {
293
+ this.saveRetry = null;
294
+ if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* saveRoom scheduled the next attempt */ } }
295
+ }, delay);
296
+ this.saveRetry?.unref?.();
297
+ }
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ storageProblem(error) {
303
+ this.storageHealth = { ok: false, message: String(error?.message ?? error).slice(0, 160), since: this.storageHealth?.ok === false ? this.storageHealth.since : Date.now() };
304
+ if (Date.now() < (this.storageLogAt ?? 0)) return;
305
+ this.storageLogAt = Date.now() + 1000;
306
+ this.say({ ev: 'persist-failed', build: hostedBuild(this.game)?.build, error: this.storageHealth.message });
307
+ }
308
+
309
+ readRoom(validateHost = false, alreadyRead) {
310
+ if (this.skipSave) { this.skipSave = false; return null; }
311
+ try {
312
+ const boot = this.durable?.boots();
313
+ this.persistentStored = boot?.persistent === true;
314
+ if (boot?.retired === true) { this.clearRoom(); return null; }
315
+ const saved = alreadyRead ?? this.durable?.read();
316
+ if (!saved) return null;
317
+ if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
318
+ this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
319
+ !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) ||
320
+ saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' ||
321
+ saved.host?.v !== 1 || !saved.net || typeof saved.stateHash !== 'string' || typeof saved.buildHash !== 'string') throw new Error('save envelope does not fit this room');
322
+ saved.at = Math.min(saved.at, Date.now());
323
+ if (saved.pausedAt !== null) saved.pausedAt = Math.min(saved.pausedAt, Date.now());
324
+ if (hostedGame(saved.game) && saved.stateHash !== hostedBuild(saved.game).stateHash) return saved;
325
+ this.game ??= saved.game; this.code ??= saved.room;
326
+ const seats = saved.net.seats;
327
+ const cap = hostedBuild(saved.game)?.seats ?? 32;
328
+ if (saved.net.v !== 1 || saved.net.room !== saved.room || !Array.isArray(seats) || seats.length > cap ||
329
+ new Set(seats.map((s) => s?.[0])).size !== seats.length || new Set(seats.map((s) => s?.[1])).size !== seats.length ||
330
+ 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');
331
+ if (validateHost && hostedGame(saved.game) && saved.stateHash === hostedBuild(saved.game).stateHash) {
332
+ const probe = startHost(saved.game, { restore: new TextEncoder().encode(saved.hostText ?? JSON.stringify(saved.host)), send() {} });
333
+ probe.stop();
334
+ } else if (!hostedGame(saved.game)) throw new Error('saved game is not in this build');
335
+ return saved;
336
+ } catch (error) { this.storageProblem(error); if (this.isPersistent()) throw error; this.clearRoom(); return null; }
337
+ }
338
+
339
+ clearRoom() {
340
+ if (this.isPersistent()) throw new Error('A lasting world cannot be cleared by match cleanup');
341
+ this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
342
+ this.roomSave = null; this.saved = null; this.pendingBoot = null;
343
+ try { this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
344
+ catch (error) { this.retryCleanup(error); }
345
+ this.queueNetCleanup(this.cleanupPending);
346
+ }
347
+
348
+ queueNetCleanup(retired) {
349
+ // The key-value record can preserve retirement when SQL writes, not just deletes, are unavailable.
350
+ // Serialize it with a later successful save so an older failure cannot retire the new match.
351
+ const generation = this.cleanupGeneration;
352
+ this.netCleanup = (this.netCleanup ?? Promise.resolve()).then(() => {
353
+ if (generation !== this.cleanupGeneration) return;
354
+ return retired ? this.ctx.storage.put('net', { retired: true }) : this.ctx.storage.delete('net');
355
+ }).then(() => {
356
+ if (generation !== this.cleanupGeneration) return;
357
+ this.netCleanupFailed = false;
358
+ clearTimeout(this.netCleanupRetry); this.netCleanupRetry = null;
359
+ if (!this.cleanupPending) this.cleanupFailures = 0;
360
+ }).catch((error) => {
361
+ if (generation !== this.cleanupGeneration) return;
362
+ if (retired) { this.retryCleanup(error); return; }
363
+ // Removing an old key marker is not permission to delete a newer SQL match.
364
+ this.netCleanupFailed = true; this.storageProblem(error);
365
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.cleanupFailures ?? 0, 6));
366
+ this.cleanupFailures = (this.cleanupFailures ?? 0) + 1;
367
+ this.armRoom(Date.now() + delay);
368
+ if (!this.netCleanupRetry) {
369
+ this.netCleanupRetry = setTimeout(() => { this.netCleanupRetry = null; if (generation === this.cleanupGeneration) this.queueNetCleanup(false); }, delay);
370
+ this.netCleanupRetry?.unref?.();
371
+ }
372
+ });
373
+ this.ctx.waitUntil(this.netCleanup);
374
+ }
375
+
376
+ retryCleanup(error) {
377
+ this.cleanupPending = true; this.storageProblem(error);
378
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.cleanupFailures ?? 0, 6));
379
+ this.cleanupFailures = (this.cleanupFailures ?? 0) + 1;
380
+ this.armRoom(Date.now() + delay);
381
+ // An alarm write can fail as well. Keep the end pending for this object's lifetime.
382
+ if (!this.cleanupRetry) {
383
+ this.cleanupRetry = setTimeout(() => { this.cleanupRetry = null; if (this.cleanupPending) this.clearRoom(); }, delay);
384
+ this.cleanupRetry?.unref?.();
385
+ }
386
+ }
387
+
388
+ finishRoom(why) {
389
+ if (this.isPersistent()) { this.hostRt?.stop(); this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`); return; }
390
+ this.hostRt?.stop(); this.hostRt = null;
391
+ clearInterval(this.timer); this.timer = null;
392
+ clearTimeout(this.saveRetry); this.saveRetry = null; this.saveRetryAt = 0;
393
+ this.clearRoom();
394
+ const room = this.room;
395
+ if (room) {
396
+ this.officeSaved = room.officeSaved();
397
+ room.ended = { why, ver: hostedBuild(this.game)?.build ?? null };
398
+ for (const w of room.watchers) { try { w.close(1000, 'room-over'); } catch { /* gone */ } }
399
+ room.watchers.clear();
400
+ }
401
+ this.tellLobby({ ended: true });
402
+ 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.' } : {}) });
403
+ this.room = null; this.endedRoom = null; this.pausedAt = null;
404
+ }
405
+
406
+ /** The room wants the alarm at `at` (ms): set it unless an earlier one is already waiting. */
407
+ armRoom(at) {
408
+ if (this.isPersistent()) return;
409
+ this.roomAlarmAt = at;
410
+ this.alarmWrite = (this.alarmWrite ?? Promise.resolve()).then(async () => {
411
+ const cur = await this.ctx.storage.getAlarm();
412
+ if (cur === null || cur > at) await this.ctx.storage.setAlarm(at);
413
+ this.alarmFailures = 0;
414
+ }).catch((error) => {
415
+ this.storageProblem(error);
416
+ if (this.alarmRetry) return;
417
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.alarmFailures ?? 0, 6));
418
+ this.alarmFailures = (this.alarmFailures ?? 0) + 1;
419
+ this.alarmRetry = setTimeout(() => { this.alarmRetry = null; this.armRoom(this.roomAlarmAt); }, delay);
420
+ this.alarmRetry?.unref?.();
421
+ });
422
+ this.ctx.waitUntil(this.alarmWrite);
423
+ }
424
+
425
+ /**
426
+ * The rules failed again and again (rules/host.ts: the budget on every tick for two seconds, or slow ticks for
427
+ * five): the room ends. Its players are told, their seats are freed, and the next visitor starts a fresh room.
428
+ */
429
+ endHosted(why) {
430
+ if (this.isPersistent()) {
431
+ this.persistentFailed = true;
432
+ // The host's own stop would checkpoint a healthy world; this one is refused by `persistentFailed`, and the committed save stands.
433
+ try { this.hostRt?.stop(); } catch { /* stopped all the same */ }
434
+ clearTimeout(this.saveRetry); this.saveRetry = null;
435
+ this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`);
436
+ return;
437
+ }
438
+ const room = this.room;
439
+ if (!room) return;
440
+ this.finishRoom(why);
441
+ this.hostRt = null;
442
+ for (const c of [...room.clients.values()]) {
443
+ if (c.helloed) room.error(c, 'room-over', 'This room ended. Joining a fresh room.', { rematch: true, ver: room.ended.ver });
444
+ room.clients.delete(c.id);
445
+ try { c.conn.close(1011, 'room-over'); } catch { /* gone */ }
446
+ }
447
+ room.emptySince = Date.now();
448
+ room.forget();
449
+ this.report();
450
+ }
451
+
452
+ /**
453
+ * The room's part of the alarm (section 4.5 of the design). A room nobody came back to within a minute of its
454
+ * pause ends: the relay forgets it and its stored seats go (`office` and `recorded` stay). A room in play keeps an
455
+ * alarm ahead of it, so one that Cloudflare restarted and nobody returned to is still ended. After a restart there
456
+ * is no room in memory: stored seats of a server-hosted room that nobody reconnected to are deleted.
457
+ */
458
+ async roomAlarm() {
459
+ try { await this.runRoomAlarm(); this.roomAlarmFailures = 0; }
460
+ catch (error) {
461
+ this.storageProblem(error);
462
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.roomAlarmFailures ?? 0, 6));
463
+ this.roomAlarmFailures = (this.roomAlarmFailures ?? 0) + 1;
464
+ this.armRoom(Date.now() + delay);
465
+ }
466
+ }
467
+
468
+ async runRoomAlarm() {
469
+ if (this.netCleanupFailed) this.queueNetCleanup(false);
470
+ if (this.cleanupPending) this.clearRoom();
471
+ const now = Date.now();
472
+ const room = this.room;
473
+ if (!room || !this.hostRt) {
474
+ if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
475
+ this.durable ??= this.openStore(false);
476
+ let saved = this.cleanupPending ? null : this.readRoom();
477
+ if (saved) {
478
+ const generation = this.cleanupGeneration;
479
+ const preparing = prepareHost(saved.game);
480
+ if (preparing) {
481
+ await preparing;
482
+ // A join or office action can arrive while the optional engine loads.
483
+ // Re-read that lifecycle instead of writing the pre-await checkpoint.
484
+ if (this.room !== room || this.hostRt || this.cleanupGeneration !== generation) return this.runRoomAlarm();
485
+ }
486
+ saved = this.readRoom(true, saved);
487
+ }
488
+ if (saved) {
489
+ this.game = saved.game; this.code = saved.room;
490
+ if (this.isPersistent()) return;
491
+ if (saved.stateHash !== hostedBuild(saved.game)?.stateHash) { this.finishRoom('state-changed'); return; }
492
+ // No saved pause means people were present. The first cold wake starts the absence period.
493
+ if (saved.pausedAt === null) {
494
+ this.pausedAt ??= now; saved.pausedAt = this.pausedAt; this.durable.write(saved);
495
+ }
496
+ const endAt = roomEndAt({ people: 0, pausedAt: saved.pausedAt, createdAt: now });
497
+ if (now < endAt) { this.armRoom(endAt); return; }
498
+ this.finishRoom('nobody-returned');
499
+ }
500
+ }
501
+ 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 }); }
502
+ return;
503
+ }
504
+ if (!this.hostRt) return;
505
+ if (this.isPersistent()) { await this.flushCharacters(); return; }
506
+ const endAt = roomEndAt({ people: this.hostRt.people, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt });
507
+ if (endAt === Infinity) { if (now >= this.roomAlarmAt - 1000) this.armRoom(now + ROOM_ALARM_MS); else this.armRoom(this.roomAlarmAt); return; }
508
+ if (now < endAt) { this.armRoom(endAt); return; }
509
+ // Nobody returned: neither watchers nor AI seats keep the paused room.
510
+ this.finishRoom('nobody-returned');
511
+ 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 */ } }
512
+ room.forget();
513
+ this.report();
514
+ }
515
+
516
+ /**
517
+ * One log line of this room: `{ at, ev, game, room, … }`. Never a seat token, a ticket, a browser key or an address;
518
+ * a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
519
+ */
520
+ say(line) {
521
+ 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);}}
522
+ 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});
523
+ const bad = line?.ev === 'failed' || line?.ev === 'socket-error' || line?.ev === 'tick-failed' || line?.ev === 'persist-failed';
524
+ if (!bad && this.env.HOMIE_ROOM_LOG === '0') return;
525
+ 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 */ }
526
+ }
527
+
528
+ queueRoundRecord(owner,key,value) {
529
+ const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
530
+ sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
531
+ const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
532
+ const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
533
+ if(!player){
534
+ // Never dropped in silence (0.46.5): the log says so, and the person is told on their own screen.
535
+ 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{}
536
+ if(client)this.room.error(client,'unsaved','This round was not saved to your history. Reload the page to keep the next one.');
537
+ return;
538
+ }
539
+ sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
540
+ sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
541
+ this.ctx.waitUntil(this.flushRoundRecords());
542
+ }
543
+ async flushRoundRecords(){
544
+ const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
545
+ if(!sql.exec("SELECT name FROM sqlite_master WHERE name='round_outbox'").toArray().length)return;
546
+ const {appendServerRecord}=await import('./saves.mjs');
547
+ for(const row of sql.exec('SELECT * FROM round_outbox LIMIT 100').toArray()){
548
+ 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);}
549
+ catch(error){this.say({ev:'record-retry',error:String(error)});this.armRoom(Date.now()+60000);return;}
550
+ }
551
+ }
552
+ queueFunctionEvent(type,data) {
553
+ if(!this.env.DB || this.env.HOMIE_PREVIEW==='1' || !this.ctx.storage.sql) return;
554
+ this.ctx.waitUntil((async()=>{
555
+ if(!await (await import('./functions.mjs')).hasFunctionType(type,this.env))return;
556
+ const sql=this.ctx.storage.sql;
557
+ sql.exec('CREATE TABLE IF NOT EXISTS function_outbox(id TEXT PRIMARY KEY,type TEXT,data TEXT,at INTEGER)');
558
+ sql.exec('INSERT INTO function_outbox VALUES(?,?,?,?)',crypto.randomUUID(),type,JSON.stringify(data),Date.now());
559
+ this.armRoom(Date.now()+60000);
560
+ await this.flushFunctionEvents();
561
+ })());
562
+ }
563
+
564
+ async flushFunctionEvents() {
565
+ const sql=this.ctx.storage.sql;
566
+ if(!sql || !this.env.DB)return;
567
+ if(!sql.exec("SELECT name FROM sqlite_master WHERE name='function_outbox'").toArray().length)return;
568
+ const {emitEvent}=await import('./functions.mjs');
569
+ for(const row of sql.exec('SELECT * FROM function_outbox LIMIT 100').toArray()) {
570
+ 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);}
571
+ catch {this.armRoom(Date.now()+60000);await this.alarmWrite;return;}
572
+ }
573
+ const cat=await catalogue(this.env,'https://studio.invalid');
574
+ await (await import('./functions.mjs')).runFunctions(this.env,cat,cat.studio?.url??'https://studio.invalid');
575
+ }
576
+
577
+ /**
578
+ * A socket's `error` event. The runtime raises one when the other end simply went away ("Network connection lost":
579
+ * a closed tab, a phone that slept, a browser the test harness shut): that is a departure, and the room handles it
580
+ * as one. Anything else is a failed socket, logged as such. Both lines carry the room, the client and its role.
581
+ */
582
+ socketError(kind, event, facts = {}) {
583
+ const error = event?.error ?? event?.message ?? event;
584
+ const gone = departure(error) || !error || typeof error === 'object' && !error.message;
585
+ this.say({ ev: gone ? 'socket-gone' : 'socket-error', kind, ...facts, error: errorLine(error) });
586
+ }
587
+
588
+ /** Kept chat's writes, in order, each finished before the next (D1 calls from one room never pass each other). */
589
+ keptQueue(fn) {
590
+ if (!this.env.DB) return;
591
+ 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 */ } });
592
+ this.ctx.waitUntil(this.kept);
593
+ }
594
+
595
+ /** A room that keeps history puts what it kept back in its window before its first page opens (once per opening). */
596
+ async hydrateHistory(room) {
597
+ const days = room.chatRules().history;
598
+ if (!(days > 0) || room.hydrated || !this.env.DB) return;
599
+ room.hydrated = true;
600
+ try { await this.kept; room.hydrate(await historyOf(this.env, this.game, this.code, { days })); } catch { /* the window alone (before migration 0009) */ }
601
+ }
602
+
603
+ /** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
604
+ async decideOn() {
605
+ if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
606
+ let on = false;
607
+ try {
608
+ const res = this.env.ASSETS ? await this.env.ASSETS.fetch(new Request('https://assets.local/games.json')) : null;
609
+ const cat = res?.ok ? await res.json() : null;
610
+ const g = (cat?.games ?? []).find((x) => x.id === this.game);
611
+ on = Boolean(g && (g.decide === true || (g.decide && typeof g.decide === 'object' && g.decide.on !== false)));
612
+ } catch { on = false; }
613
+ this.decideRead = { at: Date.now(), on };
614
+ return on;
615
+ }
616
+
617
+ /**
618
+ * One of a game's own decisions (worker/brain.mjs checkDecide, picksOf): Clef on the studio's Workers AI (model
619
+ * HOMIE_DECIDE_MODEL, else clef-flash), or the person's own Ollama under `homie-studio dev`, within the AI brains' day
620
+ * (the same budget the guides spend: decisions and guides are one day of neurons). Not opted in, no AI, over budget, too
621
+ * slow or failed: `{ ok: false, why }`, and the host's floor answers.
622
+ */
623
+ async decide(state, questions) {
624
+ if (!(await this.decideOn())) return { ok: false, why: 'off' };
625
+ const local = this.env.AI ? null : localAiOf(this.env);
626
+ if (!this.env.AI && !local) return { ok: false, why: 'no-ai' };
627
+ if (this.env.AI) {
628
+ if (!this.brainDay || Date.now() - this.brainReadAt > 60_000) await this.readBrainDay().catch(() => {});
629
+ if (this.brainCap.neurons - this.brainUsed.neurons <= 0) return { ok: false, why: 'budget' };
630
+ }
631
+ const model = CLEF.models[this.env.HOMIE_DECIDE_MODEL] ? this.env.HOMIE_DECIDE_MODEL : '@cf/cloudflare/clef-flash';
632
+ const t0 = Date.now();
633
+ try {
634
+ const r = await clefRun(this.env, { state, questions, model, ms: DECIDE.ms, local });
635
+ const ms = Date.now() - t0;
636
+ const usage = r.usage ?? { input_tokens: Math.ceil(JSON.stringify({ state, questions }).length / 4) + 400, output_tokens: 0 };
637
+ const cost = costOf({ provider: r.engine === 'local' ? 'local' : 'workers-ai', model: r.model, usage });
638
+ this.brainSpend({ neurons: cost.neurons, micros: 0, provider: r.engine === 'local' ? 'decide-local' : 'decide' });
639
+ this.flushSoon();
640
+ const { picks, p } = picksOf(r.answers, questions);
641
+ return { ok: true, by: r.engine === 'local' ? 'local' : 'ai', picks, p, ms, neurons: cost.neurons };
642
+ } catch (error) {
643
+ return { ok: false, why: /took over/.test(String(error?.message ?? error)) ? 'slow' : 'error' };
644
+ }
645
+ }
646
+
647
+ /** 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). */
648
+ flushSoon() {
649
+ this.ctx.waitUntil((async () => { if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + 60_000); })().catch(() => {}));
650
+ }
651
+
652
+ /**
653
+ * One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
654
+ * budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
655
+ * floor alone has decided (`by` says which), and the message goes out.
656
+ */
657
+ async reviewChat(text, { links = 'block' } = {}) {
658
+ // Under `homie-studio dev` with no binding, the person's own Ollama reviews (free, so no budget).
659
+ if (!this.env.AI) {
660
+ if (!localAiOf(this.env)) return { ok: true, by: 'none' };
661
+ 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' }; }
662
+ }
663
+ if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
664
+ if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
665
+ try {
666
+ const v = await reviewChat(this.env, text, { links });
667
+ this.chatUsed += v.neurons;
668
+ this.chatPend.neurons += v.neurons;
669
+ this.chatPend[v.ok ? 'ok' : 'held'] += 1;
670
+ return { ok: v.ok, by: 'ai', why: v.why, ms: v.ms, p: v.p };
671
+ } catch (error) {
672
+ this.chatPend.error += 1;
673
+ try { console.log(JSON.stringify({ ev: 'chat-review-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ }
674
+ return { ok: true, by: 'error' };
675
+ }
676
+ }
677
+
678
+ async readChatDay() {
679
+ const day = await chatDay(this.env, today());
680
+ this.chatCap = day.budget.neurons;
681
+ this.chatUsed = day.used.neurons + this.chatPend.neurons;
682
+ this.chatReadAt = Date.now();
683
+ }
684
+
685
+ /** Chat's counts into D1 (stats_daily): lines, reactions, held by why, reviews and neurons. Never a message or a sender. */
686
+ async flushChat() {
687
+ const s = this.room?.stats;
688
+ if (!s || !this.env.DB) return;
689
+ const game = this.game ?? '';
690
+ const rows = [];
691
+ for (const [k, metric] of [['chatLines', 'chat-lines'], ['chatReacts', 'chat-reacts']]) {
692
+ const d = (s[k] ?? 0) - (this.chatSent[k] ?? 0);
693
+ if (d > 0) { rows.push(counter(this.env, { metric, subject: game, n: d })); this.chatSent[k] = s[k]; }
694
+ }
695
+ for (const [why, n] of Object.entries(s.chatHeld ?? {})) {
696
+ const d = n - (this.chatSent.held[why] ?? 0);
697
+ if (d > 0) { rows.push(counter(this.env, { metric: 'chat-held', subject: game, source: why, n: d })); this.chatSent.held[why] = n; }
698
+ }
699
+ const p = this.chatPend;
700
+ 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] }));
701
+ const neurons = Math.floor(p.neurons);
702
+ if (neurons) rows.push(counter(this.env, { metric: 'chat-neurons', subject: game, n: neurons }));
703
+ this.chatPend = { neurons: p.neurons - neurons, ok: 0, held: 0, error: 0 };
704
+ const list = rows.filter(Boolean);
705
+ if (list.length) await this.env.DB.batch(list).catch(() => {});
706
+ }
707
+
708
+ /** The game's agents.json from the build (the only words an AI in this room may say), once. */
709
+ async readVocab(game) {
710
+ if (!this.env.ASSETS || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(game ?? ''))) return false;
711
+ const res = await this.env.ASSETS.fetch(new Request(`https://assets.local/games/${game}/agents.json`));
712
+ if (!res.ok) return false;
713
+ const ok = this.room.setVocabulary(await res.json());
714
+ if (ok) this.syncHouse();
715
+ return ok;
716
+ }
717
+
718
+ /**
719
+ * The room's house guides (worker/agents.mjs): seated while the server's AI may talk, the game has a vocabulary and a
720
+ * person is playing; stood up otherwise. Called on the 1 s beat and whenever the policy changes. No brain runs here:
721
+ * decisions run on the alarm.
722
+ */
723
+ syncHouse() {
724
+ const room = this.room;
725
+ if (!room) return;
726
+ if (!this.house) {
727
+ if (!talks(room.policy) || !room.vocab || !(room.policy.guides > 0)) return;
728
+ this.house = new HouseAgents({
729
+ room, env: this.env,
730
+ // The room's own alarm (a paused server-hosted room ends on it) is never pushed back by a guide's.
731
+ setAlarm: (at) => { this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(() => {}); },
732
+ budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
733
+ log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
734
+ });
735
+ this.ctx.waitUntil(this.readBrainDay().catch(() => {}));
736
+ }
737
+ this.house.sync();
738
+ }
739
+
740
+ /** What the studio allows its AI brains a day (meta `brain_budget`) and has used today (stats_daily), all games. */
741
+ async readBrainDay() {
742
+ const db = this.env.DB;
743
+ if (!db) return;
744
+ const day = today();
745
+ try {
746
+ const b = JSON.parse((await db.prepare("SELECT value FROM meta WHERE key = 'brain_budget'").first())?.value ?? 'null');
747
+ if (b && Number.isFinite(Number(b.neurons))) this.brainCap.neurons = Math.max(0, Number(b.neurons));
748
+ if (b && Number.isFinite(Number(b.usd))) this.brainCap.micros = Math.max(0, Number(b.usd)) * 1e6;
749
+ } catch { /* the defaults */ }
750
+ try {
751
+ 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();
752
+ const used = { neurons: 0, micros: 0 };
753
+ for (const r of results ?? []) used[r.metric === 'brain-neurons' ? 'neurons' : 'micros'] = Number(r.n) || 0;
754
+ // What this room spent and has not written yet is added on top.
755
+ this.brainUsed = { neurons: used.neurons + this.brainPending.neurons, micros: used.micros + this.brainPending.micros };
756
+ } catch { /* before migration 0002: nothing used */ }
757
+ this.brainDay = day;
758
+ this.brainReadAt = Date.now();
759
+ }
760
+
761
+ brainSpend(c) {
762
+ if (this.brainDay && this.brainDay !== today()) { this.brainUsed = { neurons: 0, micros: 0 }; this.brainDay = today(); }
763
+ this.brainUsed.neurons += c.neurons ?? 0;
764
+ this.brainUsed.micros += c.micros ?? 0;
765
+ this.brainPending.neurons += c.neurons ?? 0;
766
+ this.brainPending.micros += c.micros ?? 0;
767
+ const k = c.provider ?? 'script';
768
+ this.brainPending.calls[k] = (this.brainPending.calls[k] ?? 0) + 1;
769
+ }
770
+
771
+ /**
772
+ * The day's brain counters into D1 (stats_daily: brain-calls by provider, brain-neurons, brain-microdollars), and the
773
+ * day's budget and usage read again (an owner's new budget, other rooms' use): at most once a minute, on the alarm.
774
+ */
775
+ async flushBrain(force = false) {
776
+ const p = this.brainPending;
777
+ if (!this.env.DB || (!force && Date.now() - this.brainReadAt < 60_000)) return;
778
+ if (!Object.keys(p.calls).length && p.neurons < 1 && p.micros < 1) { await this.readBrainDay(); return; }
779
+ const neurons = Math.floor(p.neurons);
780
+ const micros = Math.floor(p.micros);
781
+ const rows = [
782
+ ...Object.entries(p.calls).map(([source, n]) => counter(this.env, { metric: 'brain-calls', subject: this.game ?? '', source, n })),
783
+ neurons ? counter(this.env, { metric: 'brain-neurons', subject: this.game ?? '', n: neurons }) : null,
784
+ micros ? counter(this.env, { metric: 'brain-microdollars', subject: this.game ?? '', n: micros }) : null,
785
+ ].filter(Boolean);
786
+ this.brainPending = { calls: {}, neurons: p.neurons - neurons, micros: p.micros - micros };
787
+ if (rows.length) await this.env.DB.batch(rows).catch(() => {});
788
+ await this.readBrainDay();
789
+ }
790
+
791
+ /** The alarm: the house guides' brains decide (worker/agents.mjs), then the day's usage is written. */
792
+ async alarm() {
793
+ await this.flushRoundRecords();
794
+ await this.flushFunctionEvents();
795
+ // A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
796
+ 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 */ } }
797
+ try { await this.flushBrain(); } catch { /* counted next time */ }
798
+ try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
799
+ }
800
+
801
+ async flushCharacters() {
802
+ if (this.characterFlush) { await this.characterFlush; return; }
803
+ const saved = this.roomSave;
804
+ if (!saved) return;
805
+ const host = typeof saved.host === 'string' ? JSON.parse(saved.host) : saved.host;
806
+ const returns = (host.characters ?? []).filter(c => c.returning);
807
+ if (!returns.length) return;
808
+ this.characterFlush = (async () => {
809
+ for (const item of returns) {
810
+ await characterRequest(this.env, item.account, { op: 'return', game: this.game, holder: this.code, lease: item.lease, character: item.character });
811
+ this.hostRt?.characterReturned(item.account, item.lease);
812
+ if (this.characterGrants.get(item.account)?.lease === item.lease) this.characterGrants.delete(item.account);
813
+ }
814
+ })();
815
+ try { await this.characterFlush; } finally { this.characterFlush = null; }
816
+ }
817
+
818
+ // Network awaits may interleave another join or recall. Serialize each account
819
+ // within this room so a delayed acquisition cannot revive a returned lease.
820
+ async withCharacter(account, operation) {
821
+ this.characterOperations ??= new Map();
822
+ const previous = this.characterOperations.get(account) ?? Promise.resolve();
823
+ const current = previous.catch(() => {}).then(operation);
824
+ this.characterOperations.set(account, current);
825
+ try { return await current; }
826
+ finally { if (this.characterOperations.get(account) === current) this.characterOperations.delete(account); }
827
+ }
828
+
829
+ acquireCharacter(account) { return this.withCharacter(account, () => this.acquireCharacterOnce(account)); }
830
+
831
+ async acquireCharacterOnce(account) {
832
+ for (const c of [...this.room.clients.values()]) if (c.player === account && c.helloed) this.room.kick(c, 'character-moved', 4001);
833
+ await this.flushCharacters();
834
+ const body = { op: 'acquire', game: this.game, holder: this.code, schema: hostedBuild(this.game).stateHash };
835
+ let grant = await characterRequest(this.env, account, body);
836
+ if (grant.error === 'character-held') {
837
+ const old = this.env.TABLE.get(this.env.TABLE.idFromName(`${this.game}/${grant.holder}`));
838
+ const url = `https://room/__recall?game=${encodeURIComponent(this.game)}&room=${encodeURIComponent(grant.holder)}`;
839
+ const response = await old.fetch(url, { method: 'POST', body: JSON.stringify({ account }) });
840
+ if (!response.ok) throw new Error('Finishing your last session; its room is unavailable');
841
+ grant = await characterRequest(this.env, account, body);
842
+ }
843
+ if (!grant.ok) throw new Error(grant.error);
844
+ const held = { account, lease: grant.lease, seat: -1, character: grant.character };
845
+ this.characterGrants.set(account, held);
846
+ this.hostRt.reserveCharacter(held);
847
+ this.saveRoom(this.hostRt.save());
848
+ }
849
+
850
+ async fetch(request) {
851
+ if (new URL(request.url).pathname === '/__character') {
852
+ const body = await request.json();
853
+ this.characters ??= characterStore(this.ctx.storage);
854
+ if (body.op === 'acquire') return json(this.characters.acquire(body.game, body.holder, body.schema));
855
+ if (body.op === 'return') return json(this.characters.return(body.game, body.holder, body.lease, body.character));
856
+ return json({ ok: false }, 400);
857
+ }
858
+ const path = new URL(request.url).pathname;
859
+ if (path === '/__gate') {
860
+ // The door asks which Gate takes the next player (worker/gate.mjs, nextGate): the lowest with room, counting
861
+ // players sent to a Gate in the last ten seconds who have not reached it yet.
862
+ const gates = Number(new URL(request.url).searchParams.get('gates')) || 1, now = Date.now(), promised = new Map();
863
+ 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); }
864
+ const gate = nextGate(gates, this.gateHeld, promised, gatePlayers(this.env));
865
+ if (!this.gatePromised.has(gate)) this.gatePromised.set(gate, []);
866
+ this.gatePromised.get(gate).push(now + 10_000);
867
+ return json({ gate });
868
+ }
869
+ if (path === '/__multiplex') {
870
+ this.relayed = true;
871
+ const index = Number(new URL(request.url).searchParams.get('gate'));
872
+ // One encoding of each world for every Gate link (worker/world-wire.mjs).
873
+ return acceptMultiplex(request, (req, socket) => this.connect(req, socket), { source: (this.worldSource ??= worldSource()), onCount: (n) => {
874
+ // A player arrived: a promise to that Gate is kept. The Gate's count is what the next join is placed by.
875
+ if (n > (this.gateHeld.get(index) ?? 0)) this.gatePromised.get(index)?.shift();
876
+ if (n > 0) this.gateHeld.set(index, n); else this.gateHeld.delete(index);
877
+ } });
878
+ }
879
+ return this.connect(request);
880
+ }
881
+
882
+ async connect(request, transport = null) {
883
+ const url = new URL(request.url);
884
+ const game = url.searchParams.get('game');
885
+ const code = url.searchParams.get('room');
886
+ const max = seatCount(url.searchParams.get('max'));
887
+ if (url.pathname === '/__restart') {
888
+ if (this.env.HOMIE_PREVIEW !== '1' || request.method !== 'POST') return new Response('not found', { status: 404 });
889
+ // No last-chance save: this measures exactly what a real abrupt restart can lose.
890
+ setTimeout(() => this.ctx.abort('Preview forced restart'), 25);
891
+ return json({ ok: true });
892
+ }
893
+ const room = this.roomFor(game, code, max);
894
+ if (url.pathname === '/__recall') {
895
+ const { account } = await request.json();
896
+ return this.withCharacter(account, async () => {
897
+ const preparing = prepareHost(game);
898
+ if (preparing) await preparing;
899
+ this.ensureHost(room, true);
900
+ if (!this.hostRt || this.persistentFailed || room.hostFailed) return json({ ok: false }, 503);
901
+ await this.flushCharacters();
902
+ if (!this.hostRt.characters.has(account)) {
903
+ const grant = await characterRequest(this.env, account, { op: 'acquire', game, holder: code, schema: hostedBuild(game).stateHash });
904
+ if (!grant.ok) return json({ ok: true });
905
+ this.hostRt.reserveCharacter({ account, lease: grant.lease, seat: -1, character: grant.character });
906
+ }
907
+ this.hostRt.releaseCharacter(account);
908
+ for (const c of [...room.clients.values()]) if (c.player === account) room.kick(c, 'character-moved', 4001);
909
+ await this.flushCharacters();
910
+ return json({ ok: true });
911
+ });
912
+ }
913
+ // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
914
+ // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
915
+ if (url.pathname === '/__facts') return json({ ...room.officeFacts(), gates: [...(this.gateMetrics?.values() ?? [])], durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
916
+ // 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.
917
+ if (url.pathname === '/__chat') {
918
+ const id = url.searchParams.get('id');
919
+ // A line older than the window is in the room's kept history, when it keeps one (0.29.0).
920
+ const r = room.chatLog.find((x) => x.id === id) ?? (room.chatRules().history > 0 ? await keptLine(this.env, this.game, this.code, id) : null);
921
+ 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 });
922
+ }
923
+ if (url.pathname === '/__mcp-event') {
924
+ const ctl = await request.json().catch(() => null);
925
+ const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
926
+ 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);
927
+ if (!room.live().length) return json({ ok: false, error: 'room empty; use records for lasting changes' }, 409);
928
+ const event = { t: 'ev', from: null, k: ctl.args.event, d: ctl.args.data, external: true };
929
+ if (room.server) room.toServer(event);
930
+ for (const client of room.live()) room.send(client, event);
931
+ return json({ ok: true });
932
+ }
933
+ if (url.pathname === '/__mcp') {
934
+ const ctl = await request.json().catch(() => null);
935
+ const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
936
+ if (why || ctl.op !== 'mcp-seat') return json({ ok: false, error: 'refused' }, 403);
937
+ const result = await (await import('./mcp-room.mjs')).roomSeat(this, room, ctl);
938
+ return json(result, result.ok ? 200 : 403);
939
+ }
940
+ if (url.pathname === '/__office') {
941
+ const ctl = await request.json().catch(() => null);
942
+ const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
943
+ if (why) return json({ ok: false, error: 'refused', why }, 403);
944
+ // A Lounge card waits for the studio's review (0.29.0); every other control answers at once.
945
+ const res = await room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
946
+ // AI talk turned off (a policy) or a guide kicked: the house guides follow at once.
947
+ if (this.house) this.house.sync();
948
+ await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
949
+ if (ctl.op === 'close' && res.ok) this.tellLobby({ closed: ctl.args?.reopen ? 0 : res.until });
950
+ this.report();
951
+ return json(res);
952
+ }
953
+ room.rules = url.searchParams.get('rules') === '1';
954
+ room.perIp = perAddress(max, room.rules);
955
+ // Any tick rate a game may declare (1 to 60): the relay's allowances are the page's, from the same table.
956
+ const hz = Number(url.searchParams.get('hz'));
957
+ room.tickHz = Number.isInteger(hz) && hz >= 1 && hz <= 60 ? hz : 20;
958
+ // The owner's room size: a room already open takes it from the next visitor on.
959
+ if (room.seatCap !== max || room.rules && room.maxPlayers !== max) room.setSeats(max, perAddress(max, room.rules));
960
+ // Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
961
+ const said = url.searchParams.has('cur');
962
+ if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
963
+ // Restore before applying the Worker's current policy: a save must not replace a newer policy.
964
+ const preparing = prepareHost(game);
965
+ if (preparing) await preparing;
966
+ this.ensureHost(room, url.searchParams.get('host') === 'server');
967
+ // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
968
+ const pol = decodeFacts(url.searchParams.get('pol'));
969
+ // The room's chat rules ride beside it (they may be longer than a policy): section 19.
970
+ const chatRules = decodeFacts(url.searchParams.get('chat'), 16384);
971
+ if (pol && chatRules && typeof chatRules === 'object') pol.chat = chatRules;
972
+ if (pol) room.setPolicy(pol);
973
+ if (this.house) this.house.sync();
974
+ const agent = decodeFacts(url.searchParams.get('ag'));
975
+ // Kept chat (0.29.0): the room's history is back in its window before this page hears it.
976
+ await this.hydrateHistory(room);
977
+ const account = holders(url.searchParams.get('via')).find(p => p.startsWith('p-'))?.slice(2) ?? null;
978
+ if (hostedBuild(game)?.settings.characters && url.pathname !== '/__watch') {
979
+ if (!account || url.searchParams.get('acct') !== '1') return new Response('Sign in to keep your character', { status: 401 });
980
+ try { await this.acquireCharacter(account); }
981
+ catch (error) { return new Response(String(error.message), { status: 409 }); }
982
+ }
983
+ const [client, server] = transport ? [null, transport] : Object.values(new WebSocketPair());
984
+ server.accept();
985
+ const via = url.searchParams.get('via');
986
+ const conn = {
987
+ ip: request.headers.get('cf-connecting-ip'),
988
+ // The browser's room key (what a kick holds out), and who the play page's ticket named (o, i-<invite>, p-<player>).
989
+ browser: url.searchParams.get('b') || null,
990
+ via: via || null,
991
+ player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
992
+ // A socket opened through the game's watch door, and what the game lets watchers see (section 16).
993
+ watch: url.searchParams.get('w') === '1',
994
+ // An AI with a pass the Worker verified (section 17): its pass, role, hands and name. Only the Worker sets it.
995
+ ...(agent && typeof agent === 'object' && url.pathname === '/__net' ? { agent } : {}),
996
+ // A badge the player's account owns (the shop): the Worker looked it up; a hello cannot set one.
997
+ ...(url.pathname === '/__net' && /^[\p{L}\p{N} .'&+-]{1,16}$/u.test(url.searchParams.get('bd') ?? '') ? { badge: url.searchParams.get('bd') } : {}),
998
+ watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
999
+ // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
1000
+ qa: isQa(request),
1001
+ // The build this socket's page was served with (section 23), from the socket's own address: only when the
1002
+ // Worker speaks of builds at all (`cur`), so a relay behind an older Worker reads the hello's own.
1003
+ ...(said && url.pathname === '/__net' ? { ver: url.searchParams.get('gv') || null } : {}),
1004
+ // Room chat (section 19), the Worker's word: a signed-in account (a passkey), a member of this server, a page of
1005
+ // another site (homie.rocks's room page) watching from elsewhere.
1006
+ acct: url.searchParams.get('acct') === '1',
1007
+ member: url.searchParams.get('mem') === '1',
1008
+ hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
1009
+ // The Lounge (0.29.0): the signed-in account's name and a moderator's mark, the Worker's word on a watch socket.
1010
+ ...(url.pathname === '/__watch' && url.searchParams.get('nm') ? { name: String(url.searchParams.get('nm')).slice(0, 40) } : {}),
1011
+ ...(url.pathname === '/__watch' && url.searchParams.get('mod') === '1' ? { mod: true } : {}),
1012
+ send: (text) => { try { server.send(text); } catch { /* closed */ } },
1013
+ close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
1014
+ buffered: () => server.buffered?.() ?? 0,
1015
+ ...(server.snapshotGroup ? { snapshotGroup: server.snapshotGroup, subscribe: seat => server.subscribe(seat) } : {}),
1016
+ ...(server.snapshot ? { snapshot: (...args) => server.snapshot(...args), transportFacts: server.transportFacts } : {}),
1017
+ };
1018
+ if (url.pathname === '/__watch') {
1019
+ // Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
1020
+ // (room chat, 0.23.0: another site's page can open one too).
1021
+ const same = conn.ip ? [...room.watchers].filter((x) => x.ip === conn.ip).length : 0;
1022
+ 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 }); }
1023
+ const w = room.watch(conn);
1024
+ // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
1025
+ server.addEventListener('message', (e) => {
1026
+ if (typeof e.data !== 'string') return;
1027
+ try { w.onMessage(e.data); } catch (error) { this.say({ ev: 'failed', op: 'watch-message', error: errorLine(error) }); }
1028
+ });
1029
+ server.addEventListener('close', () => w.onClose());
1030
+ server.addEventListener('error', (e) => { this.socketError('watch', e); w.onClose(); });
1031
+ } else {
1032
+ const h = room.attach(conn);
1033
+ // A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
1034
+ // seats, so it is not counted twice). One counter write per room.
1035
+ const fresh = room.seats.size === 0;
1036
+ server.addEventListener('message', (e) => {
1037
+ this.relayFrame = Boolean(server.snapshot);
1038
+ try {
1039
+ let text = typeof e.data === 'string' ? e.data : '';
1040
+ if (hostedBuild(game)?.settings.characters && account) {
1041
+ 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 */ }
1042
+ }
1043
+ if (Number.isFinite(e.receivedAt)) this.inputTransit.push(Math.max(0, Date.now() - e.receivedAt));
1044
+ h.onMessage(text, this.relayFrame ? e.receivedAt : undefined);
1045
+ if (hostedBuild(game)?.settings.characters && account) { const joined = room.clients.get(h.id); if (joined?.token) this.accountTokens[account] = joined.token; }
1046
+ } finally { this.relayFrame = false; }
1047
+ // A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
1048
+ if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.scheduleReport();
1049
+ if (fresh && !this.openCounted && room.seats.size > 0) {
1050
+ this.openCounted = true;
1051
+ if (conn.qa) return;
1052
+ this.ctx.waitUntil(count(this.env, null, { metric: 'room', subject: this.game ?? '', source: /^pub-\d+$/.test(this.code ?? '') ? 'public' : 'named' }));
1053
+ }
1054
+ });
1055
+ // An AI's time in a seat, counted once as it leaves (stats `agent-minutes`; never for house QA).
1056
+ const left = (via) => {
1057
+ const c = room.clients.get(h.id);
1058
+ if (c?.agent && c.helloed && c.seat !== null && !conn.qa) {
1059
+ const minutes = Math.round((Date.now() - c.joinedAt) / 60_000);
1060
+ if (minutes > 0) this.ctx.waitUntil(count(this.env, null, { metric: 'agent-minutes', subject: this.game ?? '', source: c.agent.role, n: minutes }).catch(() => {}));
1061
+ }
1062
+ // The room says the departure itself (its `leave` line: who, which seat, host or not, how many are left).
1063
+ h.onClose(via);
1064
+ try { this.scheduleReport(); } catch (error) { this.say({ ev: 'failed', op: 'report', error: errorLine(error) }); }
1065
+ };
1066
+ server.addEventListener('close', () => left('close'));
1067
+ // A lost network arrives here, not as a close: said as what it is, with the client it was, then handled as the
1068
+ // departure it is. (An error with a listener is never the runtime's "Uncaught".)
1069
+ server.addEventListener('error', (e) => {
1070
+ const c = room.clients.get(h.id);
1071
+ this.socketError('net', e, { id: h.id, seat: c?.seat ?? null, role: c && c.helloed ? room.roleOf(c) : null });
1072
+ left('error');
1073
+ });
1074
+ }
1075
+ this.start();
1076
+ return transport ? null : new Response(null, { status: 101, webSocket: client });
1077
+ }
1078
+
1079
+ start() {
1080
+ if (this.timer) return;
1081
+ let n = 0;
1082
+ this.timer = setInterval(() => {
1083
+ const room = this.room;
1084
+ if (!room) return;
1085
+ // A beat that throws is one failed beat, said with its room; the next one still runs.
1086
+ try { room.tick(); } catch (error) { this.say({ ev: 'tick-failed', clients: room.clients.size, error: errorLine(error) }); }
1087
+ // A room that just opened reads its game's launch state once: a change made in the moment it opened (before the
1088
+ // Lobby knew of it) still reaches it, after the current round as always.
1089
+ if (room.openedAt && this.launchReadFor !== room.openedAt) { this.launchReadFor = room.openedAt; this.ctx.waitUntil(this.rereadLaunch().catch(() => {})); }
1090
+ // A launch change the room applied by itself (after its round): its stored state says so too.
1091
+ if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
1092
+ n += 1;
1093
+ if (n % 4 === 0) { this.publishDiagnostics(); room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
1094
+ // Room chat's counts, once a minute (section 19).
1095
+ if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
1096
+ // A server-hosted room in play keeps its alarm ahead of it (re-armed every five minutes of play).
1097
+ if (n % 1200 === 0 && this.hostRt?.running) this.armRoom(Date.now() + ROOM_ALARM_MS);
1098
+ if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
1099
+ 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; } }
1100
+ }, 250);
1101
+ }
1102
+
1103
+ /**
1104
+ * The game's launch state, read once when the room opens (one D1 read): a private or invite-only game whose room
1105
+ * holds anyone the state leaves out re-gates after the current round. Never in a Preview (no launch states there).
1106
+ */
1107
+ async rereadLaunch() {
1108
+ const room = this.room;
1109
+ if (!room || !this.game || !this.env.DB || this.env.HOMIE_PREVIEW === '1' || room.regate) return null;
1110
+ let launch = null;
1111
+ try { launch = (await this.env.DB.prepare('SELECT launch FROM office_games WHERE game = ?1').bind(this.game).first())?.launch ?? null; } catch { return null; }
1112
+ const args = regateArgs(launch);
1113
+ if (!args.allow) return null;
1114
+ const res = room.control('regate', args);
1115
+ if (res.ok && res.leaving) await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
1116
+ return res;
1117
+ }
1118
+
1119
+ /** Tell the Lobby this room closed (until when; 0 when it opened again), so it sends nobody here meanwhile. */
1120
+ tellLobby(extra) {
1121
+ if(extra.closed || extra.ended) this.queueFunctionEvent('room.closed',{game:this.game,room:this.code,...extra});
1122
+ if (!this.game || !this.code) return;
1123
+ const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
1124
+ this.lastReport = -1;
1125
+ 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(() => {}));
1126
+ }
1127
+
1128
+ /**
1129
+ * A big room's diagnostics leave it once a second for the object that answers readers (worker/gate.mjs,
1130
+ * `diagnosticsFetch`): nothing per player, one post outstanding at most, never awaited by a tick or an admission.
1131
+ * A room that every player reaches directly (one Gate's worth of seats) answers for itself and posts nothing.
1132
+ */
1133
+ publishDiagnostics() {
1134
+ const transit = this.inputTransit.sort((a, b) => a - b); this.inputTransit = [];
1135
+ if (!this.env.GATE || !this.relayed || this.diagnosticsPending || !this.room) return;
1136
+ const facts = this.room.diagnosticFacts(), now = Date.now(), last = this.diagnosticsLast;
1137
+ const at = (p) => transit.length ? transit[Math.min(transit.length - 1, Math.floor(transit.length * p))] : null;
1138
+ const tick = facts.ticks?.tick ?? null;
1139
+ this.diagnosticsLast = { now, tick };
1140
+ this.diagnosticsPending = true;
1141
+ const body = JSON.stringify({
1142
+ ...facts,
1143
+ // Ticks the rules ran per second of this object's clock since the last post.
1144
+ tickRate: last && tick !== null && last.tick !== null && now > last.now ? Math.round((tick - last.tick) / (now - last.now) * 100000) / 100 : null,
1145
+ // A Gate's stamp on a player's message to this object handling it, ms.
1146
+ inputTransitMs: { n: transit.length, p50: at(0.5), p95: at(0.95), max: transit.at(-1) ?? null },
1147
+ durability: this.storageHealth ?? { ok: true },
1148
+ // Checkpoints written without holding the room's frames for them (worker/room-store.mjs), so far.
1149
+ save: { unconfirmed: this.durable?.unconfirmedWrites ?? 0 },
1150
+ });
1151
+ const work = diagnosticsOf(this.env, this.game, this.code).fetch('https://diagnostics/__diagnostics', { method: 'POST', body })
1152
+ .then((r) => r.body?.cancel?.())
1153
+ .catch((error) => { this.say({ ev: 'diagnostics-publish-failed', error: errorLine(error) }); })
1154
+ .finally(() => { this.diagnosticsPending = false; });
1155
+ this.ctx.waitUntil(work);
1156
+ }
1157
+
1158
+ scheduleReport() {
1159
+ if (this.reportQueued) return;
1160
+ this.reportQueued = true;
1161
+ queueMicrotask(() => { this.reportQueued = false; this.report(); });
1162
+ }
1163
+
1164
+ /** Tell the Lobby how many players this room has, when it changes. */
1165
+ report() {
1166
+ const room = this.room;
1167
+ if (!room || !this.game || this.endedRoom) return;
1168
+ const counts = room.facts().counts;
1169
+ // Players are people (an agent never counts as one, DESIGN D9); agents and AI bodies are reported apart.
1170
+ const players = counts.players;
1171
+ // AIs with a pass (a house guide makes way for one, so the Lobby counts only these when it seats an AI).
1172
+ let agents = 0;
1173
+ for (const c of room.clients.values()) if (c.helloed && c.agent && c.seat !== null && !c.conn?.loopback) agents += 1;
1174
+ const ai = counts.ai ?? 0;
1175
+ // People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
1176
+ let people = 0;
1177
+ for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.agent && !c.conn?.qa) people += 1;
1178
+ // On a change, and once a minute while anybody is here (so the Lobby's "playing now" forgets a room that died).
1179
+ if (players === this.lastReport && people === this.lastPeople && agents === this.lastAgents && ai === this.lastAi && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
1180
+ this.lastReport = players;
1181
+ this.lastPeople = people;
1182
+ this.lastAgents = agents;
1183
+ this.lastAi = ai;
1184
+ this.lastReportAt = Date.now();
1185
+ const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
1186
+ // The build this room runs (section 23): the Lobby sends a visitor only to a room of the live build.
1187
+ const ver = room.gameVer === undefined ? undefined : room.gameVer ?? '';
1188
+ 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(() => {}));
1189
+ }
1190
+
1191
+ /** A round the host called over becomes one D1 row (once per round number). */
1192
+ recordRound() {
1193
+ const r = this.room?.lastRound;
1194
+ if (!r || r.phase !== 'over' || !Number.isFinite(r.n) || r.n <= this.recorded || !this.env.DB) return;
1195
+ this.recorded = r.n;
1196
+ this.ctx.storage.put('recorded', r.n).catch(() => {});
1197
+ const results = Array.isArray(r.results) ? r.results : [];
1198
+ // An AI's row is never a person's (the relay labelled it `agent`): rounds count agents as AI.
1199
+ const humans = results.filter((row) => row && !row.bot && !row.agent).length;
1200
+ // A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
1201
+ const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null && !c.agent).every((c) => c.conn?.qa);
1202
+ const db = this.env.DB;
1203
+ const insert = db.prepare('INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
1204
+ .bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
1205
+ // The round and the people in it, on the studio's daily counters (worker/stats.mjs). One batch, per round.
1206
+ 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);
1207
+ this.ctx.waitUntil(db.batch([insert, ...counters]).catch(() => insert.run().catch(() => {})));
1208
+ }
1209
+ }
1210
+
1211
+ export {Table};
1212
+