@homie-rocks/studio 0.12.1 → 0.14.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
@@ -28,6 +28,10 @@
28
28
  * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
29
29
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
30
30
  * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
31
+ * /_studio/office the owner's back office: live rooms and who is in them, kick, mute, announce, close,
32
+ * each game's launch state (private, invite-only beta, public), remix switch, room size
33
+ * and invites (worker/office.mjs); /_studio/api/* is the same as JSON
34
+ * /<game>/invite an invite code spent for this browser's pass to an invite-only game
31
35
  * /_homie/site.js the pages' one script
32
36
  * /account/ a player's account: a passkey, a name, their data (worker/players.mjs, saves/SAVES.md)
33
37
  * /api/player/... sign in and up, saves, lifetime stats and memorials (the game's saves bridge, via the
@@ -60,10 +64,20 @@ import {
60
64
  } from './site.mjs';
61
65
  import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
62
66
  import { ownerRoutes } from './stats-page.mjs';
63
- import { playerRoutes } from './players.mjs';
67
+ import { playerRoutes, players as playerAccounts } from './players.mjs';
68
+ import {
69
+ accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, officeRoutes, publicCatalogue, redeemInvite, remixOf, seatsFor, settingsOf, ticketAllows, ticketFor,
70
+ ticketSub, usePlayers, verifyControl,
71
+ } from './office.mjs';
72
+
73
+ // The back office knows the studio's player accounts: a signed-in player is named in the office and held by a kick on
74
+ // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
75
+ usePlayers(playerAccounts);
64
76
  import { STUDIO_VERSION_TAG } from './version.mjs';
65
77
 
66
78
  export { SEAT_MAX } from './seats.mjs';
79
+ /** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
80
+ export { usePlayers } from './office.mjs';
67
81
  /** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
68
82
  export { countVisit } from './stats.mjs';
69
83
  /** Players and cloud saves (saves/SAVES.md): `players.list`, `players.get`, `players.isOwner`… for the back office. */
@@ -74,6 +88,9 @@ const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
74
88
  /** A game's own files, framed by its play page: never x-frame-options DENY. */
75
89
  const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/__game(?:\/|$)/;
76
90
  const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
91
+ /** A browser's room key (random, kept in the play page's own storage) and a play ticket, as they may appear in an address. */
92
+ const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
93
+ const TICKET_TEXT = /^[a-z0-9]{6,12}\.[A-Za-z0-9_~-]{1,140}\.[A-Za-z0-9_-]{32}$/;
77
94
 
78
95
  const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
79
96
  status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
