@homie-rocks/studio 0.15.0 → 0.16.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/worker/index.mjs CHANGED
@@ -23,8 +23,14 @@
23
23
  * /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
24
24
  * /<game>/__net?room= the room's netplay socket (Table Durable Object)
25
25
  * /<game>/__watch?room= the room's facts, for the shell
26
- * /<game>/api/lobby which public room to join (Lobby Durable Object)
26
+ * /<game>/api/lobby which public room to join (Lobby Durable Object); ?server=<id>: a room of that server
27
27
  * /<game>/api/watch which public room to watch: the busiest one now (nothing is reserved)
28
+ * /<game>/servers/ the game's servers (worker/servers.mjs): named, lasting room pools with their own
29
+ * policy (open, humans-only, hybrid, beginner) and door; /<game>/api/servers as JSON
30
+ * /<game>/s/<id>/ a server's page; /<game>/s/<id>/play its door, then the play page in its pool;
31
+ * POST /<game>/s/<id>/home joins it, leaves it or makes it home (a signed-in player)
32
+ * /<game>/api/agent an AI's seat (POST, Bearer agent pass; worker/agents.mjs): a room with people in it,
33
+ * its ticket and socket, and the frame to load (NETPLAY.md section 17)
28
34
  * /api/games, /api/rooms the catalogue plus live counts; every public room playing now (/api/rooms may be
29
35
  * cached for 15 s)
30
36
  * /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
@@ -56,24 +62,30 @@
56
62
  * after `homie-studio storage add` (R2 needs a payment method on the account),
57
63
  * MEDIA. Everything else runs on Cloudflare's free Workers plan.
58
64
  *
59
- * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 5), run
65
+ * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 6), run
60
66
  * unchanged inside the Table, so a game that plays in `homie-studio dev`
61
67
  * plays the same way here.
62
68
  */
63
69
  import { NetRoom, WATCH_POLICIES } from './room.mjs';
64
70
  import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
