@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,40 @@
1
+ /** Generated standard entries load room engines only when their object is used.
2
+ * Custom subclasses retain their own exports and do not use this adapter. */
3
+ export class Table {
4
+ constructor(ctx,env) {
5
+ this.ready=ctx.blockConcurrencyWhile(async()=>{this.room=new (await import('./table.mjs')).Table(ctx,env);});
6
+ }
7
+ async fetch(request) {await this.ready;return this.room.fetch(request);}
8
+ async alarm() {await this.ready;return this.room.alarm();}
9
+ async webSocketMessage(...args) {await this.ready;return this.room.webSocketMessage(...args);}
10
+ async webSocketClose(...args) {await this.ready;return this.room.webSocketClose(...args);}
11
+ async webSocketError(...args) {await this.ready;return this.room.webSocketError(...args);}
12
+ }
13
+ export class Lobby {
14
+ constructor(ctx,env) {
15
+ this.ready=ctx.blockConcurrencyWhile(async()=>{this.lobby=new (await import('./lobby.mjs')).Lobby(ctx,env);});
16
+ }
17
+ async fetch(request) {await this.ready;return this.lobby.fetch(request);}
18
+ async alarm() {await this.ready;return this.lobby.alarm();}
19
+ }
20
+
21
+ export class Gate {
22
+ constructor(ctx,env) {
23
+ this.ready=ctx.blockConcurrencyWhile(async()=>{this.gate=new (await import('./gate.mjs')).Gate(ctx,env);});
24
+ }
25
+ async fetch(request) {await this.ready;return this.gate.fetch(request);}
26
+ }
27
+ export class Concentrator extends Gate {}
28
+
29
+ export class AppRecordHub {
30
+ constructor(ctx,env) {
31
+ this.ready=ctx.blockConcurrencyWhile(async()=>{this.hub=new (await import('./stream-objects.mjs')).AppRecordHub(ctx,env);});
32
+ }
33
+ async fetch(request) {await this.ready;return this.hub.fetch(request);}
34
+ }
35
+ export class EventHub {
36
+ constructor(ctx,env) {
37
+ this.ready=ctx.blockConcurrencyWhile(async()=>{this.hub=new (await import('./stream-objects.mjs')).EventHub(ctx,env);});
38
+ }
39
+ async fetch(request) {await this.ready;return this.hub.fetch(request);}
40
+ }
@@ -0,0 +1,2 @@
1
+ export const LOBBY_APP_VERSIONS = 8;
2
+ export const LOBBY_ROOMS_MAX = 512;
@@ -0,0 +1,251 @@
1
+ import { errorLine, versionOf } from './room-protocol.mjs';
2
+ import { ROOM_ID } from './page-policy.mjs';
3
+ import { SERVER_ID, pooledRoom, roomCode, roomServer } from './servers.mjs';
4
+ import { seatCount } from './seats.mjs';
5
+ import { counter, today } from './stats.mjs';
6
+
7
+ const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
8
+ status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
9
+ });
10
+
11
+ export const LOBBY_APP_VERSIONS = 8;
12
+
13
+ export const LOBBY_ROOMS_MAX = 512;
14
+
15
+ export class Lobby {
16
+ constructor(ctx, env) {
17
+ this.ctx = ctx;
18
+ this.env = env;
19
+ this.rooms = new Map();
20
+ /** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
21
+ this.next = { public: 1 };
22
+ /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
23
+ this.live = new Map();
24
+ this.peak = { day: null, players: 0, room: 0 };
25
+ /** Every room with somebody seated, public or named, for the owner's office; and the rooms the owner closed (until when). */
26
+ this.seen = new Map();
27
+ this.closed = new Map();
28
+ ctx.blockConcurrencyWhile(async () => {
29
+ const saved = await ctx.storage.get('lobby');
30
+ if (saved) {
31
+ this.current = saved.current ?? null;
32
+ this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
33
+ for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
34
+ for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
35
+ }
36
+ });
37
+ }
38
+
39
+ /**
40
+ * The day's peak for this game, all rooms together and in one room: a counter write only when a new high is
41
+ * reached (in memory per day; after an eviction the stored max still wins), so at most a few a day.
42
+ */
43
+ notePeak(room, players, game) {
44
+ const now = Date.now();
45
+ if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
46
+ for (const [name, r] of this.live) if (now - r.at > 180_000) this.live.delete(name); // a room that stopped reporting
47
+ const day = today();
48
+ if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
49
+ let all = 0;
50
+ for (const r of this.live.values()) all += r.players;
51
+ const writes = [];
52
+ if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
53
+ if (players > this.peak.room) { this.peak.room = players; writes.push(counter(this.env, { metric: 'peak-room', subject: game ?? this.game ?? '', n: players, day })); }
54
+ if (game) this.game = game;
55
+ const list = writes.filter(Boolean);
56
+ if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
57
+ }
58
+
59
+ save() { this.ctx.storage.put('lobby', { current: this.current ?? null, next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver, host }) => ({ name, players, agents, ai, server, at, ...(host ? { host } : {}), ...(ver ? { ver } : {}) })), closed: [...this.closed] }).catch(() => {}); }
60
+
61
+ prune(now) {
62
+ for (const [name, r] of this.rooms) {
63
+ r.pending = r.pending.filter((t) => now - t < 20_000);
64
+ r.agentPending = (r.agentPending ?? []).filter((t) => now - t < 20_000);
65
+ if (r.players === 0 && !r.pending.length && now - r.at > 120_000) this.rooms.delete(name);
66
+ }
67
+ }
68
+
69
+ /** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
70
+ async fetch(request) {
71
+ if(new URL(request.url).pathname==='/__app_records') {
72
+ const {AppRecordStreams}=await import('./app-streams.mjs');
73
+ this.appStreams??=new AppRecordStreams(this.env,this.ctx);
74
+ const input=await request.json();
75
+ return input.op==='open'?this.appStreams.open(input):this.appStreams.wake(input.meta);
76
+ }
77
+ if(new URL(request.url).pathname==='/__studio_events') {
78
+ const {EventStreams}=await import('./event-resources.mjs');
79
+ this.eventStreams??=new EventStreams(this.env,this.ctx);
80
+ return this.eventStreams.fetch(request);
81
+ }
82
+ try { return await this.answer(request); } catch (error) {
83
+ let path = '';
84
+ try { path = new URL(request.url).pathname; } catch { /* not an address */ }
85
+ try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-failed', game: this.game ?? null, path, error: errorLine(error) })); } catch { /* no console */ }
86
+ return json({ ok: false, error: 'lobby' }, 500);
87
+ }
88
+ }
89
+
90
+ async answer(request) {
91
+ const url = new URL(request.url);
92
+ const now = Date.now();
93
+ this.prune(now);
94
+ // REVISIONS (section 23): rooms are matched within one build of the game. A room that never said its build is
95
+ // the build of a game that names none ('').
96
+ const ver = versionOf(url.searchParams.get('ver')) ?? '';
97
+ if (url.pathname === '/join') {
98
+ // A server build can run only its current rules. Persisted browser rooms
99
+ // (including pre-kind records) and old server revisions must not consume
100
+ // the pool or become the fallback of a server with roomsMax=1.
101
+ const host = url.searchParams.get('host');
102
+ if ((host === 'server' || host === 'browser') && (this.current?.host !== host || this.current?.ver !== ver)) {
103
+ this.current = { host, ver };
104
+ for (const [name, r] of this.rooms) if (
105
+ host === 'server' ? (r.host ?? 'browser') !== host || (r.ver ?? '') !== ver : r.host === 'server'
106
+ ) { this.rooms.delete(name); this.seen.delete(name); this.live.delete(name); }
107
+ this.save();
108
+ }
109
+ const max = seatCount(url.searchParams.get('max'));
110
+ const sid = url.searchParams.get('server') ?? 'public';
111
+ const server = sid === 'public' || SERVER_ID.test(sid) ? sid : 'public';
112
+ const roomsMax = Math.max(1, Math.min(16, Math.floor(Number(url.searchParams.get('rooms'))) || 16));
113
+ // A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
114
+ const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
115
+ for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
116
+ // Every room of the server counts toward its `rooms_max` and its numbering; only this build's rooms are matched.
117
+ const everyRoom = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server);
118
+ const ofServer = everyRoom.filter((r) => !not.has(r.name) && !this.closed.has(r.name));
119
+ const pool = ofServer.filter((r) => (r.ver ?? '') === ver);
120
+ // A standalone copy names its own build (the Worker says `app=1`), and anybody can claim to be one: only a few
121
+ // builds besides the live one ever have rooms here, so a caller that invents a build a request makes no pool.
122
+ const app = url.searchParams.get('app') === '1';
123
+ if (app && !everyRoom.some((r) => (r.ver ?? '') === ver) && new Set(everyRoom.map((r) => r.ver ?? '')).size > LOBBY_APP_VERSIONS) {
124
+ return json({ ok: false, room: null, error: 'versions', message: 'Too many versions of this game are being played at once: play offline for now.' }, 503);
125
+ }
126
+ if (url.searchParams.get('agent') === '1') {
127
+ // An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
128
+ const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
129
+ let best = null;
130
+ for (const r of pool) {
131
+ const agents = (r.agents ?? 0) + (r.agentPending ?? []).length;
132
+ if (!(r.players > 0) || (reserve ? agents >= reserve : r.players + r.pending.length + agents >= max)) continue;
133
+ if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
134
+ }
135
+ if (!best) return json({ room: null, players: 0, max });
136
+ (best.agentPending ??= []).push(now);
137
+ return json({ room: best.name, players: best.players, max });
138
+ }
139
+ let best = null;
140
+ for (const r of pool) {
141
+ const fill = r.players + r.pending.length;
142
+ if (fill >= max) continue;
143
+ if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
144
+ }
145
+ let room = best?.r;
146
+ let full = false;
147
+ const fullest = (list) => list.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), list[0]);
148
+ if (!room && server !== 'public' && everyRoom.length >= roomsMax) {
149
+ // Every room this server may have exists, whatever build each runs: the fullest one of this build, where the
150
+ // visitor waits for a seat; with none of this build, the fullest of the server (its players are told a newer
151
+ // build is live, and the visitor is let in when they have it). Never a room past the server's own number.
152
+ const from = pool.length ? pool : ofServer;
153
+ if (!from.length) return json({ ok: false, room: null, error: 'full', message: 'Every room of this server is taken.' }, 503);
154
+ room = fullest(from);
155
+ full = true;
156
+ }
157
+ if (!room && this.rooms.size >= LOBBY_ROOMS_MAX) {
158
+ // The Lobby keeps a bounded list (it is one stored value): rooms nobody is seated in go first, the oldest
159
+ // first. When every room has people in it, nobody gets a new one: this build's fullest, or no room at all.
160
+ const empty = [...this.rooms.values()].filter((r) => r.players === 0).sort((a, b) => a.at - b.at);
161
+ for (const r of empty) { if (this.rooms.size < LOBBY_ROOMS_MAX) break; this.rooms.delete(r.name); }
162
+ if (this.rooms.size >= LOBBY_ROOMS_MAX) {
163
+ if (!pool.length) return json({ ok: false, room: null, error: 'busy', message: 'Every room is taken right now.' }, 503);
164
+ room = fullest(pool);
165
+ full = true;
166
+ }
167
+ }
168
+ if (!room) {
169
+ let n = this.next[server] ?? 1;
170
+ while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
171
+ room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, ...(host ? { host } : {}), pending: [], agentPending: [] };
172
+ this.next[server] = n + 1;
173
+ this.rooms.set(room.name, room);
174
+ }
175
+ room.pending.push(now);
176
+ this.save();
177
+ return json({ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) });
178
+ }
179
+ if (url.pathname === '/report' && request.method === 'POST') {
180
+ const body = await request.json().catch(() => ({}));
181
+ if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
182
+ if (body.ended === true) { this.rooms.delete(body.room); this.seen.delete(body.room); this.save(); return json({ ok: true }); }
183
+ // The owner closed this room: matched with nobody until then (and forgotten as a public room).
184
+ if (Number.isFinite(body.closed)) {
185
+ if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
186
+ this.seen.delete(body.room);
187
+ this.save();
188
+ return json({ ok: true, closed: body.closed > now });
189
+ }
190
+ // A retiring isolate may send its last report after the new pool was
191
+ // selected. It cannot resurrect an incompatible public room.
192
+ if (pooledRoom(body.room) && this.current && (this.current.host === 'server'
193
+ ? (body.host ?? 'browser') !== 'server' || (body.ver ?? '') !== this.current.ver
194
+ : body.host === 'server')) return json({ ok: true, retired: true });
195
+ const seated = Math.max(0, Math.floor(Number(body.players) || 0));
196
+ const agents = Math.max(0, Math.floor(Number(body.agents) || 0));
197
+ const ai = Math.max(agents, Math.floor(Number(body.ai) || 0));
198
+ if (seated > 0) this.seen.set(body.room, { room: body.room, players: seated, people: Math.max(0, Math.floor(Number(body.people ?? seated) || 0)), agents, ai, at: now });
199
+ else this.seen.delete(body.room);
200
+ if (this.closed.has(body.room)) return json({ ok: true, closed: true });
201
+ this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
202
+ // Only rooms of a pool are matched with strangers (pub-N, s-<id>-<n>); a named room (?room=) stays private.
203
+ if (!this.rooms.has(body.room) && !pooledRoom(body.room)) return json({ ok: true, private: true });
204
+ const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, ver: '', pending: [], agentPending: [] };
205
+ const grew = Math.max(0, seated - r.players);
206
+ // The room says which build it runs (it changes only when nobody on the old one is left).
207
+ const rver = typeof body.ver === 'string' ? versionOf(body.ver) ?? '' : r.ver ?? '';
208
+ const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
209
+ r.ver = rver;
210
+ if (body.host === 'server' || body.host === 'browser') r.host = body.host;
211
+ r.pending.splice(0, grew);
212
+ if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
213
+ r.players = seated;
214
+ r.agents = agents;
215
+ r.ai = ai;
216
+ r.at = now;
217
+ this.rooms.set(r.name, r);
218
+ if (changed) this.save(); // the minute's "still here" report writes nothing
219
+ return json({ ok: true });
220
+ }
221
+ if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at }) => ({ name, players, agents: agents ?? 0, ai: ai ?? 0, server: server ?? roomServer(name), at })) });
222
+ if (url.pathname === '/busiest') {
223
+ // The pooled room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
224
+ // no seat. A closed room, one named in `not`, or one of a pool not in `pools` (a server with a door) is never it.
225
+ const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
226
+ const pools = url.searchParams.has('pools') ? new Set(String(url.searchParams.get('pools')).split(',').filter(Boolean)) : null;
227
+ let best = null;
228
+ for (const r of this.rooms.values()) {
229
+ if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
230
+ // A watcher's page runs the live build: a room still on an older one would turn it away.
231
+ if (url.searchParams.has('ver') && (r.ver ?? '') !== ver) continue;
232
+ if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
233
+ }
234
+ return json({ room: best?.name ?? null, players: best?.players ?? 0 });
235
+ }
236
+ if (url.pathname === '/office') {
237
+ // The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
238
+ for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
239
+ const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
240
+ for (const r of this.rooms.values()) if (r.players > 0 && !rooms.has(r.name)) rooms.set(r.name, { room: r.name, players: r.players, people: r.players, agents: r.agents ?? 0, ai: r.ai ?? 0, at: r.at });
241
+ for (const [name, until] of this.closed) if (until > now && !rooms.has(name)) rooms.set(name, { room: name, players: 0, people: 0, at: now, closedUntil: until });
242
+ return json({ rooms: [...rooms.values()].map((r) => ({ ...r, server: roomServer(r.room) })).sort((a, b) => b.players - a.players).slice(0, 100) });
243
+ }
244
+ if (url.pathname === '/now') {
245
+ let players = 0;
246
+ for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }
247
+ return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
248
+ }
249
+ return json({ error: 'not found' }, 404);
250
+ }
251
+ }
package/worker/lounge.mjs CHANGED
@@ -28,7 +28,7 @@ import { encodeFacts } from './agents.mjs';
28
28
  import { PUBLIC_SERVER, policyOf } from './servers.mjs';
