@homie-rocks/studio 0.55.0 → 0.57.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 (72) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -0
  3. package/apps/APPS.md +9 -0
  4. package/apps/apps.ts +19 -1
  5. package/bin/homie-studio.mjs +1 -1
  6. package/docs/custom-routes.md +72 -0
  7. package/docs/studio-to-studio.md +248 -0
  8. package/functions/FUNCTIONS.md +9 -0
  9. package/guides/app/SKILL.md +7 -0
  10. package/guides/playtest/scripts/playtest.mjs +7 -5
  11. package/lib/check.mjs +8 -1
  12. package/lib/cloudflare.mjs +52 -22
  13. package/lib/dev.mjs +6 -8
  14. package/lib/local-database-start.mjs +6 -0
  15. package/lib/mcp-tools.mjs +3 -3
  16. package/lib/needs-headless.mjs +1 -1
  17. package/lib/port-check.mjs +3 -2
  18. package/lib/scaffold.mjs +2 -0
  19. package/lib/studios-keys.mjs +13 -0
  20. package/lib/template-history.json +88 -1
  21. package/lib/worker-build.mjs +4 -1
  22. package/lib/worker-config.mjs +7 -0
  23. package/netplay/NETPLAY.md +12 -1
  24. package/netplay/netplay.ts +24 -9
  25. package/package.json +1 -1
  26. package/rules/core.ts +9 -0
  27. package/rules/host.ts +40 -11
  28. package/rules/view.ts +1 -0
  29. package/tools/TOOLS.md +22 -1
  30. package/tools/tools.ts +14 -1
  31. package/worker/background-queue.mjs +1 -0
  32. package/worker/booking-availability.mjs +35 -0
  33. package/worker/booking-holds.mjs +2 -2
  34. package/worker/booking-resources.mjs +14 -6
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/business-events.mjs +2 -1
  37. package/worker/custom-routes.mjs +16 -0
  38. package/worker/gate.mjs +94 -23
  39. package/worker/idle-chat.mjs +56 -0
  40. package/worker/index.mjs +2 -0
  41. package/worker/lazy-objects.mjs +3 -0
  42. package/worker/lobby-peak.mjs +44 -0
  43. package/worker/lobby-shards.mjs +43 -0
  44. package/worker/lobby-store.mjs +35 -0
  45. package/worker/lobby-summary.mjs +28 -0
  46. package/worker/lobby.mjs +86 -18
  47. package/worker/lounge-store.mjs +2 -2
  48. package/worker/match-router.mjs +74 -0
  49. package/worker/mcp-room.mjs +2 -1
  50. package/worker/mcp-store.mjs +3 -1
  51. package/worker/mcp-tools.mjs +29 -4
  52. package/worker/mcp.mjs +43 -8
  53. package/worker/office.mjs +2 -1
  54. package/worker/operational-schema.mjs +2 -0
  55. package/worker/pages.mjs +1 -1
  56. package/worker/relay-channel.mjs +119 -0
  57. package/worker/request-db.mjs +4 -3
  58. package/worker/room-chat.mjs +2 -1
  59. package/worker/room-sessions.mjs +65 -0
  60. package/worker/room.mjs +45 -9
  61. package/worker/route-auth.mjs +22 -0
  62. package/worker/routes.mjs +8 -5
  63. package/worker/runtime-support.mjs +5 -3
  64. package/worker/session-codec.mjs +24 -0
  65. package/worker/stats.mjs +2 -1
  66. package/worker/studios-auth.mjs +71 -0
  67. package/worker/studios-client.mjs +93 -0
  68. package/worker/studios-events.mjs +52 -0
  69. package/worker/studios-keys.mjs +54 -0
  70. package/worker/table.mjs +277 -41
  71. package/worker/version.mjs +1 -1
  72. package/worker/watch-sockets.mjs +75 -0
package/worker/table.mjs CHANGED
@@ -1,4 +1,8 @@
1
- /** Extracted from the studio runtime; behavior is unchanged. */
1
+ import {lobbyFor} from './lobby-shards.mjs';
2
+ import {relayStream} from './relay-channel.mjs';
3
+ import {roomSessions,restoreRoomFields} from './room-sessions.mjs';
4
+ import {idleChatSnapshot,restoreIdleChat,readIdleChat,writeIdleChat,expireIdleChat} from './idle-chat.mjs';
5
+ import {WatchSockets} from './watch-sockets.mjs';
2
6
  import {RULES_WIRING_MESSAGE} from './rules-wiring.mjs';
3
7
  import {characterStore,characterRequest} from './characters.mjs';
4
8
  import {NetRoom,WATCH_POLICIES,departure,errorLine} from './room.mjs';
@@ -10,18 +14,21 @@ import {CLEF,DECIDE,clefRun,costOf,localAiOf,picksOf,talks} from './brain.mjs';
10
14
  import {reviewChat} from './chat.mjs';
11
15
  import {chatDay} from './chat-store.mjs';
12
16
  import {forgetLines,historyOf,keepLines,keptLine} from './lounge-store.mjs';
13
- import {acceptMultiplex,diagnosticsOf,gatePlayers,nextGate} from './gate.mjs';
17
+ import {multiplexSession,acceptMultiplex,diagnosticsOf,gatePlayers,nextGate} from './gate.mjs';
14
18
  import {worldSource} from './world-wire.mjs';
15
19
  import {seatCount,perAddress} from './seats.mjs';
