@homie-rocks/studio 0.54.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 (104) hide show
  1. package/CHANGELOG.md +66 -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/background-queue.mjs +31 -0
  8. package/lib/build.mjs +4 -1
  9. package/lib/check.mjs +8 -1
  10. package/lib/cloudflare.mjs +65 -23
  11. package/lib/dev.mjs +6 -8
  12. package/lib/local-database-start.mjs +6 -0
  13. package/lib/mcp-tools.mjs +3 -3
  14. package/lib/port-check.mjs +3 -2
  15. package/lib/read-replication.mjs +26 -0
  16. package/lib/scaffold.mjs +30 -10
  17. package/lib/shop.mjs +7 -0
  18. package/lib/static-assets.mjs +27 -0
  19. package/lib/template-history.json +87 -0
  20. package/lib/worker-build.mjs +16 -4
  21. package/lib/worker-config.mjs +10 -1
  22. package/netplay/NETPLAY.md +5 -1
  23. package/netplay/netplay.ts +24 -9
  24. package/package.json +1 -1
  25. package/rules/core.ts +9 -0
  26. package/rules/host.ts +40 -11
  27. package/rules/view.ts +1 -0
  28. package/tools/TOOLS.md +6 -1
  29. package/worker/analytics.mjs +84 -0
  30. package/worker/app-frame.mjs +1 -1
  31. package/worker/app-graphics.mjs +1 -1
  32. package/worker/app-streams.mjs +3 -3
  33. package/worker/asset-paths.mjs +2 -0
  34. package/worker/background-queue.mjs +31 -0
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/built-metadata.mjs +2 -0
  37. package/worker/catalogue.mjs +18 -0
  38. package/worker/chat-page.mjs +1 -1
  39. package/worker/custom-routes.mjs +16 -0
  40. package/worker/embed.mjs +1 -1
  41. package/worker/event-resources.mjs +23 -11
  42. package/worker/function-registry.mjs +4 -0
  43. package/worker/functions.mjs +7 -5
  44. package/worker/gate-routing.mjs +61 -0
  45. package/worker/gate.mjs +106 -91
  46. package/worker/hosted-registry.mjs +11 -0
  47. package/worker/hosted.mjs +3 -17
  48. package/worker/idle-chat.mjs +56 -0
  49. package/worker/index.mjs +56 -2785
  50. package/worker/lazy-objects.mjs +43 -0
  51. package/worker/lobby-limits.mjs +2 -0
  52. package/worker/lobby-peak.mjs +44 -0
  53. package/worker/lobby-shards.mjs +43 -0
  54. package/worker/lobby-store.mjs +35 -0
  55. package/worker/lobby-summary.mjs +28 -0
  56. package/worker/lobby.mjs +319 -0
  57. package/worker/lounge-store.mjs +2 -2
  58. package/worker/lounge.mjs +3 -3
  59. package/worker/match-router.mjs +74 -0
  60. package/worker/mcp-event-streams.mjs +3 -3
  61. package/worker/mcp-events.mjs +4 -4
  62. package/worker/mcp-room.mjs +2 -1
  63. package/worker/mcp-store.mjs +12 -1
  64. package/worker/mcp-tools.mjs +4 -2
  65. package/worker/mcp.mjs +5 -1
  66. package/worker/office.mjs +18 -21
  67. package/worker/operational-schema.mjs +16 -0
  68. package/worker/page-policy.mjs +29 -0
  69. package/worker/pages.mjs +9 -29
  70. package/worker/parts.mjs +2 -1
  71. package/worker/payment-recovery.mjs +33 -19
  72. package/worker/player-registry.mjs +9 -0
  73. package/worker/players.mjs +8 -7
  74. package/worker/purchase-machine.mjs +5 -3
  75. package/worker/reconcile-guard.mjs +22 -0
  76. package/worker/relay-channel.mjs +119 -0
  77. package/worker/request-db.mjs +36 -10
  78. package/worker/responses.mjs +18 -0
  79. package/worker/room-chat.mjs +217 -0
  80. package/worker/room-controls.mjs +296 -0
  81. package/worker/room-lifecycle.mjs +10 -0
  82. package/worker/room-protocol.mjs +197 -0
  83. package/worker/room-sessions.mjs +65 -0
  84. package/worker/room.mjs +54 -740
  85. package/worker/rooms-cache.mjs +19 -0
  86. package/worker/route-auth.mjs +22 -0
  87. package/worker/routes.mjs +670 -0
  88. package/worker/runtime-support.mjs +447 -0
  89. package/worker/seats.mjs +1 -1
  90. package/worker/selling/index.mjs +1 -0
  91. package/worker/selling/runtime.mjs +7 -5
  92. package/worker/service-run.mjs +1 -1
  93. package/worker/session-codec.mjs +24 -0
  94. package/worker/shop-links.mjs +1 -1
  95. package/worker/shop-page.mjs +1 -0
  96. package/worker/shop.mjs +27 -20
  97. package/worker/stats.mjs +9 -7
  98. package/worker/stream-objects.mjs +18 -0
  99. package/worker/stripe-intake.mjs +79 -0
  100. package/worker/stripe.mjs +3 -0
  101. package/worker/table.mjs +1448 -0
  102. package/worker/tool-registry.mjs +10 -0
  103. package/worker/version.mjs +1 -1
  104. package/worker/watch-sockets.mjs +75 -0
