@homie-rocks/studio 0.55.0 → 0.56.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 (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/bin/homie-studio.mjs +1 -1
  4. package/docs/custom-routes.md +72 -0
  5. package/guides/app/SKILL.md +7 -0
  6. package/guides/playtest/scripts/playtest.mjs +7 -5
  7. package/lib/check.mjs +8 -1
  8. package/lib/cloudflare.mjs +49 -22
  9. package/lib/dev.mjs +6 -8
  10. package/lib/local-database-start.mjs +6 -0
  11. package/lib/mcp-tools.mjs +3 -3
  12. package/lib/port-check.mjs +3 -2
  13. package/lib/scaffold.mjs +2 -0
  14. package/lib/template-history.json +46 -1
  15. package/lib/worker-build.mjs +4 -1
  16. package/lib/worker-config.mjs +7 -0
  17. package/netplay/NETPLAY.md +5 -1
  18. package/netplay/netplay.ts +24 -9
  19. package/package.json +1 -1
  20. package/rules/core.ts +9 -0
  21. package/rules/host.ts +40 -11
  22. package/rules/view.ts +1 -0
  23. package/tools/TOOLS.md +6 -1
  24. package/worker/background-queue.mjs +1 -0
  25. package/worker/build-id.mjs +1 -1
  26. package/worker/custom-routes.mjs +16 -0
  27. package/worker/gate.mjs +94 -23
  28. package/worker/idle-chat.mjs +56 -0
  29. package/worker/index.mjs +2 -0
  30. package/worker/lazy-objects.mjs +3 -0
  31. package/worker/lobby-peak.mjs +44 -0
  32. package/worker/lobby-shards.mjs +43 -0
  33. package/worker/lobby-store.mjs +35 -0
  34. package/worker/lobby-summary.mjs +28 -0
  35. package/worker/lobby.mjs +86 -18
  36. package/worker/lounge-store.mjs +2 -2
  37. package/worker/match-router.mjs +74 -0
  38. package/worker/mcp-room.mjs +2 -1
  39. package/worker/mcp-tools.mjs +4 -2
  40. package/worker/office.mjs +2 -1
  41. package/worker/operational-schema.mjs +2 -0
  42. package/worker/pages.mjs +1 -1
  43. package/worker/relay-channel.mjs +119 -0
  44. package/worker/request-db.mjs +4 -3
  45. package/worker/room-chat.mjs +2 -1
  46. package/worker/room-sessions.mjs +65 -0
  47. package/worker/room.mjs +16 -9
  48. package/worker/route-auth.mjs +22 -0
  49. package/worker/routes.mjs +7 -4
  50. package/worker/runtime-support.mjs +5 -3
  51. package/worker/session-codec.mjs +24 -0
  52. package/worker/stats.mjs +2 -1
  53. package/worker/table.mjs +277 -41
  54. package/worker/version.mjs +1 -1
  55. package/worker/watch-sockets.mjs +75 -0
package/worker/lobby.mjs CHANGED
@@ -1,22 +1,25 @@
1
+ import {LobbyPeak} from './lobby-peak.mjs';
2
+ import {MatchRouter} from './match-router.mjs';
3
+ import {LOBBY_SHARDS,SHARDED_ROOM_BASE} from './lobby-shards.mjs';
4
+ import {json} from './responses.mjs';
5
+ import {lobbyStore} from './lobby-store.mjs';
1
6
  import { errorLine, versionOf } from './room-protocol.mjs';
2
7
  import { ROOM_ID } from './page-policy.mjs';
3
8
  import { SERVER_ID, pooledRoom, roomCode, roomServer } from './servers.mjs';
4
9
  import { seatCount } from './seats.mjs';
5
10
  import { counter, today } from './stats.mjs';
6
11
 
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;
12
+ import {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
13
+ export {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
14
14
 
15
15
  export class Lobby {
16
16
  constructor(ctx, env) {
17
17
  this.ctx = ctx;
18
18
  this.env = env;
19
+ this.store = lobbyStore(ctx.storage);
19
20
  this.rooms = new Map();
21
+ this.reservations=new Map();
22
+ ctx.storage.sql?.exec("CREATE TABLE IF NOT EXISTS matchmaking_reservations(id TEXT PRIMARY KEY,body TEXT NOT NULL,expires INTEGER NOT NULL)");
20
23
  /** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
21
24
  this.next = { public: 1 };
22
25
  /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
@@ -26,11 +29,16 @@ export class Lobby {
26
29
  this.seen = new Map();
27
30
  this.closed = new Map();
28
31
  ctx.blockConcurrencyWhile(async () => {
29
- const saved = await ctx.storage.get('lobby');
32
+ const saved = await this.store.load();
30
33
  if (saved) {
31
34
  this.current = saved.current ?? null;
32
35
  this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
33
36
  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: [] });
37
+ for(const r of this.rooms.values())if(r.players>0&&r.at>Date.now()-180000)this.live.set(r.name,{players:r.players,at:r.at});
38
+ if(ctx.storage.sql)for(const row of ctx.storage.sql.exec('SELECT body,expires FROM matchmaking_reservations WHERE expires>?',Date.now()).toArray()) {
39
+ const reservation=JSON.parse(row.body),room=this.rooms.get(reservation.room);
40
+ if(room && reservation.pending!==false)room.pending.push(row.expires-20000);
41
+ }
34
42
  for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
35
43
  }
36
44
  });
@@ -48,15 +56,23 @@ export class Lobby {
48
56
  if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
49
57
  let all = 0;
50
58
  for (const r of this.live.values()) all += r.players;
59
+ if(this.env.LOBBY){
60
+ const subject=game??this.game??'';if(game)this.game=game;
61
+ const target=new URL('https://lobby/peak');target.searchParams.set('game',subject);
62
+ const at=this.peakReportAt=Math.max(now,(this.peakReportAt??0)+1);
63
+ const body=JSON.stringify({shard:this.shard??0,players:all,room:players,at});
64
+ this.ctx.waitUntil(this.env.LOBBY.get(this.env.LOBBY.idFromName(subject+'/peak')).fetch(target,{method:'POST',body}).then(r=>{if(!r.ok)throw Error('peak report failed');return r.body?.cancel();}).catch(error=>console.warn('Lobby peak report failed:',error.message)));
65
+ return;
66
+ }
51
67
  const writes = [];
52
68
  if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
53
69
  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
70
  if (game) this.game = game;
55
71
  const list = writes.filter(Boolean);
56
- if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
72
+ if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(error => { this.peak = {day, players:0, room:0}; console.warn('Lobby peak write will retry on report:',error.message); }));
57
73
  }
58
74
 
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(() => {}); }
75
+ save() { const pending=this.store.save( { 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] }); if(pending)this.ctx.waitUntil(pending); }
60
76
 
61
77
  prune(now) {
62
78
  for (const [name, r] of this.rooms) {
@@ -68,6 +84,8 @@ export class Lobby {
68
84
 
69
85
  /** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
70
86
  async fetch(request) {
87
+ if(new URL(request.url).pathname==='/peak')return (this.peakStore??=new LobbyPeak(this.ctx,this.env)).fetch(request);
88
+ if(['/match','/match-policy'].includes(new URL(request.url).pathname))return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).fetch(request);
71
89
  if(new URL(request.url).pathname==='/__app_records') {
72
90
  const {AppRecordStreams}=await import('./app-streams.mjs');
73
91
  this.appStreams??=new AppRecordStreams(this.env,this.ctx);
@@ -79,7 +97,11 @@ export class Lobby {
79
97
  this.eventStreams??=new EventStreams(this.env,this.ctx);
80
98
  return this.eventStreams.fetch(request);
81
99
  }
82
- try { return await this.answer(request); } catch (error) {
100
+ try {
101
+ const response=await this.answer(request),url=new URL(request.url);
102
+ if(response.ok && request.method==='POST' && url.pathname==='/report' && this.env?.BACKGROUND && url.searchParams.get('game')) await this.scheduleSummary(url.searchParams.get('game'),Number(url.searchParams.get('shard'))||0);
103
+ return response;
104
+ } catch (error) {
83
105
  let path = '';
84
106
  try { path = new URL(request.url).pathname; } catch { /* not an address */ }
85
107
  try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-failed', game: this.game ?? null, path, error: errorLine(error) })); } catch { /* no console */ }
@@ -87,14 +109,52 @@ export class Lobby {
87
109
  }
88
110
  }
89
111
 
112
+ async scheduleSummary(game,shard=0) {
113
+ this.summaryShard=shard;
114
+ if(this.summaryGame!==game) {await this.ctx.storage.put('summary-game',{game,shard});this.summaryGame=game;}
115
+ if(!this.summaryDue) {
116
+ this.summaryDue=Date.now()+10000;
117
+ try {await this.ctx.storage.setAlarm(this.summaryDue);} catch(error) {this.summaryDue=null;throw error;}
118
+ }
119
+ }
120
+ async alarm() {
121
+ if(this.matchRouter)return this.matchRouter.alarm();
122
+ if((await this.ctx.storage.list?.({prefix:'match:',limit:1}))?.size)return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).alarm();
123
+ this.summaryDue=null;
124
+ const saved=await this.ctx.storage.get('summary-game');
125
+ const game=this.summaryGame??(typeof saved==='string'?saved:saved?.game),shard=this.summaryShard??saved?.shard??0;
126
+ if(!game||!this.env.BACKGROUND)return;
127
+ const at=Date.now();
128
+ const rooms=[...this.rooms.values()].filter(r=>r.players>0&&r.at>at-180000).map(({name,players,agents,ai,server,at})=>({name,players,agents:agents??0,ai:ai??0,server:server??roomServer(name),at}));
129
+ await this.env.BACKGROUND.send({v:1,type:'lobby.snapshot',game,shard,at,rooms});
130
+ }
131
+
132
+ reservation(key,result) {
133
+ const sql=this.ctx.storage.sql,now=Date.now();
134
+ if(sql){
135
+ if(result){sql.exec('DELETE FROM matchmaking_reservations WHERE expires<=?',now);sql.exec('INSERT OR REPLACE INTO matchmaking_reservations VALUES(?,?,?)',key,JSON.stringify({...result,pending:true}),now+20000);return;}
136
+ const row=sql.exec('SELECT body FROM matchmaking_reservations WHERE id=? AND expires>?',key,now).toArray()[0];if(!row)return null;const {pending,...saved}=JSON.parse(row.body);return saved;
137
+ }
138
+ for(const [id,row] of this.reservations)if(row.expires<=now)this.reservations.delete(id);
139
+ if(result)this.reservations.set(key,{result,expires:now+20000});
140
+ else return this.reservations.get(key)?.result;
141
+ }
142
+
90
143
  async answer(request) {
91
144
  const url = new URL(request.url);
92
145
  const now = Date.now();
146
+ this.shard=Number(url.searchParams.get('shard'))||0;
93
147
  this.prune(now);
94
148
  // REVISIONS (section 23): rooms are matched within one build of the game. A room that never said its build is
95
149
  // the build of a game that names none ('').
96
150
  const ver = versionOf(url.searchParams.get('ver')) ?? '';
97
151
  if (url.pathname === '/join') {
152
+ const shard=url.searchParams.has('shard')?Number(url.searchParams.get('shard')):null;
153
+ if(shard!==null&&(!Number.isInteger(shard)||shard<0||shard>=LOBBY_SHARDS))return json({error:'shard'},400);
154
+ const identity=url.searchParams.get('reservation');
155
+ const reservation=identity?JSON.stringify([url.searchParams.get('server'),ver,url.searchParams.get('host'),identity.slice(0,128)]):null;
156
+ const prior=reservation&&this.reservation(reservation);
157
+ if(prior && this.rooms.has(prior.room) && !this.closed.has(prior.room) && !(url.searchParams.get('not')??'').split(',').includes(prior.room))return json(prior);
98
158
  // A server build can run only its current rules. Persisted browser rooms
99
159
  // (including pre-kind records) and old server revisions must not consume
100
160
  // the pool or become the fallback of a server with roomsMax=1.
@@ -142,6 +202,7 @@ export class Lobby {
142
202
  if (fill >= max) continue;
143
203
  if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
144
204
  }
205
+ if(url.searchParams.get('peek')==='1')return json({room:best?.r.name??null});
145
206
  let room = best?.r;
146
207
  let full = false;
147
208
  const fullest = (list) => list.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), list[0]);
@@ -166,22 +227,26 @@ export class Lobby {
166
227
  }
167
228
  }
168
229
  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;
230
+ const step=server==='public'&&shard!==null?LOBBY_SHARDS:1;
231
+ let n = Math.max(this.next[server] ?? 1,step>1?SHARDED_ROOM_BASE+shard:1);
232
+ while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += step;
171
233
  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;
234
+ this.next[server] = n + step;
173
235
  this.rooms.set(room.name, room);
174
236
  }