29
29
  import { players } from './players.mjs';
30
30
  import { signControl } from './office.mjs';
31
- import { LOUNGE_JS, loungePage } from './lounge-page.mjs';
31
+
32
32
  import { STUDIO_VERSION_TAG } from './version.mjs';
33
33
 
34
34
  const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
@@ -262,14 +262,14 @@ export async function loungeRoutes(request, env, ctx, url, { catalogueOf, roomsO
262
262
  const cat = await catalogueOf();
263
263
  const L = loungeOf(cat);
264
264
  if (!L) return null;
265
- if (path === '/_homie/lounge.js') return new Response(LOUNGE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
265
+ if (path === '/_homie/lounge.js') return new Response((await import('./lounge-page.mjs')).LOUNGE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
266
266
  if (path === '/lounge' && read) return Response.redirect(`${url.origin}/lounge/${url.search}`, 301);
267
267
  if (!env.TABLE || !env.DB) return path.startsWith('/lounge/api/') ? json({ ok: false, error: 'no-rooms', message: 'This studio has no rooms or database bound yet: `npm run deploy` sets them up.' }, 503) : null;
268
268
  const local = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(url.origin);
269
269
 
270
270
  if (path === '/lounge/' && read) {
271
271
  const [who, rules, nights] = await Promise.all([whoOf(request, env), loungeRules(env, cat), nightsOf(env)]);
272
- return loungePage(cat, { origin: url.origin, who, rules: publicChat(rules), nights: nights.map((n) => nightView(n, cat, url.origin)) });
272
+ return (await import('./lounge-page.mjs')).loungePage(cat, { origin: url.origin, who, rules: publicChat(rules), nights: nights.map((n) => nightView(n, cat, url.origin)) });
273
273
  }
274
274
 
275
275
  if (path === '/lounge/__watch') {
@@ -4,7 +4,7 @@ import {EVENT_ERRORS,terminalEventError,EventsError,eventError,notification,sseF
4
4
  /** Connection-scoped push subscriptions hosted by the existing per-connection Lobby. */
5
5
  export class McpEventStreams {
6
6
  constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
7
- close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(()=>{}));}
7
+ close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(error=>console.warn("MCP stream cleanup deferred to expiry:",error.message)));}
8
8
  async write(stream,value,authorize){
9
9
  if(stream.closed)return false;
10
10
  if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
@@ -19,7 +19,7 @@ export class McpEventStreams {
19
19
  const caller=await eventCaller(this.env,input.connection);const feed=await checkEvent(this.env,input.cat,caller,input.params);
20
20
  const start=await eventStart(this.env,input.params);
21
21
  const registryKey='mcp-event-stream:'+input.connection+':'+crypto.randomUUID();
22
- await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Date.now()+60000).run();
22
+ await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Math.floor(Date.now()/1000)+60).run();
23
23
  const stream={...input,registryKey,source:eventSource(feed),cursor:cursorOf(input.params.name,start.after),closed:false,busy:false};
24
24
  const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(sseFrame(notification('active',{cursor:stream.cursor,truncated:start.truncated},stream.id)));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
25
25
  this.streams.add(stream);
@@ -46,7 +46,7 @@ export class McpEventStreams {
46
46
  }
47
47
  stream.cursor=page.cursor;more=page.hasMore;
48
48
  }
49
- await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Date.now()+60000).run();
49
+ await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Math.floor(Date.now()/1000)+60).run();
50
50
  await this.write(stream,notification('heartbeat',{cursor:stream.cursor},stream.id));