@@ -0,0 +1,43 @@
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
+ async webSocketMessage(...args) {await this.ready;return this.gate.webSocketMessage(...args);}
27
+ async webSocketClose(...args) {await this.ready;return this.gate.webSocketClose(...args);}
28
+ async webSocketError(...args) {await this.ready;return this.gate.webSocketError(...args);}
29
+ }
30
+ export class Concentrator extends Gate {}
31
+
32
+ export class AppRecordHub {
33
+ constructor(ctx,env) {
34
+ this.ready=ctx.blockConcurrencyWhile(async()=>{this.hub=new (await import('./stream-objects.mjs')).AppRecordHub(ctx,env);});
35
+ }
36
+ async fetch(request) {await this.ready;return this.hub.fetch(request);}
37
+ }
38
+ export class EventHub {
39
+ constructor(ctx,env) {
40
+ this.ready=ctx.blockConcurrencyWhile(async()=>{this.hub=new (await import('./stream-objects.mjs')).EventHub(ctx,env);});
41
+ }
42
+ async fetch(request) {await this.ready;return this.hub.fetch(request);}
43
+ }
@@ -0,0 +1,2 @@
1
+ export const LOBBY_APP_VERSIONS = 8;
2
+ export const LOBBY_ROOMS_MAX = 512;
@@ -0,0 +1,44 @@
1
+ import {counter,today} from './stats.mjs';
2
+
3
+ // One total per bookkeeping shard, never a sum of independent historical peaks.
4
+ // Reports carry their observation time so a delayed request cannot rewind a shard.
5
+ export class LobbyPeak {
6
+ constructor(ctx,env) {this.ctx=ctx;this.env=env;this.chain=Promise.resolve();}
7
+ fetch(request) {
8
+ const task=this.chain.then(()=>this.answer(request));this.chain=task.catch(()=>{});return task;
9
+ }
10
+ async flush() {
11
+ const state=this.state;
12
+ const writes=[state.players>(state.publishedPlayers??0)&&counter(this.env,{metric:'peak',subject:this.subject,n:state.players}),state.room>(state.publishedRoom??0)&&counter(this.env,{metric:'peak-room',subject:this.subject,n:state.room})].filter(Boolean);
13
+ if(writes.length)await this.env.DB.batch(writes);
14
+ // Current totals are volatile. Reusing an old high-water snapshot's shard
15
+ // counts after eviction would incorrectly add players who already left.
16
+ await this.ctx.storage.put('concurrent-peak',{day:state.day,players:state.players,room:state.room,publishedPlayers:state.players,publishedRoom:state.room});
17
+ state.publishedPlayers=state.players;state.publishedRoom=state.room;this.dirty=false;
18
+ }
19
+ schedule() {
20
+ if(this.flushing)return;
21
+ const task=new Promise(resolve=>setTimeout(resolve,1000)).then(()=>{
22
+ const flush=this.chain.then(()=>this.flush());this.chain=flush.catch(()=>{});return flush;
23
+ }).catch(error=>{this.dirty=true;console.warn('Lobby peak write will retry:',error.message);}).finally(()=>{this.flushing=null;});
24
+ this.flushing=task;this.ctx.waitUntil(task);
25
+ }
26
+ async answer(request) {
27
+ if(!this.state)this.state={...await this.ctx.storage.get('concurrent-peak')??{day:today(),players:0,room:0},shards:{}};
28
+ const state=this.state;
29
+ if(state.day!==today()){state.day=today();state.players=0;state.room=0;state.publishedPlayers=0;state.publishedRoom=0;this.dirty=true;}
30
+ if(request.method==='POST') {
31
+ const row=await request.json(),prior=state.shards[row.shard];
32
+ if(!Number.isInteger(row.shard)||row.shard<0||row.shard>=16||!Number.isFinite(row.at)||!Number.isFinite(row.players)||row.players<0)return Response.json({error:'report'},{status:400});
33
+ if(!prior||row.at>prior.at){
34
+ state.shards[row.shard]={players:row.players,at:row.at};
35
+ const concurrent=Object.values(state.shards).filter(r=>r.at>Date.now()-180000).reduce((n,r)=>n+r.players,0);
36
+ const players=Math.max(state.players,concurrent),room=Math.max(state.room,Number(row.room)||0);
37
+ if(players>state.players||room>state.room)this.dirty=true;
38
+ state.players=players;state.room=room;this.subject=new URL(request.url).searchParams.get('game');
39
+ }
40
+ }
41
+ if(this.dirty){if(this.ctx.waitUntil)this.schedule();else await this.flush();}
42
+ return Response.json({players:state.players,room:state.room});
43
+ }
44
+ }
@@ -0,0 +1,43 @@
1
+ import {roomServer} from './servers.mjs';
2
+ export const LOBBY_SHARDS=16;
3
+ export const SHARDED_ROOM_BASE=100000000;
4
+ export function lobbyShard(key) {
5
+ let hash=2166136261;for(const c of String(key))hash=Math.imul(hash^c.charCodeAt(0),16777619);
6
+ return (hash>>>0)%LOBBY_SHARDS;
7
+ }
8
+ export function roomShard(room) {
9
+ const server=roomServer(room);
10
+ if(server!=='public')return lobbyShard(server);
11
+ const n=/^pub-(\d+)$/.exec(room)?.[1];
12
+ return n ? Number(n)>=SHARDED_ROOM_BASE?(Number(n)-SHARDED_ROOM_BASE)%LOBBY_SHARDS:0 : lobbyShard(room);
13
+ }
14
+ export function lobbyFor(env,game,identity='') {
15
+ const stub=shard=>env.LOBBY.get(env.LOBBY.idFromName(shard?`${game}/shard/${shard}`:game));
16
+ const send=(shard,url,init)=>{const target=new URL(url);target.searchParams.set('shard',String(shard));return stub(shard).fetch(target.toString(),init);};
17
+ return {async fetch(input,init) {
18
+ const url=new URL(typeof input==='string'?input:input.url??input);
19
+ if(url.pathname==='/join') {
20
+ const server=url.searchParams.get('server')??'public';
21
+ const key=identity||url.searchParams.get('b')||crypto.randomUUID();
22
+ const shard=lobbyShard(server==='public'?key:server);
23
+ url.searchParams.set('reservation',key.slice(0,128));
24
+ if(server==='public'){url.pathname='/match';url.searchParams.set('game',game);return env.LOBBY.get(env.LOBBY.idFromName(`${game}/match/${shard}`)).fetch(url.toString(),init);}
25
+ return send(shard,url,init);
26
+ }
27
+ if(url.pathname==='/report') {
28
+ const body=JSON.parse(init?.body??await input.clone().text());
29
+ return send(roomShard(body.room),url,init??{method:'POST',body:JSON.stringify(body)});
30
+ }
31
+ const rows=await Promise.all(Array.from({length:LOBBY_SHARDS},async(_,shard)=>{
32
+ const response=await send(shard,url,init);if(!response.ok)throw Error('Lobby shard unavailable');return response.json();
33
+ }));
34
+ if(url.pathname==='/busiest')return Response.json(rows.sort((a,b)=>b.players-a.players||String(a.room).localeCompare(String(b.room)))[0]);
35
+ if(url.pathname==='/now'){
36
+ const target=new URL('https://lobby/peak');target.searchParams.set('game',game);
37
+ const response=await env.LOBBY.get(env.LOBBY.idFromName(game+'/peak')).fetch(target);
38
+ if(!response.ok)throw Error('Lobby peak unavailable');
39
+ return Response.json({players:rows.reduce((n,r)=>n+r.players,0),rooms:rows.reduce((n,r)=>n+r.rooms,0),peak:await response.json()});
40
+ }
41
+ return Response.json({rooms:[...new Map(rows.flatMap(r=>r.rooms??[]).map(r=>[r.name??r.room,r])).values()].sort((a,b)=>b.players-a.players)});
42
+ }};
43
+ }
@@ -0,0 +1,35 @@
1
+ /** Row storage keeps a seat update from rewriting every room. The legacy snapshot
2
+ * is imported once in the same SQLite transaction as the migration marker. */
3
+ export function lobbyStore(storage) {
4
+ const sql=storage.sql;
5
+ if(!sql)return {load:()=>storage.get('lobby'),save:value=>storage.put('lobby',value)};
6
+ let previous=new Map(),previousMeta;
7
+ const write=value=>{
8
+ const rooms=new Map(value.rooms.map(room=>[room.name,JSON.stringify(room)]));
9
+ const meta=JSON.stringify({current:value.current,next:value.next,closed:value.closed});
10
+ storage.transactionSync(()=>{
11
+ for(const [name,body] of rooms)if(previous.get(name)!==body)sql.exec('INSERT INTO matchmaking_rooms(name,body) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET body=excluded.body',name,body);
12
+ for(const name of previous.keys())if(!rooms.has(name))sql.exec('DELETE FROM matchmaking_rooms WHERE name=?',name);
13
+ if(meta!==previousMeta)sql.exec("INSERT INTO matchmaking_meta(id,body) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body",meta);
14
+ });
15
+ previous=rooms;previousMeta=meta;
16
+ };
17
+ return {
18
+ async load() {
19
+ sql.exec('CREATE TABLE IF NOT EXISTS matchmaking_rooms(name TEXT PRIMARY KEY,body TEXT NOT NULL)');
20
+ sql.exec('CREATE TABLE IF NOT EXISTS matchmaking_meta(id INTEGER PRIMARY KEY CHECK(id=1),body TEXT NOT NULL)');
21
+ const meta=sql.exec('SELECT body FROM matchmaking_meta WHERE id=1').toArray()[0];
22
+ if(!meta) {
23
+ const legacy=await storage.get('lobby');
24
+ write(legacy??{current:null,next:{public:1},closed:[],rooms:[]});
25
+ // A crash before deletion is harmless: the SQLite marker wins next time.
26
+ await storage.delete('lobby');
27
+ } else {
28
+ previousMeta=meta.body;
29
+ previous=new Map(sql.exec('SELECT name,body FROM matchmaking_rooms').toArray().map(r=>[r.name,r.body]));
30
+ }
31
+ return {...JSON.parse(previousMeta),rooms:[...previous.values()].map(body=>JSON.parse(body))};
32
+ },
33
+ save:write,
34
+ };
35
+ }
@@ -0,0 +1,28 @@
1
+ export const LIVE_ROOMS_QUERY='SELECT game,rooms,updated FROM lobby_summaries ORDER BY game LIMIT 500';
2
+ /** Queue order is not guaranteed. An older snapshot must never replace a newer one. */
3
+ export async function storeLobbySummary(env, message) {
4
+ if(!/^[a-z0-9][a-z0-9-]{0,39}$/.test(message.game)||!Array.isArray(message.rooms)||message.rooms.length>512||!Number.isFinite(message.at))throw new Error('Invalid lobby snapshot');
5
+ const shard=message.shard??0;
6
+ if(!Number.isInteger(shard)||shard<0||shard>=16)throw new Error('Invalid lobby shard');
7
+ await env.DB.prepare(`INSERT INTO lobby_summaries(game,rooms,updated) VALUES(?1,?2,?3)
8
+ ON CONFLICT(game) DO UPDATE SET rooms=excluded.rooms,updated=excluded.updated WHERE excluded.updated>lobby_summaries.updated`)
9
+ .bind(shard?`${message.game}~${shard}`:message.game,JSON.stringify(message.rooms),message.at).run();
10
+ }
11
+ export async function liveRoomSummaries(env) {
12
+ if(!env.BACKGROUND||!env.DB)return null; // Older wrappers still query their Lobby.
13
+ const result=new Map(),since=Date.now()-180000;
14
+ let cursor=null;
15
+ for(;;){
16
+ const query=cursor===null?env.DB.prepare(LIVE_ROOMS_QUERY):env.DB.prepare('SELECT game,rooms,updated FROM lobby_summaries WHERE game>? ORDER BY game LIMIT 500').bind(cursor);
17
+ const rows=(await query.all()).results??[];
18
+ for(const row of rows){
19
+ const game=row.game.split('~')[0],rooms=result.get(game)??[];
20
+ if(row.updated>since)rooms.push(...JSON.parse(row.rooms).filter(room=>room.at>since));
21
+ result.set(game,rooms);
22
+ }
23
+ if(rows.length<500)break;
24
+ cursor=rows.at(-1).game;
25
+ }
26
+ for(const [game,rooms] of result)result.set(game,[...new Map(rooms.map(room=>[room.name,room])).values()]);
27
+ return result;
28
+ }
@@ -0,0 +1,319 @@
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';
6
+ import { errorLine, versionOf } from './room-protocol.mjs';
7
+ import { ROOM_ID } from './page-policy.mjs';
8
+ import { SERVER_ID, pooledRoom, roomCode, roomServer } from './servers.mjs';
9
+ import { seatCount } from './seats.mjs';
10
+ import { counter, today } from './stats.mjs';
11
+
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
+
15
+ export class Lobby {
16
+ constructor(ctx, env) {
17
+ this.ctx = ctx;
18
+ this.env = env;
19
+ this.store = lobbyStore(ctx.storage);
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)");
23
+ /** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
24
+ this.next = { public: 1 };
25
+ /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
26
+ this.live = new Map();
27
+ this.peak = { day: null, players: 0, room: 0 };
28
+ /** Every room with somebody seated, public or named, for the owner's office; and the rooms the owner closed (until when). */
29
+ this.seen = new Map();
30
+ this.closed = new Map();
31
+ ctx.blockConcurrencyWhile(async () => {
32
+ const saved = await this.store.load();
33
+ if (saved) {
34
+ this.current = saved.current ?? null;
35
+ this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
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
+ }
42
+ for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
43
+ }
44
+ });
45
+ }
46
+
47
+ /**
48
+ * The day's peak for this game, all rooms together and in one room: a counter write only when a new high is
49
+ * reached (in memory per day; after an eviction the stored max still wins), so at most a few a day.
50
+ */
51
+ notePeak(room, players, game) {
52
+ const now = Date.now();
53
+ if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
54
+ for (const [name, r] of this.live) if (now - r.at > 180_000) this.live.delete(name); // a room that stopped reporting
55
+ const day = today();
56
+ if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
57
+ let all = 0;
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
+ }
67
+ const writes = [];
68
+ if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
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 })); }
70
+ if (game) this.game = game;
71
+ const list = writes.filter(Boolean);
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); }));
73
+ }
74
+
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); }
76
+
77
+ prune(now) {
78
+ for (const [name, r] of this.rooms) {
79
+ r.pending = r.pending.filter((t) => now - t < 20_000);
80
+ r.agentPending = (r.agentPending ?? []).filter((t) => now - t < 20_000);
81
+ if (r.players === 0 && !r.pending.length && now - r.at > 120_000) this.rooms.delete(name);
82
+ }
83
+ }
84
+
85
+ /** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
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);
89
+ if(new URL(request.url).pathname==='/__app_records') {
90
+ const {AppRecordStreams}=await import('./app-streams.mjs');
91
+ this.appStreams??=new AppRecordStreams(this.env,this.ctx);
92
+ const input=await request.json();
93
+ return input.op==='open'?this.appStreams.open(input):this.appStreams.wake(input.meta);
94
+ }
95
+ if(new URL(request.url).pathname==='/__studio_events') {
96
+ const {EventStreams}=await import('./event-resources.mjs');
97
+ this.eventStreams??=new EventStreams(this.env,this.ctx);
98
+ return this.eventStreams.fetch(request);
99
+ }
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) {
105
+ let path = '';
106
+ try { path = new URL(request.url).pathname; } catch { /* not an address */ }
107
+ try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-failed', game: this.game ?? null, path, error: errorLine(error) })); } catch { /* no console */ }
108
+ return json({ ok: false, error: 'lobby' }, 500);
109
+ }
110
+ }
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
+
143
+ async answer(request) {
144
+ const url = new URL(request.url);
145
+ const now = Date.now();
146
+ this.shard=Number(url.searchParams.get('shard'))||0;
147
+ this.prune(now);
148
+ // REVISIONS (section 23): rooms are matched within one build of the game. A room that never said its build is
149
+ // the build of a game that names none ('').
150
+ const ver = versionOf(url.searchParams.get('ver')) ?? '';
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);
158
+ // A server build can run only its current rules. Persisted browser rooms
159
+ // (including pre-kind records) and old server revisions must not consume
160
+ // the pool or become the fallback of a server with roomsMax=1.
161
+ const host = url.searchParams.get('host');
162
+ if ((host === 'server' || host === 'browser') && (this.current?.host !== host || this.current?.ver !== ver)) {
163
+ this.current = { host, ver };
164
+ for (const [name, r] of this.rooms) if (
165
+ host === 'server' ? (r.host ?? 'browser') !== host || (r.ver ?? '') !== ver : r.host === 'server'
166
+ ) { this.rooms.delete(name); this.seen.delete(name); this.live.delete(name); }
167
+ this.save();
168
+ }
169
+ const max = seatCount(url.searchParams.get('max'));
170
+ const sid = url.searchParams.get('server') ?? 'public';
171
+ const server = sid === 'public' || SERVER_ID.test(sid) ? sid : 'public';
172
+ const roomsMax = Math.max(1, Math.min(16, Math.floor(Number(url.searchParams.get('rooms'))) || 16));
173
+ // A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
174
+ const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
175
+ for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
176
+ // Every room of the server counts toward its `rooms_max` and its numbering; only this build's rooms are matched.
177
+ const everyRoom = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server);
178
+ const ofServer = everyRoom.filter((r) => !not.has(r.name) && !this.closed.has(r.name));
179
+ const pool = ofServer.filter((r) => (r.ver ?? '') === ver);
180
+ // A standalone copy names its own build (the Worker says `app=1`), and anybody can claim to be one: only a few
181
+ // builds besides the live one ever have rooms here, so a caller that invents a build a request makes no pool.
182
+ const app = url.searchParams.get('app') === '1';
183
+ if (app && !everyRoom.some((r) => (r.ver ?? '') === ver) && new Set(everyRoom.map((r) => r.ver ?? '')).size > LOBBY_APP_VERSIONS) {
184
+ 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);
185
+ }
186
+ if (url.searchParams.get('agent') === '1') {
187
+ // An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
188
+ const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
189
+ let best = null;
190
+ for (const r of pool) {
191
+ const agents = (r.agents ?? 0) + (r.agentPending ?? []).length;
192
+ if (!(r.players > 0) || (reserve ? agents >= reserve : r.players + r.pending.length + agents >= max)) continue;
193
+ if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
194
+ }
195
+ if (!best) return json({ room: null, players: 0, max });
196
+ (best.agentPending ??= []).push(now);
197
+ return json({ room: best.name, players: best.players, max });
198
+ }
199
+ let best = null;
200
+ for (const r of pool) {
201
+ const fill = r.players + r.pending.length;
202
+ if (fill >= max) continue;
203
+ if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
204
+ }
205
+ if(url.searchParams.get('peek')==='1')return json({room:best?.r.name??null});
206
+ let room = best?.r;
207
+ let full = false;
208
+ const fullest = (list) => list.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), list[0]);
209
+ if (!room && server !== 'public' && everyRoom.length >= roomsMax) {
210
+ // Every room this server may have exists, whatever build each runs: the fullest one of this build, where the
211
+ // visitor waits for a seat; with none of this build, the fullest of the server (its players are told a newer
212
+ // build is live, and the visitor is let in when they have it). Never a room past the server's own number.
213
+ const from = pool.length ? pool : ofServer;
214
+ if (!from.length) return json({ ok: false, room: null, error: 'full', message: 'Every room of this server is taken.' }, 503);
215
+ room = fullest(from);
216
+ full = true;
217
+ }
218
+ if (!room && this.rooms.size >= LOBBY_ROOMS_MAX) {
219
+ // The Lobby keeps a bounded list (it is one stored value): rooms nobody is seated in go first, the oldest
220
+ // first. When every room has people in it, nobody gets a new one: this build's fullest, or no room at all.
221
+ const empty = [...this.rooms.values()].filter((r) => r.players === 0).sort((a, b) => a.at - b.at);
222
+ for (const r of empty) { if (this.rooms.size < LOBBY_ROOMS_MAX) break; this.rooms.delete(r.name); }
223
+ if (this.rooms.size >= LOBBY_ROOMS_MAX) {
224
+ if (!pool.length) return json({ ok: false, room: null, error: 'busy', message: 'Every room is taken right now.' }, 503);
225
+ room = fullest(pool);
226
+ full = true;
227
+ }
228
+ }
229
+ if (!room) {
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;
233
+ room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, ...(host ? { host } : {}), pending: [], agentPending: [] };
234
+ this.next[server] = n + step;
235
+ this.rooms.set(room.name, room);
236
+ }
237
+ room.pending.push(now);
238
+ this.save();
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);
242
+ }
243
+ if (url.pathname === '/report' && request.method === 'POST') {
244
+ const body = await request.json().catch(() => ({}));
245
+ if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
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 }); }
247
+ // The owner closed this room: matched with nobody until then (and forgotten as a public room).
248
+ if (Number.isFinite(body.closed)) {
249
+ if(body.closed>now)this.notePeak(body.room,0,url.searchParams.get('game'));
250
+ if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
251
+ this.seen.delete(body.room);
252
+ this.save();
253
+ return json({ ok: true, closed: body.closed > now });
254
+ }
255
+ // A retiring isolate may send its last report after the new pool was
256
+ // selected. It cannot resurrect an incompatible public room.
257
+ if (pooledRoom(body.room) && this.current && (this.current.host === 'server'
258
+ ? (body.host ?? 'browser') !== 'server' || (body.ver ?? '') !== this.current.ver
259
+ : body.host === 'server')) return json({ ok: true, retired: true });
260
+ const seated = Math.max(0, Math.floor(Number(body.players) || 0));
261
+ const agents = Math.max(0, Math.floor(Number(body.agents) || 0));
262
+ const ai = Math.max(agents, Math.floor(Number(body.ai) || 0));
263
+ 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 });
264
+ else this.seen.delete(body.room);
265
+ if (this.closed.has(body.room)) return json({ ok: true, closed: true });
266
+ this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
267
+ // Only rooms of a pool are matched with strangers (pub-N, s-<id>-<n>); a named room (?room=) stays private.
268
+ if (!this.rooms.has(body.room) && !pooledRoom(body.room)) return json({ ok: true, private: true });
269
+ 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: [] };
270
+ const grew = Math.max(0, seated - r.players);
271
+ // The room says which build it runs (it changes only when nobody on the old one is left).
272
+ const rver = typeof body.ver === 'string' ? versionOf(body.ver) ?? '' : r.ver ?? '';
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;
274
+ r.ver = rver;
275
+ if (body.host === 'server' || body.host === 'browser') r.host = body.host;
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);
280
+ if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
281
+ r.players = seated;
282
+ r.agents = agents;
283
+ r.ai = ai;
284
+ r.at = now;
285
+ this.rooms.set(r.name, r);
286
+ if (changed) this.save(); // Refresh durable presence at most once per minute.
287
+ return json({ ok: true });
288
+ }
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 })) });
290
+ if (url.pathname === '/busiest') {
291
+ // The pooled room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
292
+ // 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.
293
+ const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
294
+ const pools = url.searchParams.has('pools') ? new Set(String(url.searchParams.get('pools')).split(',').filter(Boolean)) : null;
295
+ let best = null;
296
+ for (const r of this.rooms.values()) {
297
+ if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
298
+ // A watcher's page runs the live build: a room still on an older one would turn it away.
299
+ if (url.searchParams.has('ver') && (r.ver ?? '') !== ver) continue;
300
+ if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
301
+ }
302
+ return json({ room: best?.name ?? null, players: best?.players ?? 0 });
303
+ }
304
+ if (url.pathname === '/office') {
305
+ // The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
306
+ for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
307
+ const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
308
+ 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 });
309
+ 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 });
310
+ return json({ rooms: [...rooms.values()].map((r) => ({ ...r, server: roomServer(r.room) })).sort((a, b) => b.players - a.players).slice(0, 100) });
311
+ }
312
+ if (url.pathname === '/now') {
313
+ let players = 0;
314
+ for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }
315
+ return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
316
+ }
317
+ return json({ error: 'not found' }, 404);
318
+ }
319
+ }
@@ -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 []; }
package/worker/lounge.mjs CHANGED
@@ -28,7 +28,7 @@ import { encodeFacts } from './agents.mjs';
28
28
  import { PUBLIC_SERVER, policyOf } from './servers.mjs';