175
237
  room.pending.push(now);
176
238
  this.save();
177
- return json({ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) });
239
+ const result={ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) };
240
+ if(reservation)this.reservation(reservation,result);
241
+ return json(result);
178
242
  }
179
243
  if (url.pathname === '/report' && request.method === 'POST') {
180
244
  const body = await request.json().catch(() => ({}));
181
245
  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 }); }
246
+ if (body.ended === true) { this.notePeak(body.room,0,url.searchParams.get('game')); this.rooms.delete(body.room); this.seen.delete(body.room); this.save(); return json({ ok: true }); }
183
247
  // The owner closed this room: matched with nobody until then (and forgotten as a public room).
184
248
  if (Number.isFinite(body.closed)) {
249
+ if(body.closed>now)this.notePeak(body.room,0,url.searchParams.get('game'));
185
250
  if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
186
251
  this.seen.delete(body.room);
187
252
  this.save();
@@ -205,17 +270,20 @@ export class Lobby {
205
270
  const grew = Math.max(0, seated - r.players);
206
271
  // The room says which build it runs (it changes only when nobody on the old one is left).
207
272
  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;
273
+ const changed = now-r.at>=60000 || !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
209
274
  r.ver = rver;
210
275
  if (body.host === 'server' || body.host === 'browser') r.host = body.host;
211
276
  r.pending.splice(0, grew);
277
+ // Retain retry ownership while retiring fulfilled seat holds. Otherwise
278
+ // eviction restores those holds on top of the already reported players.
279
+ if(grew && this.ctx.storage.sql)this.ctx.storage.sql.exec("UPDATE matchmaking_reservations SET body=json_set(body,'$.pending',json('false')) WHERE id IN (SELECT id FROM matchmaking_reservations WHERE json_extract(body,'$.room')=? AND json_extract(body,'$.pending') IS NOT 0 ORDER BY expires,id LIMIT ?)",body.room,grew);
212
280
  if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
213
281
  r.players = seated;
214
282
  r.agents = agents;
215
283
  r.ai = ai;
216
284
  r.at = now;
217
285
  this.rooms.set(r.name, r);
218
- if (changed) this.save(); // the minute's "still here" report writes nothing
286
+ if (changed) this.save(); // Refresh durable presence at most once per minute.
219
287
  return json({ ok: true });
220
288
  }
221
289
  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 })) });
