@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/README.md +92 -4
- package/bin/homie-studio.mjs +83 -8
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +5 -5
- package/lib/codex.mjs +28 -9
- package/lib/demo.mjs +85 -0
- package/lib/files.mjs +154 -0
- package/lib/handoff.mjs +128 -0
- package/lib/jobs.mjs +181 -0
- package/lib/mcp-tools.mjs +1000 -0
- package/lib/mcp.mjs +180 -0
- package/lib/office.mjs +172 -0
- package/lib/scaffold.mjs +98 -25
- package/lib/stats.mjs +1 -1
- package/lib/studio.mjs +4 -1
- package/lib/template-history.json +52 -0
- package/lib/upgrade.mjs +6 -3
- package/mcp/ui/bridge.js +148 -0
- package/mcp/ui/build.js +63 -0
- package/mcp/ui/card.css +73 -0
- package/mcp/ui/codex.js +23 -0
- package/mcp/ui/setup.js +72 -0
- package/mcp/ui/studio.js +41 -0
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +2 -1
- package/worker/index.mjs +172 -33
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/site.mjs +24 -2
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
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
|
-
|
|
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:
|
|
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'] }))
|
|
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
|
|
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
|
|
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
|
|
551
|
-
const meta = (
|
|
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
|
-
|
|
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' ||
|
|
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
|
|
579
|
-
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 ? `¬=${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')
|
|
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) {
|
|
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; }
|