@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.
- package/CHANGELOG.md +27 -0
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/cloudflare.mjs +16 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +28 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +42 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +54 -2785
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +16 -20
- package/worker/operational-schema.mjs +14 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +8 -28
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +35 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- 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
|
+
}
|
package/worker/lobby.mjs
ADDED
|
@@ -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
|
-
|
|
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()+
|
|
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()+
|
|
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);
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}});
|
package/worker/mcp-store.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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()
|
|
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
|
+
}
|