@@ -116,7 +116,7 @@ export function recOf(r) {
116
116
  export async function historyOf(env, game, room, { days, before = null, limit = LOUNGE_LIMITS.page, now = Date.now() } = {}) {
117
117
  if (!env?.DB || !(days > 0)) return [];
118
118
  const since = now - days * DAY;
119
- await env.DB.prepare('DELETE FROM chat_history WHERE game = ?1 AND room = ?2 AND at < ?3').bind(game, room, since).run().catch(() => {});
119
+ await env.DB.prepare('DELETE FROM chat_history WHERE game = ?1 AND room = ?2 AND at < ?3').bind(game, room, since).run().catch(error => console.warn("Lounge retention deferred:",error.message));
120
120
  const n = Math.max(1, Math.min(LOUNGE_LIMITS.page, Math.floor(Number(limit) || LOUNGE_LIMITS.page)));
121
121
  const q = Number.isFinite(Number(before)) && before !== null
122
122
  ? env.DB.prepare('SELECT * FROM chat_history WHERE game = ?1 AND room = ?2 AND at >= ?3 AND at < ?4 ORDER BY at DESC LIMIT ?5').bind(game, room, since, Number(before), n)
@@ -192,7 +192,7 @@ export async function removeNight(env, id) {
192
192
  export async function nightsOf(env, { now = Date.now(), limit = 6 } = {}) {
193
193
  if (!env?.DB) return [];
194
194
  try {
195
- await env.DB.prepare('DELETE FROM lounge_nights WHERE starts_at + minutes * 60000 < ?1').bind(now - DAY).run().catch(() => {});
195
+ await env.DB.prepare('DELETE FROM lounge_nights WHERE starts_at + minutes * 60000 < ?1').bind(now - DAY).run().catch(error => console.warn("Lounge retention deferred:",error.message));
196
196
  const { results } = await env.DB.prepare('SELECT * FROM lounge_nights WHERE starts_at + minutes * 60000 >= ?1 ORDER BY starts_at ASC LIMIT ?2').bind(now, Math.max(1, Math.min(LOUNGE_LIMITS.nights, limit))).all();
197
197
  return (results ?? []).map((r) => ({ id: r.id, title: r.title, at: Number(r.starts_at), minutes: Number(r.minutes), note: r.note ?? null, game: r.game ?? null, on: Number(r.starts_at) <= now }));
198
198
  } catch { return []; }
@@ -0,0 +1,74 @@
1
+ import {lobbyShard,LOBBY_SHARDS} from './lobby-shards.mjs';
2
+
3
+ // Entry objects partition reservation ownership. They lease 8 routing decisions
4
+ // at a time, so the policy object sees 1/8 of join traffic, never room reports.
5
+ // A quiet game refills existing pools before opening shard zero. Spread above
6
+ // 128 observed joins/s: below half the slowest single-coordinator throughput
7
+ // measured by the deployed fixture (1024 durable joins, 32 concurrent).
8
+ // Count consumed leases, not all eight slots in a newly issued lease.
9
+ export class MatchRouter {
10
+ constructor(ctx,env) { this.ctx=ctx;this.env=env;this.pending=new Map();this.routes=new Map(); }
11
+ async fetch(request) {
12
+ const url=new URL(request.url),now=Date.now();
13
+ if(url.pathname==='/match-policy') {
14
+ this.demand=(this.demand??[]).filter(row=>now-row.at<1000);
15
+ this.demand.push({at:now,joins:Math.max(1,Math.min(8,Number(url.searchParams.get('used'))||1))});
16
+ return Response.json({wide:this.demand.reduce((n,row)=>n+row.joins,0)>128,until:now+1000});
17
+ }
18
+ const identity=url.searchParams.get('reservation');
19
+ const key=JSON.stringify([identity,url.searchParams.get('ver'),url.searchParams.get('host'),url.searchParams.get('not')]);
20
+ if(this.pending.has(key))return Response.json(await this.pending.get(key));
21
+ const task=this.join(url,key,now);this.pending.set(key,task);
22
+ try{return Response.json(await task);}finally{this.pending.delete(key);}
23
+ }
24
+ async join(url,key,now) {
25
+ const game=url.searchParams.get('game');
26
+ for(const [id,row] of this.routes)if(row.until<=now)this.routes.delete(id);
27
+ let route=this.routes.get(key)??await this.ctx.storage.get('match:'+key);
28
+ if(!route||route.until<=now) {
29
+ // Serialize lease consumption, not requests to the matching shards.
30
+ const previous=this.leasing??Promise.resolve();
31
+ const lease=previous.then(async()=>{
32
+ if(!this.policy||this.policy.until<=now||!this.remaining){
33
+ const target=new URL('https://lobby/match-policy');target.searchParams.set('game',game);target.searchParams.set('used',String(this.policy?8-this.remaining:1));
34
+ const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(game+'/match-policy')).fetch(target);
35
+ if(!response.ok)throw Error('Matching policy unavailable');
36
+ this.policy=await response.json();this.remaining=8;
37
+ }
38
+ this.remaining--;
39
+ return this.policy.wide?lobbyShard(url.searchParams.get('reservation')):null;
40
+ });
41
+ this.leasing=lease.then(()=>{},()=>{});
42
+ let shard=await lease;
43
+ if(shard===null) {
44
+ // Read-only discovery: persist ownership before reserving a seat. Quiet
45
+ // joins may refill any pool left open by a burst, including after eviction.
46
+ shard=0;
47
+ for(let candidate=0;candidate<LOBBY_SHARDS;candidate++){
48
+ const target=new URL(url);target.pathname='/join';target.searchParams.set('peek','1');target.searchParams.set('shard',String(candidate));
49
+ const name=candidate?`${game}/shard/${candidate}`:game;
50
+ const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(name)).fetch(target);
51
+ if(!response.ok)throw Error('Matching discovery unavailable');
52
+ if((await response.json()).room){shard=candidate;break;}
53
+ }
54
+ }
55
+ route={shard,until:now+20000};
56
+ // Persist ownership before forwarding: a lost response or entry eviction
57
+ // must retry the same shard, even while the game changes routing policy.
58
+ await this.ctx.storage.put('match:'+key,route);this.routes.set(key,route);
59
+ await this.ctx.storage.setAlarm?.(now+20000);
60
+ }
61
+ url.pathname='/join';url.searchParams.set('shard',String(route.shard));
62
+ const name=route.shard?`${game}/shard/${route.shard}`:game;
63
+ const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(name)).fetch(url);
64
+ if(!response.ok)throw Error('Matching shard unavailable');
65
+ return response.json();
66
+ }
67
+ async alarm() {
68
+ const now=Date.now();let next=Infinity;
69
+ for(const [key,row] of await this.ctx.storage.list({prefix:'match:'})) {
70
+ if(row.until<=now)await this.ctx.storage.delete(key);else next=Math.min(next,row.until);
71
+ }
72
+ if(Number.isFinite(next))await this.ctx.storage.setAlarm(next);
73
+ }
74
+ }
@@ -1,3 +1,4 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  import {signControl,settingsOf,launchOf,seatsFor} from './office.mjs';
2
3
  import {appAccess} from './app-records.mjs';
3
4
  import {withToolIdentity} from './tool-identity.mjs';
@@ -23,7 +24,7 @@ export async function remoteSeat(env,cat,caller,op,args) {
23
24
  if(refusal)throw new Error(refusal.message);
24
25
  let room=args.room;
25
26
  if(!room&&op==='sit'){
26
- const joined=await env.LOBBY.get(env.LOBBY.idFromName(meta.id)).fetch(`https://lobby/join?max=${max}&server=${serverId}&agent=1&ai=${policy.aiSeats+policy.guides}`,{method:'POST'});room=(await joined.json()).room;
27
+ const joined=await lobbyFor(env,meta.id).fetch(`https://lobby/join?max=${max}&server=${serverId}&agent=1&ai=${policy.aiSeats+policy.guides}`,{method:'POST'});room=(await joined.json()).room;
27
28
  }
28
29
  if(!/^[A-Za-z0-9_-]{1,32}$/.test(room??''))throw new Error('No room with people; an AI never starts a room');
29
30
  const ctl=await signControl(env,{op:'mcp-seat',game:meta.id,room,args:{...args,operation:op,connection:caller.connection,person:caller.id,name:caller.name??'Studio AI'}});
@@ -78,8 +78,10 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
78
78
  fetch:globalThis.fetch.bind(globalThis),
79
79
  // A named secret may authenticate an HTTPS request; its value is never returned.
80
80
  async fetchWithSecret(name,url,init={}) {
81
- const rule=cat.studio?.mcp?.secrets?.[name]; const target=new URL(url);
82
- if(!rule || target.protocol!=='https:' || target.origin!==rule.origin || (!caller.owner&&!rule.tools?.includes(definition.name))) throw new Error('secret capability refused');
81
+ const rule=cat.studio?.mcp?.secrets?.[name];
82
+ const permittedOrigin=rule?.origin==='self'?new URL(origin).origin:rule?.origin;
83
+ const target=new URL(url,rule?.origin==='self'?permittedOrigin:undefined);
84
+ if(!rule || target.protocol!=='https:' || target.username || target.password || target.origin!==permittedOrigin || (!caller.owner&&!rule.tools?.includes(definition.name))) throw new Error('secret capability refused');
83
85
  const secret=env[name];if(typeof secret!=='string')throw new Error('secret unavailable');
84
86
  const headers=new Headers(init.headers);headers.set(rule.header??'authorization',`${rule.prefix??'Bearer '}${secret}`);
85
87
  const res=await fetch(target,{...init,headers,redirect:'error'});
package/worker/office.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
2
3
  import { selling } from './extensions.mjs';
3
4
  import { purchaseOrderIfPresent } from './shop.mjs';
@@ -410,7 +411,7 @@ export async function redeemInvite(request, env, url, cat, meta, settings) {
410
411
 
411
412
  /* ------------------------------------------------------------------ talking to rooms */
412
413
 
413
- const lobbyOf = (env, game) => env.LOBBY.get(env.LOBBY.idFromName(game));
414
+ const lobbyOf = (env, game) => lobbyFor(env,game);
414
415
  const tableOf = (env, game, room) => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
415
416
 
416
417
  /** The rooms of one game with people in them now, as its Lobby last heard (public and named). */
@@ -36,6 +36,8 @@ CREATE INDEX IF NOT EXISTS mcp_kv_expires ON mcp_kv(expires) WHERE expires IS NO
36
36
  `;
37
37
 
38
38
  export const EXTRA_RUNTIME_MIGRATIONS={
39
+ "0036_lobby_summaries.sql":`CREATE TABLE IF NOT EXISTS lobby_summaries(game TEXT PRIMARY KEY,rooms TEXT NOT NULL,updated INTEGER NOT NULL);
40
+ `,
39
41
  "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
42
  CREATE INDEX IF NOT EXISTS stripe_intake_pending ON stripe_intake(lease,received,id) WHERE done IS NULL;
41
43
  CREATE INDEX IF NOT EXISTS stripe_intake_done ON stripe_intake(done) WHERE done IS NOT NULL;
package/worker/pages.mjs CHANGED
@@ -983,7 +983,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
983
983
  try { arrived = performance.getEntriesByType('navigation')[0].type; } catch (e) {}
984
984
  askedRoom = kept && (arrived === 'reload' || arrived === 'back_forward') && EMBED_ROOM.test(kept.room || '') ? kept.room : null;
985
985
  }
986
- var lobbyQ = [];
986
+ var lobbyQ = ['b=' + encodeURIComponent(roomKey)];
987
987
  if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
988
988
  if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
989
989
  // Once the game has the screen, the chip says the room's facts for a few seconds, then gets out of the way.
@@ -0,0 +1,119 @@
1
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
2
+ // Retry transport failures in order. Queue limits still bound memory during outages.
3
+ export async function retryRelay(work, alive = () => true, wait = sleep) {
4
+ for (let attempt = 0; alive(); attempt++) {
5
+ try { return await work(); } catch (error) {
6
+ if (!alive()) return;
7
+ await wait(Math.min(5000, 100 * 2 ** Math.min(attempt, 6)));
8
+ }
9
+ }
10
+ }
11
+
12
+ /** Ordered input batches over bindings. A completed request holds no connection;
13
+ * output travels on a separate stream, never a recursive callback chain. */
14
+ export function relayChannel(ctx, deliver, {sleep: wait = sleep} = {}) {
15
+ const listeners = new Map();
16
+ let chain = Promise.resolve(), closed = false, pending = 0, pendingBytes = 0, live = null, sequence = 0;
17
+ const emit = (name, event) => { for (const fn of listeners.get(name) ?? []) fn(event); };
18
+ return {
19
+ accept() {},
20
+ addEventListener(name, fn) { const list = listeners.get(name) ?? []; list.push(fn); listeners.set(name, list); },
21
+ emit,
22
+ async attachLive(socket) {
23
+ socket.addEventListener('message',event=>emit('message',event));
24
+ socket.addEventListener('close',()=>{try{socket.close(1000,'detached');}catch{}if(live===socket){live=null;emit('detached',{});}});
25
+ socket.addEventListener('error',()=>{if(live===socket){live=null;emit('detached',{});}});
26
+ await chain;
27
+ if(closed){socket.close(1000,'closed');return;}
28
+ if(socket.readyState!==1)return;
29
+ live=socket;
30
+ emit('drain',{});
31
+ },
32
+ isLive:()=>Boolean(live),
33
+ canSend(size) { return Boolean(live) || pendingBytes+size<=4*1024*1024; },
34
+ send(data) {
35
+ if(live){try{if(live.readyState===1){live.send(data);return;}}catch{ /* Retry over the idle transport. */ }live=null;}
36
+ if (closed) throw new Error('Relay channel closed');
37
+ const size=data.length;
38
+ if (pendingBytes+size>4*1024*1024) throw new Error('Relay channel queue limit');
39
+ pending++;pendingBytes+=size;
40
+ const id = ++sequence;
41
+ chain = chain.then(async () => {
42
+ if (closed) return;
43
+ await retryRelay(async () => {
44
+ const response = await deliver(data, id);
45
+ await response.body?.cancel();
46
+ if (!response.ok) throw new Error('Relay delivery failed: ' + response.status);
47
+ }, () => !closed, wait);
48
+ }).catch(error => { if (!closed) { closed = true; emit('error', {error}); } }).finally(() => { pending--;pendingBytes-=size;emit('drain',{}); });
49
+ ctx.waitUntil(chain);
50
+ },
51
+ close(code, reason) { if (!closed) { closed = true;live?.close(code,reason);live=null;emit('close', {code, reason}); } },
52
+ };
53
+ }
54
+
55
+ /** A Table's output is ordered on one stream while active. Closing the stream
56
+ * detaches transport, not logical players. An alarm or a player's next message
57
+ * can request another stream without sending a new welcome. */
58
+ export function relayStream(ctx, wake, {sleep: wait = sleep} = {}) {
59
+ const listeners = new Map(), encoder = new TextEncoder();
60
+ const limit = 4 * 1024 * 1024;
61
+ let controller = null, live = null, queued = [], bytes = 0, waking = false, closed = false;
62
+ const emit = (name, event) => { for (const fn of listeners.get(name) ?? []) fn(event); };
63
+ const request = () => {
64
+ if (waking || closed) return;
65
+ waking = true;
66
+ ctx.waitUntil(retryRelay(wake, () => !closed && waking, wait));
67
+ };
68
+ return {
69
+ accept() {}, emit,
70
+ canSend(size, data) {
71
+ const needed=data===undefined?size*3+1:encoder.encode(data+'\n').byteLength;
72
+ return !closed && (Boolean(live) || needed+(controller?limit-controller.desiredSize:bytes)<=limit);
73
+ },
74
+ addEventListener(name, fn) { const list=listeners.get(name)??[];list.push(fn);listeners.set(name,list); },
75
+ send(data) {
76
+ if(live){try{if(live.readyState===1){live.send(data);return;}}catch{ /* Retry over the idle transport. */ }live=null;}
77
+ const chunk = encoder.encode(data+'\n');
78
+ if (chunk.byteLength + (controller ? limit-controller.desiredSize : bytes) > limit) throw new Error('Relay output queue limit');
79
+ if (controller) controller.enqueue(chunk);
80
+ else { queued.push(chunk);bytes+=chunk.byteLength;request(); }
81
+ },
82
+ attachLive(socket) {
83
+ controller?.close();controller=null;live?.close(1000,'replaced');live=socket;waking=false;emit('drain',{});
84
+ for(const chunk of queued)socket.send(new TextDecoder().decode(chunk).trimEnd());queued=[];bytes=0;
85
+ socket.addEventListener('close',()=>{try{socket.close(1000,'detached');}catch{}if(live===socket)live=null;});
86
+ socket.addEventListener('error',()=>{if(live===socket)live=null;});
87
+ },
88
+ open() {
89
+ controller?.close();
90
+ let owned;
91
+ return new ReadableStream({
92
+ start(next) { owned=next;controller=next;waking=false;for(const chunk of queued)next.enqueue(chunk);queued=[];bytes=0; },
93
+ pull() { emit('drain',{}); },
94
+ cancel() { if(controller===owned)controller=null; },
95
+ },{highWaterMark:limit,size:chunk=>chunk.byteLength});
96
+ },
97
+ sleep() { controller?.close();controller=null;const socket=live;live=null;socket?.close(1000,'room idle'); },
98
+ close(code,reason) {
99
+ if(closed)return;closed=true;
100
+ if(live){try{live.send(JSON.stringify([['link-close',code,reason]]));}catch{}live.close(code,reason);live=null;}
101
+ if(controller){controller.enqueue(encoder.encode(JSON.stringify([['link-close',code,reason]])+'\n'));controller.close();}
102
+ controller=null;queued=[];bytes=0;emit('close',{code,reason});
103
+ },
104
+ };
105
+ }
106
+
107
+ export async function readRelay(response, socket) {
108
+ if (!response.ok || !response.body) throw new Error('Relay stream refused');
109
+ const reader=response.body.getReader(),decoder=new TextDecoder();let pending='';
110
+ try {
111
+ for (;;) {
112
+ const {value,done}=await reader.read();if(done)break;
113
+ pending+=decoder.decode(value,{stream:true});
114
+ let end;while((end=pending.indexOf('\n'))>=0){if(end>4*1024*1024)throw new Error('Relay frame limit');const data=pending.slice(0,end);pending=pending.slice(end+1);socket.emit('message',{data});}
115
+ if(pending.length>4*1024*1024)throw new Error('Relay frame limit');
116
+ }
117
+ if(pending)throw new Error('Incomplete relay frame');
118
+ } finally { await reader.cancel().catch(()=>{}); }
119
+ }
@@ -1,3 +1,4 @@
1
+ import {LIVE_ROOMS_QUERY} from './lobby-summary.mjs';
1
2
  export const METADATA_REVISION=Symbol('metadata revision');
2
3
  export const ORIGINAL_DB=Symbol('original database');
3
4
  export const MUTATIONS=Symbol('request mutations');
@@ -15,7 +16,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
15
16
  // any bookmark returned by an earlier request without trusting stale auth.
16
17
  if(DB===binding&&typeof DB.withSession==='function')DB=DB.withSession('first-primary');
17
18
  let revision,records,rows,liveRooms;
18
- const prefetchRooms=false;
19
+ const prefetchRooms=state.summaries&&/^\/(?:api\/(?:rooms|games)|\.well-known\/homie-studio\.json)?\/?$/.test(new URL(request.url).pathname);
19
20
  const url=new URL(request.url),match=request.method==='GET'&&/^\/([a-z0-9][a-z0-9-]{0,39})\/api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(url.pathname);
20
21
  const recordQuery=match&&(match[3]?'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3':'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id');
21
22
  const stamp=()=>revision??=(async()=>{
@@ -27,7 +28,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
27
28
  const cold=!cached;
28
29
  const statements=[query,...(cold?queries.map(sql=>DB.prepare(sql)):[])];
29
30
  if(recordQuery)statements.push(DB.prepare(recordQuery).bind(...match.slice(1).filter(x=>x!==undefined)));
30
- if(prefetchRooms)statements.push(DB.prepare('SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'));
31
+ if(prefetchRooms)statements.push(DB.prepare(LIVE_ROOMS_QUERY));
31
32
  const result=await DB.batch(statements);
32
33
  if(prefetchRooms)liveRooms=result.at(-1);
33
34
  let version=result[0].results?.[0]?.value??null;
@@ -46,7 +47,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
46
47
  const prepare=(sql,args=[])=>{
47
48
  const original=()=>{const stmt=DB.prepare(sql);return args.length?stmt.bind(...args):stmt;};
48
49
  const read=async()=>{
49
- if(sql==='SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'&&prefetchRooms){await stamp();return liveRooms;}
50
+ if(sql===LIVE_ROOMS_QUERY&&prefetchRooms){await stamp();return liveRooms;}
50
51
  if(sql===recordQuery&&JSON.stringify(args)===JSON.stringify(match.slice(1).filter(x=>x!==undefined))){await stamp();return records;}
51
52
  if(!metadataQueries.has(sql))return original().all();
52
53
  await stamp();
@@ -130,7 +130,7 @@ onChat({ client = null, conn = null }, m) {
130
130
  if (this.room.chatPending >= CHAT_LIMITS.pending) return reply('busy');
131
131
  this.room.chatPending += 1;
132
132
  const t0 = now;
133
- Promise.resolve()
133
+ const reviewing=Promise.resolve()
134
134
  .then(() => this.room.review(rec.text, { room: this.room.code, links: rules.links }))
135
135
  .then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
136
136
  .then((v) => {
@@ -150,6 +150,7 @@ onChat({ client = null, conn = null }, m) {
150
150
  if (this.room.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
151
151
  this.room.publishChat(rec, who.conn, n);
152
152
  });
153
+ this.room.waitUntil?.(reviewing);
153
154
  return { ok: true, pending: true };
154
155
  }
155
156
  return this.room.publishChat(rec, who.conn, n);
@@ -0,0 +1,65 @@
1
+ /** Hibernation keeps transports outside JavaScript. Only data crosses this
2
+ * boundary; callbacks, bindings and sockets are reconstructed by the Table.
3
+ */
4
+ // Only protocol identity, admission, controls and round deadlines survive here.
5
+ // World snapshots/checkpoints belong to the existing host/room save, not sessions.
6
+ const fields = new Set(`rawVocab vocab snapAt rates agentsAloneMs voteMs voteCooldownMs autoVoteMs policy level levelBy caps vote lastVoteOpen lastAutoVote lastHumanAt agentsOut officeDirty endedMatch code rules tickHz seatCap maxPlayers maxScreens perIp stallMs silentMs idleMs holdMs forgetMs persistMs gameVer currentVer occSeq seats hostId hostSince lastJoinAt lastRound lastRoster emptySince preferHost seatsDirty persistDirty lastPersistAt bans mutes announcement closedUntil closedWhy openedAt askedMax regate decideTimes defaultChat`.split(' '));
7
+ function urlDelta(base, value) {
8
+ const a=new URL(base),b=new URL(value),changes=[];
9
+ for(const key of new Set([...a.searchParams.keys(),...b.searchParams.keys()])) {
10
+ const x=a.searchParams.getAll(key),y=b.searchParams.getAll(key);
11
+ if(JSON.stringify(x)!==JSON.stringify(y))changes.push([key,y]);
12
+ }
13
+ return {path:b.pathname,changes};
14
+ }
15
+ function clientURL(base, delta) {
16
+ const url=new URL(base);url.pathname=delta.path;
17
+ for(const [key,values] of delta.changes){url.searchParams.delete(key);for(const value of values)url.searchParams.append(key,value);}
18
+ return url.toString();
19
+ }
20
+ import {isQa} from './stats.mjs';
21
+ const qa = agent => isQa({headers:new Headers({'user-agent':agent??''})})?'homie-house-qa':null;
22
+ import {encodeSession as encode,decodeSession as decode} from './session-codec.mjs';
23
+ const legacyDecode=value=>JSON.parse(value,(_key,item)=>item?.__roomMap?new Map(item.__roomMap):item?.__roomSet?new Set(item.__roomSet):item?.__roomNumber?item.__roomNumber*Infinity:item);
24
+ export function roomSessions(storage) {
25
+ const sql=storage.sql;let previous=new Map();
26
+ return {
27
+ read() {
28
+ if(!sql)return null;
29
+ sql.exec('CREATE TABLE IF NOT EXISTS room_sessions(key TEXT PRIMARY KEY,value TEXT NOT NULL)');
30
+ previous=new Map(sql.exec('SELECT key,value FROM room_sessions').toArray().map(row=>[row.key,row.value]));
31
+ if(!previous.has('version'))return null;
32
+ if(!['1','2','3'].includes(previous.get('version')))throw new Error('Unsupported room session snapshot');
33
+ const read=previous.get('version')==='1'?legacyDecode:decode;
34
+ const table=previous.has('table')?read(previous.get('table')):{};
35
+ if(previous.get('version')==='3')table.relayDescriptors=[...previous].filter(([key])=>key.startsWith('relay:')).map(([key,value])=>{
36
+ const relay=read(value),prefix='relay-client:'+key.slice(6)+':';
37
+ relay.clients=[...previous].filter(([key])=>key.startsWith(prefix)).map(([,value])=>{const [id,clientId,delta,ip,agent]=read(value);return [id,clientId,clientURL(relay.url,delta),ip,qa(agent)];});
38
+ return relay;
39
+ });
40
+ return {table,fields:new Map([...previous].filter(([key])=>key.startsWith('room:')).map(([key,value])=>[key.slice(5),read(value)])),clients:new Map([...previous].filter(([key])=>key.startsWith('client:')).map(([key,value])=>[key.slice(7),read(value)]))};
41
+ },
42
+ write(room,table = {}) {
43
+ if(!sql)throw new Error('Room hibernation requires SQLite storage');
44
+ const {relayDescriptors=[],...metadata}=table;
45
+ const next=new Map([['version','3'],['table',encode(metadata)]]);
46
+ relayDescriptors.forEach((relay,index)=>{
47
+ next.set('relay:'+index,encode({url:relay.url}));
48
+ for(const [id,clientId,url,ip,agent] of relay.clients)next.set('relay-client:'+index+':'+id,encode([id,clientId,urlDelta(relay.url,url),ip,qa(agent)]));
49
+ });
50
+ for(const [key,value] of Object.entries(room))if(fields.has(key)&&value!==undefined&&typeof value!=='function')next.set('room:'+key,encode(value));
51
+ for(const [id,client] of room.clients){const {conn,lastSeen,probe,readyAt,ingressRateAt,rttWindow,rttReadAt,rttToldAt,measuredRtt,...state}=client;
52
+ if(state.rates instanceof Map)state.rates=new Map([...state.rates].filter(([key])=>key!=='ping'&&key!=='ping:view'));next.set('client:'+id,encode(state));}
53
+ storage.transactionSync(()=>{
54
+ for(const [key,value] of next)if(previous.get(key)!==value)sql.exec('INSERT INTO room_sessions VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',key,value);
55
+ for(const key of previous.keys())if(!next.has(key))sql.exec('DELETE FROM room_sessions WHERE key=?',key);
56
+ });
57
+ previous=next;
58
+ },
59
+ clear() {if(sql&&previous.size){sql.exec('DELETE FROM room_sessions');previous.clear();}},
60
+ };
61
+ }
62
+ export function restoreRoomFields(room,snapshot) {
63
+ if(!snapshot)return;
64
+ for(const [key,value] of snapshot.fields)if(fields.has(key)&&Object.hasOwn(room,key)&&typeof room[key]!=='function')room[key]=value;
65
+ }