51
51
  }catch(error){
52
52
  const details=eventError(error);
@@ -78,7 +78,7 @@ export async function subscribeEvents(env,cat,caller,params,{now=Date.now}={}){
78
78
  const id=await subId(caller,params,url),previous=await stored(env,id),live=previous&&previous.expires>now();
79
79
  if(live&&previous.source!==eventSource(feed)){await removeEventHook(env,id);throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
80
80
  if(previous&&!live)await removeEventHook(env,id);
81
- if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%'").all()).results;if(rows.filter(row=>{const sub=JSON.parse(row.value);return sub.name===params.name&&sub.expires>now();}).length>=feed.maxSubscriptions)throw new EventsError(EVENT_ERRORS.exhausted,{limit:'subscriptions',max:feed.maxSubscriptions});}
81
+ if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE (key >= 'mcp-event-hook:' AND key < 'mcp-event-hook;')").all()).results;if(rows.filter(row=>{const sub=JSON.parse(row.value);return sub.name===params.name&&sub.expires>now();}).length>=feed.maxSubscriptions)throw new EventsError(EVENT_ERRORS.exhausted,{limit:'subscriptions',max:feed.maxSubscriptions});}
82
82
  const start=await eventStart(env,params,now());
83
83
  const sub={id,name:params.name,source:eventSource(feed),url,secret,principal:caller.id,connection:caller.connection??'local-test-owner',start:live?previous.start:start.after,expires:now()+Math.min(params.ttlMs??3600000,86400000)};
84
84
  if(live){if(previous.secret!==secret){sub.previous=previous.secret;sub.rotateUntil=now()+300000;}else if(previous.rotateUntil>now()){sub.previous=previous.previous;sub.rotateUntil=previous.rotateUntil;}}
@@ -98,15 +98,15 @@ export async function unsubscribeEvents(env,caller,params){
98
98
  /** Dynamic hooks become ordinary durable function deliveries: one queue and retry engine. */
99
99
  export async function eventHookSubscriptions(env,cat,options={}){
100
100
  const now=options.now??Date.now,definitions=[];let after='';
101
- for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
101
+ for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key >= 'mcp-event-hook:' AND key < 'mcp-event-hook;') AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
102
102
  for(const row of rows){const sub=JSON.parse(row.value);if(sub.expires<=now()){await removeEventHook(env,sub.id);continue;}
103
103
  let feed;
104
104
  try{feed=await checkEvent(env,cat,await eventCaller(env,sub.connection),{name:sub.name});if(!permittedCallback(env,sub.name,sub.url))throw new EventsError(EVENT_ERRORS.forbidden);if(sub.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
105
- catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,sub.id);continue;}
105
+ catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{/* Termination is best effort; the failed subscription is already being retired. */}await removeEventHook(env,sub.id);continue;}
106
106
  definitions.push({name:hookFunction(sub.id),event:feed.event,webhook:true,mcpEvent:true,matches:event=>matchesFeed(feed,event),async handler(event){
107
107
  const fresh=await stored(env,sub.id);if(!fresh||fresh.expires<=now())return;
108
108
  try{await checkEvent(env,cat,await eventCaller(env,fresh.connection),{name:fresh.name});}
109
- catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,fresh.id);return;}
109
+ catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{/* Termination is best effort; the failed subscription is already being retired. */}await removeEventHook(env,fresh.id);return;}
110
110
  try{await send(env,fresh,occurrence(fresh.name,event,await watermark(env,fresh)),event.id,options);}
111
111
  catch(error){const failure=new WebhookFailure(deliveryCategory(error));failure.permanent=error.status===410||error.status===413;throw failure;}
112
112
  }});
@@ -1,3 +1,4 @@
1
+ /** mcp_kv.expires is Unix seconds for ALL rows (OAuth, webhook dedupe and streams); NULL never expires. */
1
2
  /** OAuth's KV contract on the studio's existing D1. No new Cloudflare resource. */
2
3
  export const MCP_MIGRATION_FILE = '0017_studio_mcp.sql';
3
4
  export const MCP_MIGRATION = `CREATE TABLE IF NOT EXISTS mcp_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires INTEGER);
@@ -6,6 +7,15 @@ CREATE TABLE IF NOT EXISTS mcp_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, pers
6
7
  CREATE TABLE IF NOT EXISTS mcp_limits (caller TEXT NOT NULL, minute INTEGER NOT NULL, n INTEGER NOT NULL, PRIMARY KEY(caller, minute));
7
8
  CREATE TABLE IF NOT EXISTS tool_data (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(namespace,key));
8
9
  `;
10
+ /** Exclusive bound in SQLite's binary text order; null means no upper bound. */
11
+ export function prefixEnd(prefix) {
12
+ const points=Array.from(prefix, c=>c.codePointAt(0));
13
+ while(points.length) {
14
+ const last=points.pop();
15
+ if(last<0x10ffff)return String.fromCodePoint(...points,last===0xd7ff?0xe000:last+1);
16
+ }
17
+ return null;
18
+ }
9
19
  export function oauthStorage(DB) {
10
20
  return {
11
21
  async get(key, options) {
@@ -19,7 +29,8 @@ export function oauthStorage(DB) {
19
29
  async delete(key) { await DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(key).run(); },
20
30
  async list({prefix = '', cursor = '', limit = 1000} = {}) {
21
31
  const n = Math.max(1,Math.min(1000,limit));
22
- const rows = (await DB.prepare('SELECT key,expires FROM mcp_kv WHERE substr(key,1,?1)=?2 AND key>?3 AND (expires IS NULL OR expires>?4) ORDER BY key LIMIT ?5').bind(prefix.length,prefix,cursor,Math.floor(Date.now()/1000),n+1).all()).results;
32
+ const end=prefixEnd(prefix);
33
+ const rows = (await DB.prepare(`SELECT key,expires FROM mcp_kv WHERE key>=?1 ${end===null?'':'AND key<?2'} AND key>?3 AND (expires IS NULL OR expires>?4) ORDER BY key LIMIT ?5`).bind(prefix,end,cursor,Math.floor(Date.now()/1000),n+1).all()).results;
23
34
  return { keys: rows.slice(0,n).map(r=>({name:r.key,...(r.expires ? {expiration:r.expires}: {})})), list_complete: rows.length<=n, cursor: rows.length>n ? rows[n-1].key : '' };
24
35
  },
25
36
  };
package/worker/mcp.mjs CHANGED
@@ -52,7 +52,11 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
52
52
  }
53
53
  const cat=await catalogueOf();
54
54
  const tools=[...builtins(env,cat,origin),...definitions];
55
- await pushToolList(env,cat,tools,origin);
55
+ // Catalogue notifications update existing streams; the caller's response
56
+ // uses the catalogue above and does not depend on that delivery finishing.
57
+ const refresh=pushToolList(env,cat,tools,origin);
58
+ if(ctx?.waitUntil)ctx.waitUntil(refresh);
59
+ else await refresh;
56
60
  if(request.method==='GET'&&env.LOBBY) {
57
61
  if(!request.headers.get('accept')?.includes('text/event-stream'))return json({error:'Accept text/event-stream required'},406);
58
62
  const connection=caller.connection??'public:'+crypto.randomUUID();
package/worker/office.mjs CHANGED
@@ -63,13 +63,13 @@ import { toolIdentity } from './tool-identity.mjs';
63
63
  * from an office key it is ALWAYS an ask (the AI may only propose one).
64
64
  */
65
65
  import { seatsOf } from './seats.mjs';
66
- import { diagnosticsOf, gatePlayers, roomLayout } from './gate.mjs';
66
+ import { diagnosticsOf, gatePlayers, roomLayout } from './gate-routing.mjs';
67
67
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
68
68
  import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
69
69
  import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
70
70
  import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
71
71
  import { esc, layout, notFoundPage } from './site.mjs';
72
- import { confirmPage, lockedPage, officePage } from './office-page.mjs';
72
+
73
73
  import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
74
74
  import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
75
75
  import { checkRelease, performRelease, checkRefund, describeRefund, officeShopPage, ordersCsv, performRefund, shopOffice } from './shop.mjs';
@@ -142,12 +142,8 @@ export function canonical(v) {
142
142
 
143
143
  /* ------------------------------------------------------------------ player accounts (a seam) */
144
144
 
145
- let players = null;
146
- /**
147
- * The player-accounts API, handed over once by a studio's Worker that has it:
148
- * `{ get(id, env), of?(request, env), isOwner?(request, env) }` (worker/players.mjs `players` is exactly this).
149
- */
150
- export function usePlayers(api) { players = api && typeof api === 'object' ? api : null; }
145
+ import {players} from './player-registry.mjs';
146
+ export {usePlayers} from './player-registry.mjs';
151
147
 
152
148
  async function playerOf(env, id) {
153
149
  if (!players?.get || !id) return null;
@@ -554,7 +550,7 @@ export async function agentsTry(env, cat, body) {
554
550
  const n = Math.round(t.cost.neurons);
555
551
  if (n > 0) rows.push(counter(env, { metric: 'brain-neurons', subject: meta.id, n }));
556
552
  if (t.cost.micros >= 1) rows.push(counter(env, { metric: 'brain-microdollars', subject: meta.id, n: Math.round(t.cost.micros) }));
557
- await env.DB.batch(rows.filter(Boolean)).catch(() => {});
553
+ await env.DB.batch(rows.filter(Boolean));
558
554
  }
559
555
  return {
560
556
  ok: true, game: meta.id, mode, model: t.model ?? (mode === 'workers-ai' ? e.HOMIE_BRAIN_MODEL || DEFAULT_MODEL : null), engine: t.engine, provider: t.provider,
@@ -1118,7 +1114,7 @@ export async function perform(env, cat, a) {
1118
1114
  if (a.budget !== undefined) {
1119
1115
  const day = await brainDay(env);
1120
1116
  const next = { neurons: day.budget.neurons, usd: day.budget.usd, ...(a.mode === 'workers-ai' ? { neurons: Math.round(a.budget) } : { usd: Math.round(a.budget * 100) / 100 }) };
1121
- await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run().catch(() => {});
1117
+ await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run();
1122
1118
  }
1123
1119
  await pushPolicy(env, meta, settings, sv);
1124
1120
  const day = await brainDay(env);
@@ -1342,42 +1338,42 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
1342
1338
  if (path === '/_studio/office' || path === '/_studio/office/') {
1343
1339
  const cat = await catalogueOf();
1344
1340
  if (request.method !== 'GET') return new Response('method', { status: 405 });
1345
- if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
1341
+ if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'office' });
1346
1342
  // A session from before 0.13.0 lives only under /_studio/: carry it to / so the owner's games know them too.
1347
1343
  const session = await ownerSession(request, env);
1348
1344
  const secure = url.protocol === 'https:' ? '; Secure' : '';
1349
- return officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
1345
+ return (await import('./office-page.mjs')).officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
1350
1346
  }
1351
1347
  if (path === '/_studio/office/shop' || path === '/_studio/office/shop/') {
1352
1348
  const cat = await catalogueOf();
1353
1349
  if (request.method !== 'GET') return new Response('method', { status: 405 });
1354
- if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
1350
+ if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'office' });
1355
1351
  return officeShopPage(cat);
1356
1352
  }
1357
1353
  const confirm = /^\/_studio\/confirm\/(ask_[a-f0-9]{16})\/?$/.exec(path);
1358
1354
  if (confirm) {
1359
1355
  const cat = await catalogueOf();
1360
- if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'confirm', ask: confirm[1] });
1356
+ if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'confirm', ask: confirm[1] });
1361
1357
  const row = await readAsk(env, confirm[1]);
1362
- if (!row) return confirmPage(cat, null, { missing: true });
1358
+ if (!row) return (await import('./office-page.mjs')).confirmPage(cat, null, { missing: true });
1363
1359
  const ask = askView(row, cat, url.origin);
1364
- if (request.method === 'GET') return confirmPage(cat, ask);
1360
+ if (request.method === 'GET') return (await import('./office-page.mjs')).confirmPage(cat, ask);
1365
1361
  if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
1366
1362
  let yes = false;
1367
1363
  try { yes = String((await request.formData()).get('do') ?? '') === 'yes'; } catch { yes = false; }
1368
- if (ask.state !== 'pending') return confirmPage(cat, ask);
1364
+ if (ask.state !== 'pending') return (await import('./office-page.mjs')).confirmPage(cat, ask);
1369
1365
  if (!yes) {
1370
1366
  await env.DB.prepare("UPDATE office_asks SET state = 'cancelled' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
1371
- return confirmPage(cat, { ...ask, state: 'cancelled' });
1367
+ return (await import('./office-page.mjs')).confirmPage(cat, { ...ask, state: 'cancelled' });
1372
1368
  }
1373
1369
  // Claim it first, so a double tap does it once.
1374
1370
  await env.DB.prepare("UPDATE office_asks SET state = 'working' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
1375
1371
  const claimed = await readAsk(env, ask.id);
1376
- if (claimed?.state !== 'working') return confirmPage(cat, askView(claimed ?? row, cat, url.origin));
1372
+ if (claimed?.state !== 'working') return (await import('./office-page.mjs')).confirmPage(cat, askView(claimed ?? row, cat, url.origin));
1377
1373
  if (ask.action.op === 'shop-release') ask.action.by = await releaseIdentity(request, env);
1378
1374
  const result = await perform(env, cat, ask.action);
1379
1375
  await env.DB.prepare('UPDATE office_asks SET state = ?2, result = ?3 WHERE id = ?1').bind(ask.id, result.ok ? 'done' : 'failed', JSON.stringify(result).slice(0, 4000)).run();
1380
- return confirmPage(cat, { ...ask, state: result.ok ? 'done' : 'failed', result });
1376
+ return (await import('./office-page.mjs')).confirmPage(cat, { ...ask, state: result.ok ? 'done' : 'failed', result });
1381
1377
  }
1382
1378
  return null;
1383
1379
  }
@@ -28,3 +28,17 @@ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';retu
28
28
  ${op==='UPDATE'?`INSERT INTO tool_data_revisions SELECT OLD.namespace,1 WHERE OLD.namespace!=NEW.namespace ON CONFLICT(namespace) DO UPDATE SET revision=revision+1;`:''}
29
29
  END;`;}).join('\n')}
30
30
  `;
31
+
32
+ export const RUNTIME_INDEX_MIGRATION_FILE='0034_runtime_indexes.sql';
33
+ export const RUNTIME_INDEX_MIGRATION=`-- Older stream registrations used milliseconds; OAuth and dedupe already use seconds.
34
+ UPDATE mcp_kv SET expires=CAST(expires / 1000 AS INTEGER) WHERE expires>100000000000 AND (key GLOB 'tools-stream:*' OR key GLOB 'mcp-event-stream:*');
35
+ CREATE INDEX IF NOT EXISTS mcp_kv_expires ON mcp_kv(expires) WHERE expires IS NOT NULL;
36
+ `;
37
+
38
+ export const EXTRA_RUNTIME_MIGRATIONS={
39
+ "0035_payment_delivery.sql":`CREATE TABLE IF NOT EXISTS stripe_intake(id TEXT PRIMARY KEY,payload TEXT NOT NULL,origin TEXT NOT NULL,received INTEGER NOT NULL,lease INTEGER NOT NULL DEFAULT 0,done INTEGER);
40
+ CREATE INDEX IF NOT EXISTS stripe_intake_pending ON stripe_intake(lease,received,id) WHERE done IS NULL;
41
+ CREATE INDEX IF NOT EXISTS stripe_intake_done ON stripe_intake(done) WHERE done IS NOT NULL;
42
+ CREATE INDEX IF NOT EXISTS shop_events_at ON shop_events(at);
43
+ `,
44
+ };
@@ -0,0 +1,29 @@
1
+ import {STUDIO_VERSION_TAG} from './version.mjs';
2
+ import {playerAncestors} from './embed.mjs';
3
+ export function shellKey(g) {
4
+ // Never what names a build (the rules' build and state hashes, the bundle's version): those change with every
5
+ // edit, and a newer build of the same game inside the same page is exactly what the frame's own reload is for.
6
+ const { build: _build, stateHash: _state, ...room } = g?.room ?? {};
7
+ const { version: _version, ...netplay } = g?.netplay ?? {};
8
+ const text = JSON.stringify([g?.kind ?? null, g?.players ?? null, room, g?.saves ?? null, g?.agents ?? null, g?.screen ?? null, netplay, g?.launch ?? null]);
9
+ let h = 2166136261;
10
+ for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
11
+ return `${STUDIO_VERSION_TAG}-${(h >>> 0).toString(36)}`;
12
+ }
13
+ export function shellStandIn(room, key) {
14
+ const message = key ? { t: 'homie-net', what: 'shell', key } : { t: 'homie-net', what: 'rematch', room };
15
+ return `<!doctype html><meta charset="utf-8"><title>Updating</title><body style="margin:0;background:#0b0d14;color:#cfd6e6;font:15px system-ui;display:grid;place-items:center;height:100vh"><p>This game was updated. Loading it again…</p><script>try{parent.postMessage(${JSON.stringify(message).replace(/</g, '\\u003c')},'*');}catch(e){}</script>`;
16
+ }
17
+ export const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
18
+ export function frameAncestors(cat) {
19
+ const extra = (Array.isArray(cat?.studio?.site?.frameAncestors) ? cat.studio.site.frameAncestors : [])
20
+ .map((o) => { try { const u = new URL(String(o)); return u.protocol === 'https:' && u.origin === String(o).replace(/\/+$/, '') ? u.origin : null; } catch { return null; } })
21
+ .filter(Boolean).slice(0, 8);
22
+ return ["'self'", ...extra].join(' ');
23
+ }
24
+ export function embedAncestors(cat, origin, preview = false) {
25
+ const normal = playerAncestors(cat);
26
+ if (!preview || !/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(origin)) return normal;
27
+ const port = new URL(origin).port;
28
+ return `${normal === "'none'" ? '' : normal} http://localhost${port ? ':' + port : ''} http://127.0.0.1${port ? ':' + port : ''}`.trim();
29
+ }