16
20
  import {count,counter,isQa,today} from './stats.mjs';
17
21
  import {holders,regateArgs,verifyControl} from './office.mjs';
22
+
18
23
  import {json} from './responses.mjs';
19
24
  import {catalogue} from './catalogue.mjs';
25
+
20
26
  import {ROOM_ALARM_MS,ROOM_PAUSE_MS,roomEndAt} from './room-lifecycle.mjs';
21
27
 
22
- class Table {
28
+ export class Table {
23
29
  constructor(ctx, env) {
24
30
  this.ctx = ctx;
31
+ this.watchSockets = new WatchSockets(ctx);
25
32
  this.env = env;
26
33
  this.room = null;
27
34
  this.timer = null;
@@ -59,6 +66,8 @@ class Table {
59
66
  this.accountTokens = {};
60
67
  this.inputTransit = [];
61
68
  this.relayed = false;
69
+ this.relays = new Map();
70
+ this.relayEpoch = crypto.randomUUID();
62
71
  this.gateHeld = new Map();
63
72
  this.gatePromised = new Map();
64
73
  this.roomAlarmAt = 0;
@@ -71,6 +80,18 @@ class Table {
71
80
  this.recoveredAlarmAt = Infinity;
72
81
  try { this.recoveredAlarmAt = (await ctx.storage.getAlarm?.()) ?? null; }
73
82
  catch (error) { this.storageProblem(error); }
83
+ this.hibernationReady=true;
84
+ try {
85
+ this.sessionStore=roomSessions(ctx.storage);
86
+ this.sessionSnapshot=this.sessionStore.read();
87
+ this.idleChat=(await readIdleChat(ctx.storage))??null;
88
+ if(this.sessionSnapshot)Object.assign(this,this.sessionSnapshot.table);
89
+ } catch(error) {
90
+ // Optional session state must not bypass the existing host-storage
91
+ // failure response and strand a player before its socket is handled.
92
+ this.hibernationReady=false;this.sessionStore=null;this.sessionSnapshot=null;this.idleChat=null;
93
+ this.storageProblem(error);
94
+ }
74
95
  this.saved = (await ctx.storage.get('net')) ?? null;
75
96
  if (this.saved?.retired === true) this.cleanupPending = true;
76
97
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
@@ -78,6 +99,28 @@ class Table {
78
99
  // A big match room's last unconfirmed checkpoint (worker/room-store.mjs), read once as the object wakes.
79
100
  try { this.looseSave = (await ctx.storage.list?.({ prefix: LOOSE_SAVE })) ?? null; }
80
101
  catch (error) { this.looseSave = null; this.storageProblem(error); }
102
+ this.restoringSockets=true;
103
+ await this.watchSockets.restore((request, transport, attachment) => {
104
+ if(!this.hibernationReady){transport.close(1011,"room storage unavailable");return;}
105
+ if(attachment.clientId) {
106
+ transport.resume=this.sessionSnapshot?.clients.get(attachment.clientId);
107
+ if(!transport.resume){transport.close(1012,'room session restarted');return;}
108
+ }
109
+ return this.connect(request, transport);
110
+ });
111
+ // The last socket can close while an office write is being retried. An
112
+ // alarm must restore that room even when there is no transport to attach.
113
+ if(!this.room&&this.sessionSnapshot&&this.game&&this.code)this.roomFor(this.game,this.code,this.sessionSnapshot.fields.get('seatCap')??8);
114
+ for (const descriptor of this.relayDescriptors ?? []) {
115
+ this.relayed = true;
116
+ const relay = this.relayFor(new URL(descriptor.url), descriptor);
117
+ relay.socket.emit('message', {data:JSON.stringify(descriptor.clients.map(([id, clientId, url, ip, agent]) => ['open',id,url,ip,agent]))});
118
+ await relay.session.settled();
119
+ }
120
+ this.restoringSockets=false;
121
+ if(this.sessionSnapshot)this.say({ev:"sessions-restored",clients:this.room?.clients.size??0});
122
+ this.sessionSnapshot=null;
123
+ if(this.room)this.start();
81
124
  });
82
125
  }
83
126
 
@@ -104,7 +147,7 @@ class Table {
104
147
  if (this.hostRt === host) { try { this.saveRoom(host.save()); } catch (error) { this.storageProblem(error); } }
105
148
  });
106
149
  }
107
- } else if (!hostedGame(game)) storage.put('net', o).catch(() => {});
150
+ } else if (!hostedGame(game)) storage.put('net', o).catch(error => this.storageProblem(error));
108
151
  },
109
152
  clear: () => { this.clearRoom(); },
110
153
  },
@@ -113,9 +156,16 @@ class Table {
113
156
  // are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
114
157
  log: (line) => this.say(line),
115
158
  });
159
+
160
+ this.room.waitUntil=work=>this.ctx.waitUntil(Promise.resolve(work).finally(()=>this.keepIdleChat()));
116
161
  this.room.gateFacts = () => [...(this.gateMetrics?.values() ?? [])];
117
162
  if (this.saved && !hostedGame(game)) this.room.restore(this.saved);
118
- if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
163
+ restoreRoomFields(this.room,this.sessionSnapshot);
164
+ // A failed office write remains in the session snapshot for retry. Otherwise
165
+ // the separate office record also covers changes made while no socket exists.
166
+ if (this.officeSaved && !this.room.officeDirty) this.room.restoreOffice(this.officeSaved);
167
+ restoreIdleChat(this,this.idleChat);
168
+ this.idleChat=null;
119
169
  this.vocabRead = this.readVocab(game).catch(() => false);
120
170
  // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
121
171
  this.room.review = (text, opts) => this.reviewChat(text, opts);
@@ -204,6 +254,9 @@ class Table {
204
254
  const account = room.clients.get(peer.id)?.player;
205
255
  return account ? this.characterGrants.get(account) ?? null : null;
206
256
  } } : {}),
257
+ // Alarm-driven suspension requires the native hibernation lifecycle.
258
+ // Legacy adapters keep their host clock, as browser hosts do.
259
+ ...(this.ctx.acceptWebSocket ? { onRoundIdle: until => { room.tellWatchers(); this.report(); this.recordRound(); this.armRoom(until); this.ctx.waitUntil(this.sleepRoom()); } } : {}),
207
260
  onTick: () => {
208
261
  if (!saved) return;
209
262
  ticks += 1;
@@ -340,7 +393,7 @@ class Table {
340
393
  if (this.isPersistent()) throw new Error('A lasting world cannot be cleared by match cleanup');
341
394
  this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
342
395
  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; }
396
+ try { this.sessionStore?.clear(); this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
344
397
  catch (error) { this.retryCleanup(error); }
345
398
  this.queueNetCleanup(this.cleanupPending);
346
399
  }
@@ -498,7 +551,7 @@ class Table {
498
551
  this.finishRoom('nobody-returned');
499
552
  }
500
553
  }
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 }); }
554
+ if (this.saved?.hosted === 'server') { const was = this.saved.room ?? null; this.saved = null; await this.ctx.storage.delete('net'); this.say({ ev: 'host-over', why: 'nobody-returned', room: was }); }
502
555
  return;
503
556
  }
504
557
  if (!this.hostRt) return;
@@ -532,7 +585,7 @@ class Table {
532
585
  const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
533
586
  if(!player){
534
587
  // 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{}
588
+ 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{/* Logging must not mask the missing-player recovery path. */}
536
589
  if(client)this.room.error(client,'unsaved','This round was not saved to your history. Reload the page to keep the next one.');
537
590
  return;
538
591
  }
@@ -625,7 +678,7 @@ class Table {
625
678
  const local = this.env.AI ? null : localAiOf(this.env);
626
679
  if (!this.env.AI && !local) return { ok: false, why: 'no-ai' };
627
680
  if (this.env.AI) {
628
- if (!this.brainDay || Date.now() - this.brainReadAt > 60_000) await this.readBrainDay().catch(() => {});
681
+ if (!this.brainDay || Date.now() - this.brainReadAt > 60_000) await this.readBrainDay().catch(error => this.say({ev:'background-failed',error:errorLine(error)}));
629
682
  if (this.brainCap.neurons - this.brainUsed.neurons <= 0) return { ok: false, why: 'budget' };
630
683
  }
631
684
  const model = CLEF.models[this.env.HOMIE_DECIDE_MODEL] ? this.env.HOMIE_DECIDE_MODEL : '@cf/cloudflare/clef-flash';
@@ -646,7 +699,7 @@ class Table {
646
699
 
647
700
  /** 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
701
  flushSoon() {
649
- this.ctx.waitUntil((async () => { if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + 60_000); })().catch(() => {}));
702
+ this.ctx.waitUntil((async () => { if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + 60_000); })().catch(error => this.say({ev:'background-failed',error:errorLine(error)})));
650
703
  }
651
704
 
652
705
  /**
@@ -660,7 +713,7 @@ class Table {
660
713
  if (!localAiOf(this.env)) return { ok: true, by: 'none' };
661
714
  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
715
  }
663
- if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
716
+ if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(error => this.say({ev:'background-failed',error:errorLine(error)}));
664
717
  if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
665
718
  try {
666
719
  const v = await reviewChat(this.env, text, { links });
@@ -683,10 +736,18 @@ class Table {
683
736
  }
684
737
 
685
738
  /** Chat's counts into D1 (stats_daily): lines, reactions, held by why, reviews and neurons. Never a message or a sender. */
686
- async flushChat() {
739
+ flushChat() {
740
+ if (this.chatFlushAt && Date.now()-this.chatFlushAt<60_000) return Promise.resolve();
741
+ if (this.chatFlush) return this.chatFlush;
742
+ this.chatFlush = this.flushChatPending().then(() => { this.chatFlushAt=Date.now(); }).finally(() => { this.chatFlush = null; });
743
+ return this.chatFlush;
744
+ }
745
+
746
+ async flushChatPending() {
687
747
  const s = this.room?.stats;
688
748
  if (!s || !this.env.DB) return;
689
749
  const game = this.game ?? '';
750
+ const sent = structuredClone(this.chatSent);
690
751
  const rows = [];
691
752
  for (const [k, metric] of [['chatLines', 'chat-lines'], ['chatReacts', 'chat-reacts']]) {
692
753
  const d = (s[k] ?? 0) - (this.chatSent[k] ?? 0);
@@ -702,7 +763,14 @@ class Table {
702
763
  if (neurons) rows.push(counter(this.env, { metric: 'chat-neurons', subject: game, n: neurons }));
703
764
  this.chatPend = { neurons: p.neurons - neurons, ok: 0, held: 0, error: 0 };
704
765
  const list = rows.filter(Boolean);
705
- if (list.length) await this.env.DB.batch(list).catch(() => {});
766
+ try { if (list.length) await this.env.DB.batch(list); }
767
+ catch (error) {
768
+ this.chatSent = sent;
769
+ this.chatPend.neurons += neurons;
770
+ for (const k of ['ok', 'held', 'error']) this.chatPend[k] += p[k];
771
+ this.say({ev: 'chat-counter-retry', error: errorLine(error)});
772
+ throw error;
773
+ }
706
774
  }
707
775
 
708
776
  /** The game's agents.json from the build (the only words an AI in this room may say), once. */
@@ -728,11 +796,11 @@ class Table {
728
796
  this.house = new HouseAgents({
729
797
  room, env: this.env,
730
798
  // 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(() => {}); },
799
+ setAlarm: (at) => { this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(error => this.say({ev:'background-failed',error:errorLine(error)})); },
732
800
  budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
733
801
  log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
734
802
  });
735
- this.ctx.waitUntil(this.readBrainDay().catch(() => {}));
803
+ this.ctx.waitUntil(this.readBrainDay().catch(error => this.say({ev:'background-failed',error:errorLine(error)})));
736
804
  }
737
805
  this.house.sync();
738
806
  }
@@ -772,7 +840,13 @@ class Table {
772
840
  * The day's brain counters into D1 (stats_daily: brain-calls by provider, brain-neurons, brain-microdollars), and the
773
841
  * day's budget and usage read again (an owner's new budget, other rooms' use): at most once a minute, on the alarm.
774
842
  */
775
- async flushBrain(force = false) {
843
+ flushBrain(force = false) {
844
+ if (this.brainFlush) return this.brainFlush;
845
+ this.brainFlush = this.flushBrainPending(force).finally(() => { this.brainFlush = null; });
846
+ return this.brainFlush;
847
+ }
848
+
849
+ async flushBrainPending(force = false) {
776
850
  const p = this.brainPending;
777
851
  if (!this.env.DB || (!force && Date.now() - this.brainReadAt < 60_000)) return;
778
852
  if (!Object.keys(p.calls).length && p.neurons < 1 && p.micros < 1) { await this.readBrainDay(); return; }
@@ -784,18 +858,45 @@ class Table {
784
858
  micros ? counter(this.env, { metric: 'brain-microdollars', subject: this.game ?? '', n: micros }) : null,
785
859
  ].filter(Boolean);
786
860
  this.brainPending = { calls: {}, neurons: p.neurons - neurons, micros: p.micros - micros };
787
- if (rows.length) await this.env.DB.batch(rows).catch(() => {});
861
+ try { if (rows.length) await this.env.DB.batch(rows); }
862
+ catch (error) {
863
+ this.brainPending.neurons += neurons;
864
+ this.brainPending.micros += micros;
865
+ for (const [source, n] of Object.entries(p.calls)) this.brainPending.calls[source] = (this.brainPending.calls[source] ?? 0) + n;
866
+ this.say({ev: 'brain-counter-retry', error: errorLine(error)});
867
+ throw error;
868
+ }
788
869
  await this.readBrainDay();
789
870
  }
790
871
 
791
872
  /** The alarm: the house guides' brains decide (worker/agents.mjs), then the day's usage is written. */
792
873
  async alarm() {
874
+ this.hostRt?.wakeRound();
875
+ if (this.hostRt?.running) this.start();
876
+ for (const relay of this.relays.values()) {
877
+ try {
878
+ const url = new URL(relay.url); url.pathname='/__relay-presence';
879
+ const key = `${url.searchParams.get('game')}/${url.searchParams.get('room')}/${url.searchParams.get('gate')}`;
880
+ const response = await this.env.GATE.get(this.env.GATE.idFromName(key)).fetch(url);
881
+ if (!response.ok) throw new Error('Gate presence unavailable');
882
+ const presence = new Map(await response.json());
883
+ relay.pings = presence;
884
+ for (const [id, clientId] of relay.clients.values()) {
885
+ const client = this.room?.clients.get(clientId);
886
+ if (client && presence.has(id)) client.lastSeen = Math.max(client.lastSeen,presence.get(id));
887
+ }
888
+ } catch (error) { this.say({ev:'gate-presence-failed',error:errorLine(error)}); }
889
+ }
890
+ if (this.room && (!this.room.clients.size || this.canSleep())) { this.room.tick(Boolean(this.canSleep())); this.room.tellWatchers(); this.report(); }
793
891
  await this.flushRoundRecords();
794
892
  await this.flushFunctionEvents();
795
893
  // A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
796
894
  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
895
  try { await this.flushBrain(); } catch { /* counted next time */ }
896
+ try { await this.keepIdleChat(); } catch (error) { this.say({ev:"chat-flush-failed",error:errorLine(error)}); }
798
897
  try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
898
+ if(this.hibernationReady)await expireIdleChat(this.ctx.storage);
899
+ if(this.canSleep())await this.sleepRoom();
799
900
  }
800
901
 
801
902
  async flushCharacters() {
@@ -820,7 +921,7 @@ class Table {
820
921
  async withCharacter(account, operation) {
821
922
  this.characterOperations ??= new Map();
822
923
  const previous = this.characterOperations.get(account) ?? Promise.resolve();
823
- const current = previous.catch(() => {}).then(operation);
924
+ const current = previous.catch(() => { /* The previous caller observed its failure; the next operation must still run. */ }).then(operation);
824
925
  this.characterOperations.set(account, current);
825
926
  try { return await current; }
826
927
  finally { if (this.characterOperations.get(account) === current) this.characterOperations.delete(account); }
@@ -848,6 +949,10 @@ class Table {
848
949
  }
849
950
 
850
951
  async fetch(request) {
952
+ try { return await this.fetchRequest(request); }
953
+ finally { if(this.canSleep())await this.sleepRoom(); }
954
+ }
955
+ async fetchRequest(request) {
851
956
  if (new URL(request.url).pathname === '/__character') {
852
957
  const body = await request.json();
853
958
  this.characters ??= characterStore(this.ctx.storage);
@@ -866,6 +971,42 @@ class Table {
866
971
  this.gatePromised.get(gate).push(now + 10_000);
867
972
  return json({ gate });
868
973
  }
974
+ if (path === '/__relay-live') {
975
+ if(this.room && this.canSleep())return new Response(null,{status:204});
976
+ this.relayed=true;
977
+ const relay=this.relayFor(new URL(request.url));
978
+ const epoch=request.headers.get('x-homie-relay-epoch');
979
+ if(relay.remoteEpoch!==epoch){relay.remoteEpoch=epoch;relay.inputSequence=0;}
980
+ relay.session.reset();
981
+ const [client,server]=Object.values(new WebSocketPair());server.accept();
982
+ server.addEventListener('message',event=>this.ctx.waitUntil(this.relayInput(relay,event.data)));
983
+ relay.socket.attachLive(server);
984
+ return new Response(null,{status:101,webSocket:client,headers:{'x-homie-relay-epoch':this.relayEpoch}});
985
+ }
986
+ if (path === '/__relay-stream') {
987
+ this.relayed=true;
988
+ const relay=this.relayFor(new URL(request.url));
989
+ const epoch=request.headers.get('x-homie-relay-epoch');
990
+ if(relay.remoteEpoch!==epoch){relay.remoteEpoch=epoch;relay.inputSequence=0;}
991
+ relay.session.reset();
992
+ const response=new Response(relay.socket.open(),{headers:{'content-type':'application/x-ndjson','x-homie-relay-epoch':this.relayEpoch}});
993
+ if(this.canSleep())this.ctx.waitUntil(this.sleepRoom());
994
+ return response;
995
+ }
996
+ if (path === '/__relay') {
997
+ this.relayed = true;
998
+ const relay = this.relayFor(new URL(request.url));
999
+ const epoch=request.headers.get('x-homie-relay-epoch');
1000
+ if(relay.remoteEpoch!==epoch){relay.remoteEpoch=epoch;relay.inputSequence=0;relay.session.reset();}
1001
+ const sequence=Number(request.headers.get('x-homie-relay-sequence'));
1002
+ const data=await request.text();
1003
+ // One Gate serializes POSTs; a lost response must not replay admission/input.
1004
+ if(!sequence || sequence>relay.inputSequence){
1005
+ relay.inputSequence=sequence;
1006
+ await this.relayInput(relay,data);
1007
+ }
1008
+ return new Response(null,{status:204});
1009
+ }
869
1010
  if (path === '/__multiplex') {
870
1011
  this.relayed = true;
871
1012
  const index = Number(new URL(request.url).searchParams.get('gate'));
@@ -879,6 +1020,15 @@ class Table {
879
1020
  return this.connect(request);
880
1021
  }
881
1022
 
1023
+ async relayInput(relay,data) {
1024
+ relay.socket.emit('message',{data});
1025
+ await relay.session.settled();
1026
+ const rows=JSON.parse(data);
1027
+ for(const row of rows)if(row[0]==='close')relay.clients.delete(row[1]);
1028
+ if(this.canSleep())await this.sleepRoom();
1029
+ else if(rows.some(row=>row[0]==='open'||row[0]==='close'||row[0]==='data'&&/\"t\"\s*:\s*\"hello\"/.test(row[2])))this.saveSessions();
1030
+ }
1031
+
882
1032
  async connect(request, transport = null) {
883
1033
  const url = new URL(request.url);
884
1034
  const game = url.searchParams.get('game');
@@ -945,7 +1095,7 @@ class Table {
945
1095
  const res = await room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
946
1096
  // AI talk turned off (a policy) or a guide kicked: the house guides follow at once.
947
1097
  if (this.house) this.house.sync();
948
- await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
1098
+ await this.ctx.storage.put('office', room.officeSaved());
949
1099
  if (ctl.op === 'close' && res.ok) this.tellLobby({ closed: ctl.args?.reopen ? 0 : res.until });
950
1100
  this.report();
951
1101
  return json(res);
@@ -977,14 +1127,21 @@ class Table {
977
1127
  const account = holders(url.searchParams.get('via')).find(p => p.startsWith('p-'))?.slice(2) ?? null;
978
1128
  if (hostedBuild(game)?.settings.characters && url.pathname !== '/__watch') {
979
1129
  if (!account || url.searchParams.get('acct') !== '1') return new Response('Sign in to keep your character', { status: 401 });
980
- try { await this.acquireCharacter(account); }
1130
+ try {
1131
+ const held=transport?.resume && this.hostRt?.characters.get(account);
1132
+ if(held && !held.returning)this.characterGrants.set(account,held);
1133
+ else if(transport?.resume)return new Response('Character lease unavailable',{status:409});
1134
+ else await this.acquireCharacter(account);
1135
+ }
981
1136
  catch (error) { return new Response(String(error.message), { status: 409 }); }
982
1137
  }
983
- const [client, server] = transport ? [null, transport] : Object.values(new WebSocketPair());
984
- server.accept();
1138
+ const [client, accepted] = transport ? [null, transport] : Object.values(new WebSocketPair());
1139
+ const hibernating = !transport && this.hibernationReady && this.ctx.acceptWebSocket && (url.pathname === '/__watch' || Boolean(this.ctx.storage.sql));
1140
+ const server = hibernating ? await this.watchSockets.accept(accepted, request) : accepted;
1141
+ if (!hibernating) server.accept();
985
1142
  const via = url.searchParams.get('via');
986
1143
  const conn = {
987
- ip: request.headers.get('cf-connecting-ip'),
1144
+ ip: server.address ?? request.headers.get('cf-connecting-ip'),
988
1145
  // The browser's room key (what a kick holds out), and who the play page's ticket named (o, i-<invite>, p-<player>).
989
1146
  browser: url.searchParams.get('b') || null,
990
1147
  via: via || null,
@@ -1012,6 +1169,8 @@ class Table {
1012
1169
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
1013
1170
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
1014
1171
  buffered: () => server.buffered?.() ?? 0,
1172
+ lastPing: () => server.lastPing?.() ?? 0,
1173
+ idlePing: Boolean(server.remember && this.ctx.setWebSocketAutoResponse),
1015
1174
  ...(server.snapshotGroup ? { snapshotGroup: server.snapshotGroup, subscribe: seat => server.subscribe(seat) } : {}),
1016
1175
  ...(server.snapshot ? { snapshot: (...args) => server.snapshot(...args), transportFacts: server.transportFacts } : {}),
1017
1176
  };
@@ -1020,16 +1179,19 @@ class Table {
1020
1179
  // (room chat, 0.23.0: another site's page can open one too).
1021
1180
  const same = conn.ip ? [...room.watchers].filter((x) => x.ip === conn.ip).length : 0;
1022
1181
  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);
1182
+ const w = room.watch(conn, this.restoringSockets ? server.watchState ?? {rate: []} : null);
1183
+ server.rememberWatch?.(w.state);
1024
1184
  // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
1025
1185
  server.addEventListener('message', (e) => {
1026
1186
  if (typeof e.data !== 'string') return;
1027
- try { w.onMessage(e.data); } catch (error) { this.say({ ev: 'failed', op: 'watch-message', error: errorLine(error) }); }
1187
+ try { w.onMessage(e.data); server.rememberWatch?.(w.state); } catch (error) { this.say({ ev: 'failed', op: 'watch-message', error: errorLine(error) }); }
1028
1188
  });
1029
1189
  server.addEventListener('close', () => w.onClose());
1030
1190
  server.addEventListener('error', (e) => { this.socketError('watch', e); w.onClose(); });
1031
1191
  } else {
1032
- const h = room.attach(conn);
1192
+ const h = room.attach(conn,server.resume);
1193
+ server.remember?.(h.id);
1194
+ if (server.resume && room.server) room.joinServer(room.clients.get(h.id));
1033
1195
  // A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
1034
1196
  // seats, so it is not counted twice). One counter write per room.
1035
1197
  const fresh = room.seats.size === 0;
@@ -1057,7 +1219,7 @@ class Table {
1057
1219
  const c = room.clients.get(h.id);
1058
1220
  if (c?.agent && c.helloed && c.seat !== null && !conn.qa) {
1059
1221
  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(() => {}));
1222
+ if (minutes > 0) this.ctx.waitUntil(count(this.env, null, { metric: 'agent-minutes', subject: this.game ?? '', source: c.agent.role, n: minutes }).catch(error => this.say({ev:'background-failed',error:errorLine(error)})));
1061
1223
  }
1062
1224
  // The room says the departure itself (its `leave` line: who, which seat, host or not, how many are left).
1063
1225
  h.onClose(via);
@@ -1076,7 +1238,83 @@ class Table {
1076
1238
  return transport ? null : new Response(null, { status: 101, webSocket: client });
1077
1239
  }
1078
1240
 
1241
+ async keepIdleChat() {
1242
+ if(!this.room||!this.hibernationReady)return;
1243
+ try { await this.flushChat(); }
1244
+ finally { await writeIdleChat(this.ctx.storage,idleChatSnapshot(this)); }
1245
+ }
1246
+ async webSocketMessage(socket, message) {
1247
+ this.watchSockets.message(socket, message);
1248
+ if(this.canSleep()) { await this.keepIdleChat(); await this.sleepRoom(); }
1249
+ else { this.start(); if(!socket.deserializeAttachment()?.clientId)await this.keepIdleChat(); }
1250
+ }
1251
+ // Compatibility date >= 2026-04-07 replies to Close frames automatically.
1252
+ async webSocketClose(socket) { await this.watchSockets.close(socket); if(this.canSleep())await this.sleepRoom(); }
1253
+ async webSocketError(socket, error) { await this.watchSockets.close(socket, error); if(this.canSleep())await this.sleepRoom(); }
1254
+
1255
+ relayFor(url, descriptor = null) {
1256
+ const gate = url.searchParams.get('gate');
1257
+ if (this.relays.has(gate)) return this.relays.get(gate);
1258
+ const target = new URL(url); target.pathname = '/__relay-wake';
1259
+ const key = `${url.searchParams.get('game')}/${url.searchParams.get('room')}/${gate}`;
1260
+ const socket = relayStream(this.ctx,async()=>{const response=await this.env.GATE.get(this.env.GATE.idFromName(key)).fetch(target);if(!response.ok)throw new Error('Gate wake failed');await response.body?.cancel();});
1261
+ const clients = new Map((descriptor?.clients??[]).map(row=>[row[0],row]));
1262
+ const relay = {url:url.toString(),socket,clients,inputSequence:descriptor?.inputSequence??0,remoteEpoch:descriptor?.remoteEpoch,pings:new Map(descriptor?.pings??[])};
1263
+ relay.session = multiplexSession(socket,(request,transport)=>this.connect(request,transport),{
1264
+ receipts:false,
1265
+ lastPing:id=>relay.pings.get(id)??0,
1266
+ source:(this.worldSource??=worldSource()),
1267
+ resume:id=>this.sessionSnapshot?.clients.get(clients.get(id)?.[1]),
1268
+ remember:(id,clientId,url,ip,agent)=>clients.set(id,[id,clientId,url,ip,agent]),
1269
+ onCount:n=>{const index=Number(gate);if(n>(this.gateHeld.get(index)??0))this.gatePromised.get(index)?.shift();if(n)this.gateHeld.set(index,n);else this.gateHeld.delete(index);},
1270
+ });
1271
+ const remove = () => { if(this.relays.get(gate)===relay)this.relays.delete(gate); };
1272
+ socket.addEventListener('close',remove);socket.addEventListener('error',()=>{remove();socket.close(1011,'room link failed');});
1273
+ this.relays.set(gate,relay);return relay;
1274
+ }
1275
+ saveSessions() {
1276
+ this.relayDescriptors = [...this.relays.values()].map(relay=>({url:relay.url,pings:[...relay.pings],clients:[...relay.clients.values()].filter(row=>this.room.clients.has(row[1]))}));
1277
+ const keys=['game','code','lastReport','lastPeople','lastAgents','lastAi','lastReportAt','openCounted','launchReadFor','relayDescriptors'];
1278
+ this.sessionStore.write(this.room,Object.fromEntries(keys.map(key=>[key,this[key]])));
1279
+ }
1280
+ canSleep() {
1281
+ // Older ports publish snapshots without a round protocol. Once play starts,
1282
+ // treat them as active until empty: persisting every frame would turn their
1283
+ // ordinary network traffic into a stream of SQLite writes.
1284
+ const active = this.hostRt ? this.hostRt.running : this.room?.lastRound?.phase === 'live' || (!this.room?.lastRound && this.room?.lastSnap != null && this.room?.clients.size > 0);
1285
+ return this.room && this.hibernationReady && this.sessionStore && this.ctx.storage.sql && this.ctx.acceptWebSocket && (!this.relayed || this.relays.size > 0) && (!this.hostRt || this.hostRt.roundIdleUntil !== null || !this.hostRt.running) && !active && !this.restoringSockets;
1286
+ }
1287
+ async sleepRoom() {
1288
+ if(!this.canSleep())return;
1289
+ clearInterval(this.timer);this.timer=null;
1290
+ const now=Date.now(),room=this.room;
1291
+ if(room.officeDirty) {
1292
+ room.officeDirty=false;
1293
+ try {this.officeSaved=room.officeSaved();await this.ctx.storage.put('office',this.officeSaved);}
1294
+ catch(error) {room.officeDirty=true;this.storageProblem(error);}
1295
+ }
1296
+ if(!room.clients.size&&!room.watchers.size&&!room.officeDirty) {
1297
+ for(const relay of this.relays.values()){relay.session.flush();relay.socket.sleep();}
1298
+ this.sessionStore.clear();
1299
+ await this.watchSockets?.prune();
1300
+ // Empty rooms get one final expiry pass, never a permanent minute alarm.
1301
+ const until=room.emptySince+room.forgetMs;
1302
+ if(Number.isFinite(until)&&until>now){const alarm=await this.ctx.storage.getAlarm();if(alarm==null||alarm>until)await this.ctx.storage.setAlarm(until);}
1303
+ return;
1304
+ }
1305
+ this.saveSessions();
1306
+ for(const relay of this.relays.values()){relay.session.flush();relay.socket.sleep();}
1307
+ // Edge-answered heartbeats need only a minute's liveness sweep. A legacy
1308
+ // client still keeps its original hello/heartbeat deadline.
1309
+ const due=[...(this.hostRt?.roundIdleUntil != null ? [this.hostRt.roundIdleUntil] : []),now+(room.officeDirty?1000:60000),...[...room.clients.values()].map(c=>{const ping=c.conn.lastPing?.()??0;return c.helloed?Math.max(c.lastSeen,ping)+(ping?Math.max(room.idleMs,60000):room.idleMs):c.joinedAt+5000;})];
1310
+ for(const control of [room.vote?.open?room.vote:null,room.regate,room.agentsOut])if(control?.until)due.push(control.until);
1311
+ const at=Math.max(now+1,Math.min(...due)),alarm=await this.ctx.storage.getAlarm();
1312
+ if(alarm==null||alarm>at)await this.ctx.storage.setAlarm(at);
1313
+ }
1079
1314
  start() {
1315
+ if(this.restoringSockets)return;
1316
+ if(this.canSleep()){this.ctx.waitUntil(this.sleepRoom());return;}
1317
+ if(this.sessionStore&&!this.relays.size&&this.room?.lastRound?.phase==='live')this.sessionStore.clear();
1080
1318
  if (this.timer) return;
1081
1319
  let n = 0;
1082
1320
  this.timer = setInterval(() => {
@@ -1086,17 +1324,18 @@ class Table {
1086
1324
  try { room.tick(); } catch (error) { this.say({ ev: 'tick-failed', clients: room.clients.size, error: errorLine(error) }); }
1087
1325
  // A room that just opened reads its game's launch state once: a change made in the moment it opened (before the
1088
1326
  // 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(() => {})); }
1327
+ if (room.openedAt && this.launchReadFor !== room.openedAt) { this.launchReadFor = room.openedAt; this.ctx.waitUntil(this.rereadLaunch().catch(error => { this.launchReadFor = null; this.say({ev:'launch-read-retry',error:errorLine(error)}); })); }
1090
1328
  // 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(() => {}); }
1329
+ if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(error => { room.officeDirty = true; this.storageProblem(error); }); }
1092
1330
  n += 1;
1093
1331
  if (n % 4 === 0) { this.publishDiagnostics(); room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
1094
1332
  // Room chat's counts, once a minute (section 19).
1095
- if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
1333
+ if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => { /* The flush logged the failure and retained pending counters. */ }));
1096
1334
  // A server-hosted room in play keeps its alarm ahead of it (re-armed every five minutes of play).
1097
1335
  if (n % 1200 === 0 && this.hostRt?.running) this.armRoom(Date.now() + ROOM_ALARM_MS);
1098
1336
  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; } }
1337
+ if(this.canSleep()){this.ctx.waitUntil(this.sleepRoom());return;}
1338
+ if (room.clients.size === 0 && !this.hostRt?.running) { this.armRoom(Date.now() + 120_000); clearInterval(this.timer); this.timer = null; this.report(); this.ctx.waitUntil(this.flushChat().catch(() => { /* The flush logged the failure and retained pending counters. */ })); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => { /* The flush logged the failure and retained pending counters. */ })); this.house = null; } }
1100
1339
  }, 250);
1101
1340
  }
1102
1341
 
@@ -1112,7 +1351,7 @@ class Table {
1112
1351
  const args = regateArgs(launch);
1113
1352
  if (!args.allow) return null;
1114
1353
  const res = room.control('regate', args);
1115
- if (res.ok && res.leaving) await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
1354
+ if (res.ok && res.leaving) await this.ctx.storage.put('office', room.officeSaved());
1116
1355
  return res;
1117
1356
  }
1118
1357
 
@@ -1120,9 +1359,9 @@ class Table {
1120
1359
  tellLobby(extra) {
1121
1360
  if(extra.closed || extra.ended) this.queueFunctionEvent('room.closed',{game:this.game,room:this.code,...extra});
1122
1361
  if (!this.game || !this.code) return;
1123
- const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
1362
+ const lobby = lobbyFor(this.env,this.game);
1124
1363
  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(() => {}));
1364
+ 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(error => this.say({ev:'background-failed',error:errorLine(error)})));
1126
1365
  }
1127
1366
 
1128
1367
  /**
@@ -1182,10 +1421,10 @@ class Table {
1182
1421
  this.lastAgents = agents;
1183
1422
  this.lastAi = ai;
1184
1423
  this.lastReportAt = Date.now();
1185
- const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
1424
+ const lobby = lobbyFor(this.env,this.game);
1186
1425
  // The build this room runs (section 23): the Lobby sends a visitor only to a room of the live build.
1187
1426
  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(() => {}));
1427
+ 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(error => this.say({ev:'background-failed',error:errorLine(error)})));
1189
1428
  }
1190
1429
 
1191
1430
  /** A round the host called over becomes one D1 row (once per round number). */
@@ -1193,7 +1432,7 @@ class Table {
1193
1432
  const r = this.room?.lastRound;
1194
1433
  if (!r || r.phase !== 'over' || !Number.isFinite(r.n) || r.n <= this.recorded || !this.env.DB) return;
1195
1434
  this.recorded = r.n;
1196
- this.ctx.storage.put('recorded', r.n).catch(() => {});
1435
+ this.ctx.storage.put('recorded', r.n).catch(error => this.say({ev:'background-failed',error:errorLine(error)}));
1197
1436
  const results = Array.isArray(r.results) ? r.results : [];
1198
1437
  // An AI's row is never a person's (the relay labelled it `agent`): rounds count agents as AI.
1199
1438
  const humans = results.filter((row) => row && !row.bot && !row.agent).length;
@@ -1204,9 +1443,6 @@ class Table {
1204
1443
  .bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
1205
1444
  // The round and the people in it, on the studio's daily counters (worker/stats.mjs). One batch, per round.
1206
1445
  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(() => {})));
1446
+ this.ctx.waitUntil(db.batch([insert, ...counters]).catch(() => insert.run().catch(error => this.say({ev:'background-failed',error:errorLine(error)}))));
1208
1447
  }
1209
1448
  }
1210
-
1211
- export {Table};
1212
-
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.55.0';
2
+ export const STUDIO_VERSION_TAG = '0.57.0';