29
29
  import { players } from './players.mjs';
30
30
  import { signControl } from './office.mjs';
31
- import { LOUNGE_JS, loungePage } from './lounge-page.mjs';
31
+
32
32
  import { STUDIO_VERSION_TAG } from './version.mjs';
33
33
 
34
34
  const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
@@ -262,14 +262,14 @@ export async function loungeRoutes(request, env, ctx, url, { catalogueOf, roomsO
262
262
  const cat = await catalogueOf();
263
263
  const L = loungeOf(cat);
264
264
  if (!L) return null;
265
- if (path === '/_homie/lounge.js') return new Response(LOUNGE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
265
+ if (path === '/_homie/lounge.js') return new Response((await import('./lounge-page.mjs')).LOUNGE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
266
266
  if (path === '/lounge' && read) return Response.redirect(`${url.origin}/lounge/${url.search}`, 301);
267
267
  if (!env.TABLE || !env.DB) return path.startsWith('/lounge/api/') ? json({ ok: false, error: 'no-rooms', message: 'This studio has no rooms or database bound yet: `npm run deploy` sets them up.' }, 503) : null;
268
268
  const local = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(url.origin);
269
269
 
270
270
  if (path === '/lounge/' && read) {
271
271
  const [who, rules, nights] = await Promise.all([whoOf(request, env), loungeRules(env, cat), nightsOf(env)]);
272
- return loungePage(cat, { origin: url.origin, who, rules: publicChat(rules), nights: nights.map((n) => nightView(n, cat, url.origin)) });
272
+ return (await import('./lounge-page.mjs')).loungePage(cat, { origin: url.origin, who, rules: publicChat(rules), nights: nights.map((n) => nightView(n, cat, url.origin)) });
273
273
  }
274
274
 
275
275
  if (path === '/lounge/__watch') {