71
+ import {
72
+ PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
73
+ setMembership,
74
+ } from './servers.mjs';
75
+ import { agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
76
+ import { doorPage, serverPage, serversPage } from './site.mjs';
65
77
  import { qrSvg } from './qr.mjs';
66
78
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
67
79
  import {
68
80
  SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
69
81
  notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
70
82
  } from './site.mjs';
71
- import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
83
+ import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
72
84
  import { ownerRoutes } from './stats-page.mjs';
73
85
  import { playerRoutes, players as playerAccounts } from './players.mjs';
74
86
  import {
75
- accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, remixOf, seatsFor, settingsOf, ticketAllows, ticketFor,
76
- ticketSub, usePlayers, verifyControl,
87
+ accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, remixOf, sameOrigin, seatsFor, settingsOf, ticketAllows,
88
+ ticketFor, ticketSub, usePlayers, verifyControl,
77
89
  } from './office.mjs';
78
90
 
79
91
  // The back office knows the studio's player accounts: a signed-in player is named in the office and held by a kick on
@@ -205,8 +217,12 @@ function named(cat, env) {
205
217
  return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
206
218
  }
207
219
 
208
- /** Every public room with people in it, per game (the Lobby's list), and the number playing per game. */
209
- async function roomsOf(env, games) {
220
+ /**
221
+ * Every public room with people in it, per game (the Lobby's list), and the number playing per game. A room of a
222
+ * server (section 17) says which, its policy, and how many AI are in it; a server that is not listed, closed, or has
223
+ * a door is left out of the lists (its own page shows its rooms to whoever it lets in).
224
+ */
225
+ async function roomsOf(env, games, { servers = null } = {}) {
210
226
  const rooms = [];
211
227
  const live = {};
212
228
  await Promise.all(games.map(async (g) => {
@@ -214,7 +230,13 @@ async function roomsOf(env, games) {
214
230
  try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
215
231
  const busy = list.filter((r) => (r.players || 0) > 0);
216
232
  live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
217
- for (const r of busy) rooms.push(roomView(g, r, seatsOf(g)));
233
+ const known = busy.some((r) => r.server && r.server !== 'public') ? (servers?.[g.id] ?? await serversOf(env, g).catch(() => [])) : [];
234
+ for (const r of busy) {
235
+ const sid = r.server ?? roomServer(r.name);
236
+ const srv = sid === 'public' ? (known.find((x) => x.id === 'public') ?? PUBLIC_SERVER) : known.find((x) => x.id === sid);
237
+ if (!srv || (sid !== 'public' && (!srv.listed || srv.state !== 'open' || srv.door !== 'open'))) continue;
238
+ rooms.push(roomView(g, r, seatsOf(g), srv));
239
+ }
218
240
  }));
219
241
  rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
220
242
  return { rooms, live };
@@ -304,8 +326,11 @@ function notAHomie(request) {
304
326
  return json({ ok: false, error: 'not-a-homie', message: 'this is a studio site, not a Homie box; a game here plays on its own' }, 200, KNOCK_CORS);
305
327
  }
306
328
 
307
- /** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
308
- async function gameDocument(request, env, url, game, meta, cat) {
329
+ /**
330
+ * The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). `agent`: the
331
+ * frame was opened with an agent's ticket (section 17): the game plays as that AI, named "<label> · AI".
332
+ */
333
+ async function gameDocument(request, env, url, game, meta, cat, { agent = null } = {}) {
309
334
  const asked = url.searchParams.get('room');
310
335
  // A room code the relay cannot use is refused here too, never swapped for another room.
311
336
  if (asked !== null && !ROOM_ID.test(asked)) return new Response('That room link does not work: a room code is 1 to 32 letters, digits, - or _.\n', { status: 400, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
@@ -314,10 +339,10 @@ async function gameDocument(request, env, url, game, meta, cat) {
314
339
  const room = asked ?? 'main';
315
340
  // A watcher (section 16): a screen that never takes a seat, following the player the watch page asks for.
316
341
  const policy = watchOf(meta);
317
- const watching = url.searchParams.get('watch') === '1';
342
+ const watching = !agent && url.searchParams.get('watch') === '1';
318
343
  if (watching && policy === 'off') return new Response(`${meta?.name ?? game} cannot be watched; play it instead.\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
319
344
  const follow = /^(?:auto|overview|\d{1,2})$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
320
- const want = watching || url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
345
+ const want = !agent && (watching || url.searchParams.get('want') === 'screen') ? 'screen' : 'play';
321
346
  const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
322
347
  const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
323
348
  // The browser's room key (b: what a kick holds out) and the play page's ticket (t: a game that is not public) ride
@@ -329,7 +354,11 @@ async function gameDocument(request, env, url, game, meta, cat) {
329
354
  url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}`,
330
355
  room,
331
356
  ...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
332
- ...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
357
+ ...(agent ? { name: agent.name } : url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
358
+ // An AI that runs the game itself (hands `self`): its hello says so; the relay knows it from its ticket anyway.
359
+ ...(agent ? { agent: { hands: agent.hands, role: agent.role } } : {}),
360
+ // The play page's "Quiet AI": this browser hides AI speech (the helper's `net.hushed`).
361
+ ...(url.searchParams.get('hush') === '1' ? { hush: true } : {}),
333
362
  ...(device ? { device } : {}),
334
363
  want,
335
364
  ...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
@@ -621,7 +650,7 @@ async function route(request, env, ctx) {
621
650
  if (!meta) return fallback(request, env, url, getCat);
622
651
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
623
652
  const cat = await getCat();
624
- const sub = parts.slice(1).join('/');
653
+ let sub = parts.slice(1).join('/');
625
654
  // Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
626
655
  const settings = await settingsOf(env, { fresh: true });
627
656
  // The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
@@ -630,19 +659,48 @@ async function route(request, env, ctx) {
630
659
  const max = seatsFor(meta, settings);
631
660
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
632
661
  if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
633
- const door = launch !== 'public' && ['', 'live', 'credits', 'tv', 'play', 'watch', 'api/lobby', 'api/watch'].includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
662
+
663
+ // SERVERS (worker/servers.mjs, NETPLAY.md section 17): which server a request is for — its path (/s/<id>/), its
664
+ // room (s-<id>-<n>; pub-N and any named room are the public server's), or for a bare Play the signed-in player's
665
+ // home server, else public. Every door follows the game's launch state first, as it always did.
666
+ const servers = await serversOf(env, meta);
667
+ const serverById = (id) => servers.find((x) => x.id === id) ?? null;
668
+ const pathServer = /^s\/([a-z0-9][a-z0-9-]{1,19})(?:\/(play|tv|home))?\/?$/.exec(sub);
669
+ if (/^s(?:\/|$)/.test(sub) && !pathServer) return notFoundPage(`Nothing here in ${meta.name}.`, cat);
670
+ if (pathServer && (!serverById(pathServer[1]) || pathServer[1] === 'public' || serverById(pathServer[1]).state === 'archived')) return notFoundPage(`${meta.name} has no server called "${pathServer[1]}".`, cat);
671
+ if (pathServer && !pathServer[2] && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/s/${pathServer[1]}/${url.search}`, 301);
672
+ const policyFor = (srv, named = false) => policyOf(srv, { seats: max, named });
673
+ const humanSeats = (pol) => Math.max(1, max - (pol.aiSeats + pol.guides));
674
+ const askedRoom = url.searchParams.get('room');
675
+ if (pathServer) sub = pathServer[2] === 'play' ? 'play' : pathServer[2] === 'tv' ? 'tv' : pathServer[2] === 'home' ? 's-home' : 's-page';
676
+
677
+ const doorSubs = ['', 'live', 'credits', 'tv', 'play', 'watch', 'api/lobby', 'api/watch', 'servers', 'api/servers', 's-page', 's-home'];
678
+ const door = launch !== 'public' && doorSubs.includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
634
679
  const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
680
+ /** A page's own door to a server: who this browser is (owner, invited, signed in) and whether it may come in. */
681
+ const pageDoor = async (srv, { watching = false } = {}) => {
682
+ const owner = door.owner || (launch === 'public' && await isOwner(request, env));
683
+ const acct = await accountSub(request, env);
684
+ const sPass = srv && srv.door === 'invite' && !owner ? await serverPassOf(request, env, game, srv.id, cookieValues) : null;
685
+ const list = [owner ? 'o' : null, door.sub, sPass ? `i-${sPass.invite}` : null, acct].filter(Boolean).flatMap(holders);
686
+ const access = srv ? await serverAccess(env, { game, server: srv, holders: list, watching }) : { ok: true };
687
+ return { ...access, owner, acct, holder: joinHolders(door.sub ?? (owner ? 'o' : null), sPass ? `i-${sPass.invite}` : null, acct) };
688
+ };
689
+ /** The public server is hidden (closed by its owner): Play shows the other servers instead, and named rooms are off. */
690
+ const publicHidden = () => (serverById('public') ?? PUBLIC_SERVER).state !== 'open';
691
+
635
692
  if (sub === '') {
636
693
  if (!door.ok) return shut();
637
694
  await countVisit(request, env, ctx, game);
638
695
  shareDaily(cat, url, ctx);
639
- const { rooms, live } = await roomsOf(env, [meta]);
696
+ const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
640
697
  const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
641
- return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings) });
698
+ const band = servers.some((x) => x.id !== 'public' && x.listed && x.state === 'open') ? await serverLive(env, meta, servers, url.origin, lobby) : null;
699
+ return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings), servers: band });
642
700
  }
643
701
  if (sub === 'live') {
644
702
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
645
- const { rooms, live } = await roomsOf(env, [meta]);
703
+ const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
646
704
  const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
647
705
  // The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
648
706
  return json({ ok: true, game, counted: true, playing: live[game] ?? 0, waiting: 0, max, rooms, road: { seats: 'any', start: 'now', room: launch === 'public' ? 'public' : launch, big: true }, ...(week ? { week } : {}) }, 200, { 'cache-control': 'no-store' });
@@ -654,62 +712,138 @@ async function route(request, env, ctx) {
654
712
  if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
655
713
  return creditsPage(cat, meta, texts, { origin: url.origin });
656
714
  }
715
+ if (sub === 'servers') {
716
+ if (!door.ok) return shut();
717
+ if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/servers/`, 301);
718
+ await countVisit(request, env, ctx, `${game}/servers`);
719
+ return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: publicHidden() });
720
+ }
721
+ if (sub === 'api/servers') {
722
+ if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
723
+ const list = (await serverLive(env, meta, servers, url.origin, lobby)).filter((x) => x.id === 'public' || (x.listed && x.state === 'open'));
724
+ return json({ ok: true, game, servers: list }, 200, { 'cache-control': 'public, max-age=15' });
725
+ }
726
+ if (sub === 's-page') {
727
+ if (!door.ok) return shut();
728
+ const srv = serverById(pathServer[1]);
729
+ const d = await pageDoor(srv);
730
+ const pid = d.acct?.startsWith('p-') ? d.acct.slice(2) : null;
731
+ const member = pid ? await memberOf(env, game, srv.id, pid) : null;
732
+ await countVisit(request, env, ctx, `${game}/s/${srv.id}`);
733
+ const [row] = await serverLive(env, meta, [srv], url.origin, lobby);
734
+ const { rooms } = await roomsOf(env, [meta], { servers: { [game]: servers } });
735
+ return serverPage(cat, meta, row, { origin: url.origin, rooms: rooms.filter((r) => r.server?.id === srv.id), door: d, member, signedIn: Boolean(pid) });
736
+ }
737
+ if (sub === 's-home') {
738
+ // Join this server, leave it, or make it home: the signed-in player's own choice (a same-origin JSON POST).
739
+ if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin' }, 403);
740
+ if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
741
+ const srv = serverById(pathServer[1]);
742
+ const acct = await accountSub(request, env);
743
+ if (!acct?.startsWith('p-')) return json({ ok: false, error: 'sign-in', message: 'Sign in to the studio first (your account page).' }, 401);
744
+ let body = {};
745
+ try { body = JSON.parse((await request.text()) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
746
+ const d = await pageDoor(srv);
747
+ if (body.join !== false && !d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
748
+ try { return json(await setMembership(env, game, srv.id, acct.slice(2), { join: body.join !== false, home: body.home === true })); } catch { return json({ ok: false, error: 'not-migrated', message: 'This studio\'s database has no servers yet (migration 0006).' }, 503); }
749
+ }
657
750
  if (sub === 'tv' || sub === 'play') {
658
751
  const screen = sub === 'tv' || url.searchParams.get('screen') === '1';
659
- const asked = url.searchParams.get('room');
752
+ const asked = askedRoom;
660
753
  if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
661
754
  if (!door.ok) return shut();
662
- // A browser let into a game that is not public carries a ticket into its frame and sockets; the owner (signed
663
- // in, in any game) also gets the owner's overlay, which no one else's page carries.
664
- // The owner's page (in any game), a browser let into a game that is not public, and a signed-in player carry a
665
- // ticket naming them (an invited player: the invite and the account), so the room knows who stays when the game
666
- // narrows and whom a kick holds on every device.
667
- const owner = door.owner || (launch === 'public' && await isOwner(request, env));
668
- const holder = joinHolders(door.sub ?? (owner ? 'o' : null), await accountSub(request, env));
669
- const ticket = holder ? await ticketFor(env, game, holder) : null;
755
+ // Which server: the path's, the room's, else (Play) the player's home server, else public.
756
+ let srv = pathServer ? serverById(pathServer[1]) : asked !== null ? serverById(roomServer(asked)) : null;
757
+ if (asked !== null && !srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
758
+ const named = asked !== null && !pooledRoom(asked);
759
+ if (!srv) {
760
+ const acct = await accountSub(request, env);
761
+ const home = acct?.startsWith('p-') ? await homeOf(env, game, acct.slice(2)) : null;
762
+ const h = home ? serverById(home) : null;
763
+ srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
764
+ }
765
+ const d = await pageDoor(srv);
766
+ // The owner hid the public server: Play shows the servers to pick from, and a named room is off (owner aside).
767
+ if (srv.id === 'public' && publicHidden() && !d.owner) {
768
+ if (named) return doorPage(cat, meta, srv, { why: 'closed' });
769
+ return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: true, pick: true });
770
+ }
771
+ if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, days: d.days, origin: url.origin, next: `${url.pathname}${url.search}`, code: url.searchParams.get('invite') ?? '' });
772
+ if (d.acct?.startsWith('p-') && srv.id !== 'public') ctx?.waitUntil?.(noteMember(env, game, srv.id, d.acct.slice(2)));
773
+ const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
774
+ const pol = policyFor(srv, named);
775
+ const server = srv.id === 'public' && !pol.server ? null : { ...serverView(srv, { origin: url.origin, game }), mentor: Boolean(d.mentor) };
670
776
  if (screen) {
671
777
  await countVisit(request, env, ctx, game, 'screen');
672
778
  // The big screen picks its room now, so the QR it shows puts every phone in that same room.
673
779
  let room = asked;
674
780
  if (!room) {
675
- try { room = (await (await lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
781
+ try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
676
782
  }
677
783
  const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
678
784
  let qr = null;
679
785
  try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
680
- return playPage(cat, meta, { screen: true, joinUrl, qr, room, ticket, owner, launch });
786
+ return playPage(cat, meta, { screen: true, joinUrl, qr, room, ticket, owner: d.owner, launch, server });
681
787
  }
682
788
  await countVisit(request, env, ctx, game, 'play');
683
789
  shareDaily(cat, url, ctx);
684
- return playPage(cat, meta, { ticket, owner, launch });
790
+ return playPage(cat, meta, { ticket, owner: d.owner, launch, server });
685
791
  }
686
792
  if (sub === 'watch') {
687
793
  // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
688
- const asked = url.searchParams.get('room');
794
+ const asked = askedRoom;
689
795
  if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { watch: true });
690
796
  if (!door.ok) return shut();
691
797
  const policy = watchOf(meta);
692
798
  if (policy === 'off') return noWatchPage(cat, meta);
693
- const owner = door.owner || (launch === 'public' && await isOwner(request, env));
694
- const holder = joinHolders(door.sub ?? (owner ? 'o' : null), await accountSub(request, env));
695
- const ticket = holder ? await ticketFor(env, game, holder) : null;
799
+ // A server's door is its watch door too (an invite or an account); a beginner server is watched by anyone.
800
+ const srv = asked !== null ? serverById(roomServer(asked)) : serverById('public') ?? PUBLIC_SERVER;
801
+ if (!srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
802
+ const d = await pageDoor(srv, { watching: true });
803
+ if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, origin: url.origin, next: `${url.pathname}${url.search}`, watch: true });
804
+ const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
696
805
  await countVisit(request, env, ctx, game, 'watch');
697
806
  shareDaily(cat, url, ctx);
698
807
  return watchPage(cat, meta, { room: asked, ticket, policy });
699
808
  }
700
809
  if (sub === 'api/watch') {
701
810
  if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
702
- // The busiest public room now, to watch; nothing is reserved (a watcher takes no seat).
811
+ // The busiest room now, to watch, of a server anyone may watch; nothing is reserved (a watcher takes no seat).
703
812
  const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
813
+ const pools = servers.filter((x) => x.state === 'open' && x.door === 'open').map((x) => x.id).join(',');
704
814
  let best = null;
705
- try { best = await (await lobby().fetch(`https://lobby/busiest${not ? `?not=${encodeURIComponent(not)}` : ''}`)).json(); } catch { best = null; }
815
+ try { best = await (await lobby().fetch(`https://lobby/busiest?pools=${encodeURIComponent(pools)}${not ? `&not=${encodeURIComponent(not)}` : ''}`)).json(); } catch { best = null; }
706
816
  return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
707
817
  }
708
818
  if (sub === 'api/lobby') {
709
819
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
820
+ // ?server=<id>: a room of that server's pool (its door first); none: public. Strangers never meet across servers.
821
+ const sid = url.searchParams.get('server') ?? 'public';
822
+ const srv = SERVER_ID.test(sid) || sid === 'public' ? serverById(sid) : null;
823
+ if (!srv) return json({ ok: false, error: 'no-server', message: `${meta.name} has no server called ${String(sid).slice(0, 24)}.` }, 404);
824
+ if (srv.id === 'public' && publicHidden() && !(await pageDoor(srv)).owner) return json({ ok: false, error: 'closed', message: 'Pick a server to play on.' }, 403);
825
+ const d = await pageDoor(srv);
826
+ if (!d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
710
827
  // A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
711
828
  const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
712
- return lobby().fetch(`https://lobby/join?max=${max}${not ? `&not=${encodeURIComponent(not)}` : ''}`, { method: 'POST' });
829
+ const pol = policyFor(srv);
830
+ return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}`, { method: 'POST' });
831
+ }
832
+ if (sub === 'api/agent') {
833
+ // An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
834
+ return sitRoute(request, env, url, {
835
+ meta, launch,
836
+ serverOf: (id) => serverById(id),
837
+ policyOf: (srv) => policyFor(srv),
838
+ ticketFor: (holder) => ticketFor(env, game, holder),
839
+ join: async (srv, pol) => {
840
+ try {
841
+ const reserve = pol.aiSeats + pol.guides;
842
+ const r = await (await lobby().fetch(`https://lobby/join?max=${max}&server=${srv.id}&agent=1&ai=${reserve}`, { method: 'POST' })).json();
843
+ return r.room ?? null;
844
+ } catch { return null; }
845
+ },
846
+ });
713
847
  }
714
848
  if (sub === '__net' || sub === '__watch') {
715
849
  if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
@@ -724,21 +858,60 @@ async function route(request, env, ctx) {
724
858
  const policy = watchOf(meta);
725
859
  const w = sub === '__net' && url.searchParams.get('w') === '1';
726
860
  if (w && policy === 'off') return new Response('this game cannot be watched', { status: 403 });
861
+ // The room's server (section 17): its door for a person, and the policy the room applies. An AI's ticket
862
+ // (a-<pass>) is checked against its pass and the server: a humans-only server refuses it here and again at the relay.
863
+ const srv = serverById(roomServer(room));
864
+ if (!srv) return new Response('this server does not exist', { status: 404 });
865
+ const parts = holders(who);
866
+ const agentPart = parts.find((x) => x.startsWith('a-'));
867
+ const pol = policyFor(srv, !pooledRoom(room));
868
+ let ag = null;
869
+ if (agentPart && sub === '__net') {
870
+ const pass = await passById(env, agentPart.slice(2));
871
+ const why = passRefusal(pass, { game, server: srv.id, policy: pol, launch });
872
+ if (why) return new Response(why.error === 'agents-off' ? 'This server is for humans only\n' : `${why.message}\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } });
873
+ ag = { ...agentFacts(pass), name: aiName(pass.label) };
874
+ } else if (srv.state !== 'open' || srv.door !== 'open' || (srv.policy === 'beginner' && !w && sub === '__net')) {
875
+ const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
876
+ if (!access.ok) return new Response('this server is not open to you', { status: 403 });
877
+ }
727
878
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
728
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}`;
879
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}${ag ? `&ag=${encodeFacts(ag)}` : ''}`;
729
880
  return stub.fetch(new Request(target, request));
730
881
  }
731
882
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
732
883
  if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
733
884
  if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
734
- if (launch !== 'public' && !(await ticketAllows(env, await ticketSub(env, game, url.searchParams.get('t')), launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
735
- return gameDocument(request, env, url, game, meta, cat);
885
+ const who = await ticketSub(env, game, url.searchParams.get('t'));
886
+ if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
887
+ // An agent's frame (hands `self`): the game plays as that AI.
888
+ const agentPart = holders(who).find((x) => x.startsWith('a-'));
889
+ const pass = agentPart ? await passById(env, agentPart.slice(2)) : null;
890
+ return gameDocument(request, env, url, game, meta, cat, { agent: pass?.live ? { name: aiName(pass.label), hands: pass.hands, role: pass.role } : null });
736
891
  }
737
892
  if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
738
893
  }
739
894
  return fallback(request, env, url, getCat);
740
895
  }
741
896
 
897
+ /**
898
+ * A game's servers with what is happening on them now (the Lobby's rooms by server, one request) and how many
899
+ * belong to each: the landing's Servers band, /<game>/servers/, a server's page and /<game>/api/servers.
900
+ */
901
+ async function serverLive(env, meta, servers, origin, lobby) {
902
+ let list = [];
903
+ try { list = (await (await lobby().fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
904
+ const by = {};
905
+ for (const r of list) {
906
+ if (!(r.players > 0)) continue;
907
+ const sid = r.server ?? roomServer(r.name);
908
+ const b = (by[sid] ??= { playing: 0, ai: 0, rooms: 0 });
909
+ b.playing += r.players; b.ai += Number(r.ai ?? r.agents ?? 0) || 0; b.rooms += 1;
910
+ }
911
+ const members = env.DB ? await memberCounts(env, meta.id) : {};
912
+ return servers.map((s) => ({ ...serverView(s, { origin, game: meta.id }), playing: by[s.id]?.playing ?? 0, ai: by[s.id]?.ai ?? 0, live: by[s.id]?.rooms ?? 0, members: members[s.id] ?? 0 }));
913
+ }
914
+
742
915
  /** A game's "played this week" on its landing, when the studio shares its numbers: Play presses and rounds with people. */
743
916
  async function weekOf(env, game) {
744
917
  const w = await playedThisWeek(env, game);
@@ -824,6 +997,10 @@ export class Table {
824
997
  }
825
998
  // The owner's room size: a room already open takes it from the next visitor on.
826
999
  if (room.seatCap !== max) room.setSeats(max, perAddress(max));
1000
+ // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
1001
+ const pol = decodeFacts(url.searchParams.get('pol'));
1002
+ if (pol) room.setPolicy(pol);
1003
+ const agent = decodeFacts(url.searchParams.get('ag'));
827
1004
  const [client, server] = Object.values(new WebSocketPair());
828
1005
  server.accept();
829
1006
  const via = url.searchParams.get('via');
@@ -835,6 +1012,8 @@ export class Table {
835
1012
  player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
836
1013
  // A socket opened through the game's watch door, and what the game lets watchers see (section 16).
837
1014
  watch: url.searchParams.get('w') === '1',
1015
+ // An AI with a pass the Worker verified (section 17): its pass, role, hands and name. Only the Worker sets it.
1016
+ ...(agent && typeof agent === 'object' && url.pathname === '/__net' ? { agent } : {}),
838
1017
  watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
839
1018
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
840
1019
  qa: isQa(request),
@@ -844,6 +1023,8 @@ export class Table {
844
1023
  };
845
1024
  if (url.pathname === '/__watch') {
846
1025
  const w = room.watch(conn);
1026
+ // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
1027
+ server.addEventListener('message', (e) => { if (typeof e.data === 'string') w.onMessage(e.data); });
847
1028
  server.addEventListener('close', () => w.onClose());
848
1029
  server.addEventListener('error', () => w.onClose());
849
1030
  } else {
@@ -861,8 +1042,18 @@ export class Table {
861
1042
  this.ctx.waitUntil(count(this.env, null, { metric: 'room', subject: this.game ?? '', source: /^pub-\d+$/.test(this.code ?? '') ? 'public' : 'named' }));
862
1043
  }
863
1044
  });
864
- server.addEventListener('close', () => { h.onClose(); this.report(); });
865
- server.addEventListener('error', () => { h.onClose(); this.report(); });
1045
+ // An AI's time in a seat, counted once as it leaves (stats `agent-minutes`; never for house QA).
1046
+ const left = () => {
1047
+ const c = room.clients.get(h.id);
1048
+ if (c?.agent && c.helloed && c.seat !== null && !conn.qa) {
1049
+ const minutes = Math.round((Date.now() - c.joinedAt) / 60_000);
1050
+ if (minutes > 0) this.ctx.waitUntil(count(this.env, null, { metric: 'agent-minutes', subject: this.game ?? '', source: c.agent.role, n: minutes }).catch(() => {}));
1051
+ }
1052
+ h.onClose();
1053
+ this.report();
1054
+ };
1055
+ server.addEventListener('close', left);
1056
+ server.addEventListener('error', left);
866
1057
  }
867
1058
  this.start();
868
1059
  return new Response(null, { status: 101, webSocket: client });
@@ -915,17 +1106,23 @@ export class Table {
915
1106
  report() {
916
1107
  const room = this.room;
917
1108
  if (!room || !this.game) return;
918
- const players = room.facts().counts.players;
1109
+ const counts = room.facts().counts;
1110
+ // Players are people (an agent never counts as one, DESIGN D9); agents and AI bodies are reported apart.
1111
+ const players = counts.players;
1112
+ const agents = counts.agents ?? 0;
1113
+ const ai = counts.ai ?? 0;
919
1114
  // People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
920
1115
  let people = 0;
921
- for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.conn?.qa) people += 1;
1116
+ for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.agent && !c.conn?.qa) people += 1;
922
1117
  // On a change, and once a minute while anybody is here (so the Lobby's "playing now" forgets a room that died).
923
- if (players === this.lastReport && people === this.lastPeople && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
1118
+ if (players === this.lastReport && people === this.lastPeople && agents === this.lastAgents && ai === this.lastAi && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
924
1119
  this.lastReport = players;
925
1120
  this.lastPeople = people;
1121
+ this.lastAgents = agents;
1122
+ this.lastAi = ai;
926
1123
  this.lastReportAt = Date.now();
927
1124
  const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
928
- this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people }) }).catch(() => {}));
1125
+ this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people, agents, ai }) }).catch(() => {}));
929
1126
  }
930
1127
 
931
1128
  /** A round the host called over becomes one D1 row (once per round number). */
@@ -935,9 +1132,10 @@ export class Table {
935
1132
  this.recorded = r.n;
936
1133
  this.ctx.storage.put('recorded', r.n).catch(() => {});
937
1134
  const results = Array.isArray(r.results) ? r.results.slice(0, SEAT_MAX * 2) : [];
938
- const humans = results.filter((row) => row && !row.bot).length;
1135
+ // An AI's row is never a person's (the relay labelled it `agent`): rounds count agents as AI.
1136
+ const humans = results.filter((row) => row && !row.bot && !row.agent).length;
939
1137
  // A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
940
- const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null).every((c) => c.conn?.qa);
1138
+ const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null && !c.agent).every((c) => c.conn?.qa);
941
1139
  const db = this.env.DB;
942
1140
  const insert = db.prepare('INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
943
1141
  .bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
@@ -948,17 +1146,22 @@ export class Table {
948
1146
  }
949
1147
 
950
1148
  /**
951
- * LOBBY — one per game: which public room a new visitor joins. Strangers meet:
952
- * the fullest room that still has a seat wins, counting visitors it just sent
953
- * there who have not connected yet, so two people pressing Play together land
1149
+ * LOBBY — one per game: which room a new visitor joins. Strangers meet: the fullest room that still has a seat
1150
+ * wins, counting visitors it just sent there who have not connected yet, so two people pressing Play together land
954
1151
  * in the same room.
1152
+ *
1153
+ * SERVERS (section 17): one pool per server (`/join?server=`), never matched across: `public` is the game's pub-N
1154
+ * rooms, a server's are s-<id>-<n>. A server whose `rooms_max` rooms are all full sends the visitor to its fullest
1155
+ * room, where it waits as a spectator and is seated when a seat frees (`full: true`). An AI (`agent=1`) is only ever
1156
+ * sent to a room with people in it; it never opens one. Players are people: agents are reported apart.
955
1157
  */
956
1158
  export class Lobby {
957
1159
  constructor(ctx, env) {
958
1160
  this.ctx = ctx;
959
1161
  this.env = env;
960
1162
  this.rooms = new Map();
961
- this.next = 1;
1163
+ /** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
1164
+ this.next = { public: 1 };
962
1165
  /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
963
1166
  this.live = new Map();
964
1167
  this.peak = { day: null, players: 0, room: 0 };
@@ -968,8 +1171,8 @@ export class Lobby {
968
1171
  ctx.blockConcurrencyWhile(async () => {
969
1172
  const saved = await ctx.storage.get('lobby');
970
1173
  if (saved) {
971
- this.next = saved.next ?? 1;
972
- for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] });
1174
+ this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
1175
+ 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, pending: [], agentPending: [] });
973
1176
  for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
974
1177
  }
975
1178
  });
@@ -995,11 +1198,12 @@ export class Lobby {
995
1198
  if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
996
1199
  }
997
1200
 
998
- save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })), closed: [...this.closed] }).catch(() => {}); }
1201
+ save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at }) => ({ name, players, agents, ai, server, at })), closed: [...this.closed] }).catch(() => {}); }
999
1202
 
1000
1203
  prune(now) {
1001
1204
  for (const [name, r] of this.rooms) {
1002
1205
  r.pending = r.pending.filter((t) => now - t < 20_000);
1206
+ r.agentPending = (r.agentPending ?? []).filter((t) => now - t < 20_000);
1003
1207
  if (r.players === 0 && !r.pending.length && now - r.at > 120_000) this.rooms.delete(name);
1004
1208
  }
1005
1209
  }
@@ -1010,26 +1214,49 @@ export class Lobby {
1010
1214
  this.prune(now);
1011
1215
  if (url.pathname === '/join') {
1012
1216
  const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
1217
+ const sid = url.searchParams.get('server') ?? 'public';
1218
+ const server = sid === 'public' || SERVER_ID.test(sid) ? sid : 'public';
1219
+ const roomsMax = Math.max(1, Math.min(16, Math.floor(Number(url.searchParams.get('rooms'))) || 16));
1013
1220
  // A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
1014
1221
  const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
1015
1222
  for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
1223
+ const pool = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server && !not.has(r.name) && !this.closed.has(r.name));
1224
+ if (url.searchParams.get('agent') === '1') {
1225
+ // An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
1226
+ const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
1227
+ let best = null;
1228
+ for (const r of pool) {
1229
+ const agents = (r.agents ?? 0) + (r.agentPending ?? []).length;
1230
+ if (!(r.players > 0) || (reserve ? agents >= reserve : r.players + r.pending.length + agents >= max)) continue;
1231
+ if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
1232
+ }
1233
+ if (!best) return json({ room: null, players: 0, max });
1234
+ (best.agentPending ??= []).push(now);
1235
+ return json({ room: best.name, players: best.players, max });
1236
+ }
1016
1237
  let best = null;
1017
- for (const r of this.rooms.values()) {
1018
- if (not.has(r.name) || this.closed.has(r.name)) continue;
1238
+ for (const r of pool) {
1019
1239
  const fill = r.players + r.pending.length;
1020
1240
  if (fill >= max) continue;
1021
1241
  if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
1022
1242
  }
1023
1243
  let room = best?.r;
1244
+ let full = false;
1245
+ if (!room && server !== 'public' && pool.length >= roomsMax) {
1246
+ // Every room this server may have is full: the fullest one, where the visitor waits for a seat.
1247
+ room = pool.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), pool[0]);
1248
+ full = true;
1249
+ }
1024
1250
  if (!room) {
1025
- while (not.has(`pub-${this.next}`) || this.closed.has(`pub-${this.next}`)) this.next += 1;
1026
- room = { name: `pub-${this.next}`, players: 0, at: now, pending: [] };
1027
- this.next += 1;
1251
+ let n = this.next[server] ?? 1;
1252
+ while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
1253
+ room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, pending: [], agentPending: [] };
1254
+ this.next[server] = n + 1;
1028
1255
  this.rooms.set(room.name, room);
1029
1256
  }
1030
1257
  room.pending.push(now);
1031
1258
  this.save();
1032
- return json({ room: room.name, players: room.players, max });
1259
+ return json({ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) });
1033
1260
  }
1034
1261
  if (url.pathname === '/report' && request.method === 'POST') {
1035
1262
  const body = await request.json().catch(() => ({}));
@@ -1042,30 +1269,36 @@ export class Lobby {
1042
1269
  return json({ ok: true, closed: body.closed > now });
1043
1270
  }
1044
1271
  const seated = Math.max(0, Math.floor(Number(body.players) || 0));
1045
- if (seated > 0) this.seen.set(body.room, { room: body.room, players: seated, people: Math.max(0, Math.floor(Number(body.people ?? seated) || 0)), at: now });
1272
+ const agents = Math.max(0, Math.floor(Number(body.agents) || 0));
1273
+ const ai = Math.max(agents, Math.floor(Number(body.ai) || 0));
1274
+ 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 });
1046
1275
  else this.seen.delete(body.room);
1047
1276
  if (this.closed.has(body.room)) return json({ ok: true, closed: true });
1048
1277
  this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
1049
- // Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
1050
- if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
1051
- const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
1052
- const grew = Math.max(0, (Number(body.players) || 0) - r.players);
1053
- const changed = !this.rooms.has(r.name) || r.players !== Math.max(0, Number(body.players) || 0);
1278
+ // Only rooms of a pool are matched with strangers (pub-N, s-<id>-<n>); a named room (?room=) stays private.
1279
+ if (!this.rooms.has(body.room) && !pooledRoom(body.room)) return json({ ok: true, private: true });
1280
+ const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, pending: [], agentPending: [] };
1281
+ const grew = Math.max(0, seated - r.players);
1282
+ const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai;
1054
1283
  r.pending.splice(0, grew);
1055
- r.players = Math.max(0, Number(body.players) || 0);
1284
+ if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
1285
+ r.players = seated;
1286
+ r.agents = agents;
1287
+ r.ai = ai;
1056
1288
  r.at = now;
1057
1289
  this.rooms.set(r.name, r);
1058
1290
  if (changed) this.save(); // the minute's "still here" report writes nothing
1059
1291
  return json({ ok: true });
1060
1292
  }
1061
- if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
1293
+ 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 })) });
1062
1294
  if (url.pathname === '/busiest') {
1063
- // The public room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
1064
- // no seat. A closed room, or one named in `not`, is never the answer.
1295
+ // The pooled room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
1296
+ // 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.
1065
1297
  const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
1298
+ const pools = url.searchParams.has('pools') ? new Set(String(url.searchParams.get('pools')).split(',').filter(Boolean)) : null;
1066
1299
  let best = null;
1067
1300
  for (const r of this.rooms.values()) {
1068
- if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now) continue;
1301
+ if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
1069
1302
  if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
1070
1303
  }
1071
1304
  return json({ room: best?.name ?? null, players: best?.players ?? 0 });
@@ -1074,9 +1307,9 @@ export class Lobby {
1074
1307
  // The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
1075
1308
  for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
1076
1309
  const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
1077
- 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, at: r.at });
1310
+ 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 });
1078
1311
  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 });
1079
- return json({ rooms: [...rooms.values()].sort((a, b) => b.players - a.players).slice(0, 100) });
1312
+ return json({ rooms: [...rooms.values()].map((r) => ({ ...r, server: roomServer(r.room) })).sort((a, b) => b.players - a.players).slice(0, 100) });
1080
1313
  }
1081
1314
  if (url.pathname === '/now') {
1082
1315
  let players = 0;