@@ -291,9 +308,13 @@ async function gameDocument(request, env, url, game, meta, cat) {
291
308
  const want = url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
292
309
  const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
293
310
  const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
311
+ // 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
312
+ // in the socket's address, which every netplay helper opens as it is given.
313
+ const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : null;
314
+ const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
294
315
  const cfg = {
295
316
  v: 1,
296
- url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}`,
317
+ url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}`,
297
318
  room,
298
319
  ...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
299
320
  ...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
@@ -391,7 +412,10 @@ async function route(request, env, ctx) {
391
412
  const parts = path.split('/').filter(Boolean);
392
413
  const read = request.method === 'GET' || request.method === 'HEAD';
393
414
  let catP = null;
394
- const getCat = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
415
+ let pubP = null;
416
+ // Every game (for a game's own pages and the owner's), and the public ones (every list, the rooms, the manifest).
417
+ const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
418
+ const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
395
419
 
396
420
  // "Connect to Claude" (the template's first-run band): the directory's setup page, for this site's address.
397
421
  if (path === '/_studio/connect' && read) {
@@ -399,17 +423,17 @@ async function route(request, env, ctx) {
399
423
  if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
400
424
  return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
401
425
  }
402
- if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
426
+ if (path.startsWith('/_studio/')) return (await officeRoutes(request, env, url, { catalogueOf: getAll })) ?? ownerRoutes(request, env, url, { catalogueOf: getAll });
403
427
  if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
404
428
  if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
405
429
  if (path === '/_homie/site.js') return new Response(SITE_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' } });
406
430
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
407
431
  if (path === '/api/stats') {
408
432
  if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
409
- if ((await ownerAllowed(request, env, { kinds: ['read'] })) !== 'read') {
433
+ if (!(await ownerAllowed(request, env, { kinds: ['read', 'office'] }))) {
410
434
  return json({ ok: false, error: 'owner-only', message: 'The studio\'s numbers are for its owner. In the studio folder: `npx --no-install homie-studio stats` prints them; `npx --no-install homie-studio stats key` gives a read key for the Homie MCP tool studio_stats.' }, 401, { 'www-authenticate': 'Bearer' });
411
435
  }
412
- const cat = await getCat();
436
+ const cat = await getAll();
413
437
  const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
414
438
  try {
415
439
  return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
@@ -435,16 +459,20 @@ async function route(request, env, ctx) {
435
459
  // What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
436
460
  // its mark is here). A Preview says it is one.
437
461
  ...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
438
- games: (cat.games ?? []).map((g) => {
462
+ // Only the public games (a private or invite-only game leaves the directory the next time it reads this).
463
+ games: await Promise.all((cat.games ?? []).map(async (g) => {
439
464
  // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
440
465
  const cover = gameCover(g);
466
+ const remix = remixOf(g, await settingsOf(env));
441
467
  return {
442
468
  id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
443
469
  page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
470
+ // The owner's remix switch (0.13.0): whether the source is open for other studios to remix, and where.
471
+ remix, ...(remix ? { source: `${url.origin}/games/${g.id}/source.json` } : {}),
444
472
  // Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
445
473
  ...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
446
474
  };
447
- }),
475
+ })),
448
476
  songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
449
477
  videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
450
478
  // The studio's posts, for the hub (the full text is in /posts/feed.json).
@@ -479,6 +507,17 @@ async function route(request, env, ctx) {
479
507
  return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
480
508
  }
481
509
 
510
+ // A game's remix source is served while the game is public and its owner has not withdrawn it (the remix switch).
511
+ const src = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(path);
512
+ if (src) {
513
+ const all = await getAll();
514
+ const meta = (all.games ?? []).find((g) => g.id === src[1]);
515
+ if (meta) {
516
+ const settings = await settingsOf(env, { fresh: true });
517
+ if (launchOf(meta, settings, env) !== 'public' || !remixOf(meta, settings)) return json({ ok: false, error: 'not-shared', message: 'This game\'s source is not shared for remixing.' }, 404);
518
+ }
519
+ }
520
+
482
521
  // A page the studio made itself (site/pages) wins at its address.
483
522
  if (read && (path.endsWith('/') || !path.includes('.'))) {
484
523
  const cat = await getCat();
@@ -547,14 +586,24 @@ async function route(request, env, ctx) {
547
586
 
548
587
  const game = parts[0];
549
588
  if (game && GAME_ID.test(game)) {
550
- const cat = await getCat();
551
- const meta = (cat.games ?? []).find((g) => g.id === game);
589
+ const all = await getAll();
590
+ const meta = (all.games ?? []).find((g) => g.id === game);
552
591
  if (!meta) return fallback(request, env, url, getCat);
553
592
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
593
+ const cat = await getCat();
554
594
  const sub = parts.slice(1).join('/');
555
- const max = seatsOf(meta);
595
+ // Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
596
+ const settings = await settingsOf(env, { fresh: true });
597
+ // The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
598
+ // with access, and the owner may have made its rooms smaller than the game's own seats.
599
+ const launch = launchOf(meta, settings, env);
600
+ const max = seatsFor(meta, settings);
556
601
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
602
+ if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
603
+ const door = launch !== 'public' && ['', 'live', 'credits', 'tv', 'play', 'api/lobby'].includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
604
+ const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
557
605
  if (sub === '') {
606
+ if (!door.ok) return shut();
558
607
  await countVisit(request, env, ctx, game);
559
608
  shareDaily(cat, url, ctx);
560
609
  const { rooms, live } = await roomsOf(env, [meta]);
@@ -562,50 +611,72 @@ async function route(request, env, ctx) {
562
611
  return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week });
563
612
  }
564
613
  if (sub === 'live') {
614
+ if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
565
615
  const { rooms, live } = await roomsOf(env, [meta]);
566
616
  const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
567
617
  // The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
568
- return json({ ok: true, game, counted: true, playing: live[game] ?? 0, waiting: 0, max, rooms, road: { seats: 'any', start: 'now', room: 'public', big: true }, ...(week ? { week } : {}) }, 200, { 'cache-control': 'no-store' });
618
+ 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' });
569
619
  }
570
620
  if (sub === 'credits') {
621
+ if (!door.ok) return shut();
571
622
  let texts = [];
572
623
  try { const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/_landing/credits.json`)); if (res.ok) texts = (await res.json()).texts ?? []; } catch { texts = []; }
573
624
  if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
574
625
  return creditsPage(cat, meta, texts, { origin: url.origin });
575
626
  }
576
- if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
627
+ if (sub === 'tv' || sub === 'play') {
628
+ const screen = sub === 'tv' || url.searchParams.get('screen') === '1';
577
629
  const asked = url.searchParams.get('room');
578
- if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen: true });
579
- await countVisit(request, env, ctx, game, 'screen');
580
- // The big screen picks its room now, so the QR it shows puts every phone in that same room.
581
- let room = asked;
582
- if (!room) {
583
- try { room = (await (await lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
630
+ if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
631
+ if (!door.ok) return shut();
632
+ // A browser let into a game that is not public carries a ticket into its frame and sockets; the owner (signed
633
+ // in, in any game) also gets the owner's overlay, which no one else's page carries.
634
+ // The owner's page (in any game), a browser let into a game that is not public, and a signed-in player carry a
635
+ // ticket naming them (an invited player: the invite and the account), so the room knows who stays when the game
636
+ // narrows and whom a kick holds on every device.
637
+ const owner = door.owner || (launch === 'public' && await isOwner(request, env));
638
+ const holder = joinHolders(door.sub ?? (owner ? 'o' : null), await accountSub(request, env));
639
+ const ticket = holder ? await ticketFor(env, game, holder) : null;
640
+ if (screen) {
641
+ await countVisit(request, env, ctx, game, 'screen');
642
+ // The big screen picks its room now, so the QR it shows puts every phone in that same room.
643
+ let room = asked;
644
+ if (!room) {
645
+ try { room = (await (await lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
646
+ }
647
+ const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
648
+ let qr = null;
649
+ try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
650
+ return playPage(cat, meta, { screen: true, joinUrl, qr, room, ticket, owner, launch });
584
651
  }
585
- const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
586
- let qr = null;
587
- try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
588
- return playPage(cat, meta, { screen: true, joinUrl, qr, room });
589
- }
590
- if (sub === 'play') {
591
- const asked = url.searchParams.get('room');
592
- if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked);
593
652
  await countVisit(request, env, ctx, game, 'play');
594
653
  shareDaily(cat, url, ctx);
595
- return playPage(cat, meta);
654
+ return playPage(cat, meta, { ticket, owner, launch });
655
+ }
656
+ if (sub === 'api/lobby') {
657
+ if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
658
+ // A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
659
+ const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
660
+ return lobby().fetch(`https://lobby/join?max=${max}${not ? `&not=${encodeURIComponent(not)}` : ''}`, { method: 'POST' });
596
661
  }
597
- if (sub === 'api/lobby') return lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
598
662
  if (sub === '__net' || sub === '__watch') {
599
663
  if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
600
664
  const room = url.searchParams.get('room') || 'main';
601
665
  if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
666
+ // Who the ticket names: the door of a game that is not public, and in any game a signed-in player's account.
667
+ const who = url.searchParams.get('t') ? await ticketSub(env, game, url.searchParams.get('t')) : null;
668
+ if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('this game is not open to you', { status: 403 });
669
+ const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : '';
602
670
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
603
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
671
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}`;
604
672
  return stub.fetch(new Request(target, request));
605
673
  }
606
674
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
607
675
  if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
608
- if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta, cat);
676
+ if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
677
+ 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' } });
678
+ return gameDocument(request, env, url, game, meta, cat);
679
+ }
609
680
  if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
610
681
  }
611
682
  return fallback(request, env, url, getCat);
@@ -646,9 +717,13 @@ export class Table {
646
717
  this.lastReport = -1;
647
718
  /** This room was counted as opened (worker/stats.mjs `room`); cleared when its seats are all forgotten. */
648
719
  this.openCounted = false;
720
+ /** The owner's holds, banner and closed door (they outlive an empty room), and the controls already used. */
721
+ this.officeSaved = null;
722
+ this.seen = new Map();
649
723
  ctx.blockConcurrencyWhile(async () => {
650
724
  this.saved = (await ctx.storage.get('net')) ?? null;
651
725
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
726
+ this.officeSaved = (await ctx.storage.get('office')) ?? null;
652
727
  });
653
728
  }
654
729
 
@@ -667,6 +742,7 @@ export class Table {
667
742
  },
668
743
  });
669
744
  if (this.saved) this.room.restore(this.saved);
745
+ if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
670
746
  return this.room;
671
747
  }
672
748
 
@@ -676,10 +752,30 @@ export class Table {
676
752
  const code = url.searchParams.get('room');
677
753
  const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
678
754
  const room = this.roomFor(game, code, max);
755
+ // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
756
+ // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
757
+ if (url.pathname === '/__facts') return json(room.officeFacts());
758
+ if (url.pathname === '/__office') {
759
+ const ctl = await request.json().catch(() => null);
760
+ const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
761
+ if (why) return json({ ok: false, error: 'refused', why }, 403);
762
+ const res = room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
763
+ await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
764
+ if (ctl.op === 'close' && res.ok) this.tellLobby({ closed: ctl.args?.reopen ? 0 : res.until });
765
+ this.report();
766
+ return json(res);
767
+ }
768
+ // The owner's room size: a room already open takes it from the next visitor on.
769
+ if (room.seatCap !== max) room.setSeats(max, perAddress(max));
679
770
  const [client, server] = Object.values(new WebSocketPair());
680
771
  server.accept();
772
+ const via = url.searchParams.get('via');
681
773
  const conn = {
682
774
  ip: request.headers.get('cf-connecting-ip'),
775
+ // The browser's room key (what a kick holds out), and who the play page's ticket named (o, i-<invite>, p-<player>).
776
+ browser: url.searchParams.get('b') || null,
777
+ via: via || null,
778
+ player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
683
779
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
684
780
  qa: isQa(request),
685
781
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
@@ -697,6 +793,8 @@ export class Table {
697
793
  const fresh = room.seats.size === 0;
698
794
  server.addEventListener('message', (e) => {
699
795
  h.onMessage(typeof e.data === 'string' ? e.data : '');
796
+ // A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
797
+ if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.report();
700
798
  if (fresh && !this.openCounted && room.seats.size > 0) {
701
799
  this.openCounted = true;
702
800
  if (conn.qa) return;
@@ -717,6 +815,8 @@ export class Table {
717
815
  const room = this.room;
718
816
  if (!room) return;
719
817
  room.tick();
818
+ // A launch change the room applied by itself (after its round): its stored state says so too.
819
+ if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
720
820
  n += 1;
721
821
  if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); }
722
822
  if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
@@ -724,6 +824,14 @@ export class Table {
724
824
  }, 250);
725
825
  }
726
826
 
827
+ /** Tell the Lobby this room closed (until when; 0 when it opened again), so it sends nobody here meanwhile. */
828
+ tellLobby(extra) {
829
+ if (!this.game || !this.code) return;
830
+ const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
831
+ this.lastReport = -1;
832
+ this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players: 0, people: 0, ...extra }) }).catch(() => {}));
833
+ }
834
+
727
835
  /** Tell the Lobby how many players this room has, when it changes. */
728
836
  report() {
729
837
  const room = this.room;
@@ -775,9 +883,16 @@ export class Lobby {
775
883
  /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
776
884
  this.live = new Map();
777
885
  this.peak = { day: null, players: 0, room: 0 };
886
+ /** Every room with somebody seated, public or named, for the owner's office; and the rooms the owner closed (until when). */
887
+ this.seen = new Map();
888
+ this.closed = new Map();
778
889
  ctx.blockConcurrencyWhile(async () => {
779
890
  const saved = await ctx.storage.get('lobby');
780
- if (saved) { this.next = saved.next ?? 1; for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] }); }
891
+ if (saved) {
892
+ this.next = saved.next ?? 1;
893
+ for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] });
894
+ for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
895
+ }
781
896
  });
782
897
  }
783
898
 
@@ -801,7 +916,7 @@ export class Lobby {
801
916
  if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
802
917
  }
803
918
 
804
- save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) }).catch(() => {}); }
919
+ save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })), closed: [...this.closed] }).catch(() => {}); }
805
920
 
806
921
  prune(now) {
807
922
  for (const [name, r] of this.rooms) {
@@ -816,14 +931,19 @@ export class Lobby {
816
931
  this.prune(now);
817
932
  if (url.pathname === '/join') {
818
933
  const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
934
+ // A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
935
+ const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
936
+ for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
819
937
  let best = null;
820
938
  for (const r of this.rooms.values()) {
939
+ if (not.has(r.name) || this.closed.has(r.name)) continue;
821
940
  const fill = r.players + r.pending.length;
822
941
  if (fill >= max) continue;
823
942
  if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
824
943
  }
825
944
  let room = best?.r;
826
945
  if (!room) {
946
+ while (not.has(`pub-${this.next}`) || this.closed.has(`pub-${this.next}`)) this.next += 1;
827
947
  room = { name: `pub-${this.next}`, players: 0, at: now, pending: [] };
828
948
  this.next += 1;
829
949
  this.rooms.set(room.name, room);
@@ -835,6 +955,17 @@ export class Lobby {
835
955
  if (url.pathname === '/report' && request.method === 'POST') {
836
956
  const body = await request.json().catch(() => ({}));
837
957
  if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
958
+ // The owner closed this room: matched with nobody until then (and forgotten as a public room).
959
+ if (Number.isFinite(body.closed)) {
960
+ if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
961
+ this.seen.delete(body.room);
962
+ this.save();
963
+ return json({ ok: true, closed: body.closed > now });
964
+ }
965
+ const seated = Math.max(0, Math.floor(Number(body.players) || 0));
966
+ 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 });
967
+ else this.seen.delete(body.room);
968
+ if (this.closed.has(body.room)) return json({ ok: true, closed: true });
838
969
  this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
839
970
  // Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
840
971
  if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
@@ -849,6 +980,14 @@ export class Lobby {
849
980
  return json({ ok: true });
850
981
  }
851
982
  if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
983
+ if (url.pathname === '/office') {
984
+ // The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
985
+ for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
986
+ const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
987
+ 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 });
988
+ 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 });
989
+ return json({ rooms: [...rooms.values()].sort((a, b) => b.players - a.players).slice(0, 100) });
990
+ }
852
991
  if (url.pathname === '/now') {
853
992
  let players = 0;
854
993
  for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }