@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.
- package/CHANGELOG.md +72 -0
- package/README.md +2 -0
- package/apps/APPS.md +9 -0
- package/apps/apps.ts +19 -1
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/docs/studio-to-studio.md +248 -0
- package/functions/FUNCTIONS.md +9 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +52 -22
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/needs-headless.mjs +1 -1
- package/lib/port-check.mjs +3 -2
- package/lib/scaffold.mjs +2 -0
- package/lib/studios-keys.mjs +13 -0
- package/lib/template-history.json +88 -1
- package/lib/worker-build.mjs +4 -1
- package/lib/worker-config.mjs +7 -0
- package/netplay/NETPLAY.md +12 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +22 -1
- package/tools/tools.ts +14 -1
- package/worker/background-queue.mjs +1 -0
- package/worker/booking-availability.mjs +35 -0
- package/worker/booking-holds.mjs +2 -2
- package/worker/booking-resources.mjs +14 -6
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +2 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/gate.mjs +94 -23
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +2 -0
- package/worker/lazy-objects.mjs +3 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +86 -18
- package/worker/lounge-store.mjs +2 -2
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +3 -1
- package/worker/mcp-tools.mjs +29 -4
- package/worker/mcp.mjs +43 -8
- package/worker/office.mjs +2 -1
- package/worker/operational-schema.mjs +2 -0
- package/worker/pages.mjs +1 -1
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +4 -3
- package/worker/room-chat.mjs +2 -1
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +45 -9
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +8 -5
- package/worker/runtime-support.mjs +5 -3
- package/worker/session-codec.mjs +24 -0
- package/worker/stats.mjs +2 -1
- package/worker/studios-auth.mjs +71 -0
- package/worker/studios-client.mjs +93 -0
- package/worker/studios-events.mjs +52 -0
- package/worker/studios-keys.mjs +54 -0
- package/worker/table.mjs +277 -41
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/worker/table.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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())
|
|
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 {
|
|
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,
|
|
984
|
-
|
|
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
|
|
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())
|
|
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 =
|
|
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 =
|
|
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
|
-
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.57.0';
|