@homie-rocks/studio 0.12.0 → 0.13.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 +86 -7
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +18 -5
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/office.mjs +172 -0
- package/lib/progress.mjs +39 -44
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +42 -1
- package/lib/setup.mjs +15 -23
- package/lib/stats.mjs +1 -1
- package/lib/template-history.json +49 -0
- package/lib/upgrade.mjs +5 -2
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +1 -1
- package/worker/index.mjs +197 -40
- 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/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 },
|
|
@@ -115,11 +132,23 @@ const claimMissed = new Map();
|
|
|
115
132
|
async function storedClaim(env, origin) {
|
|
116
133
|
if (!env.DB) return null;
|
|
117
134
|
try {
|
|
118
|
-
const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2)').bind(`homie_claim:${origin}`, 'homie_claim').all()).results ?? [];
|
|
119
|
-
|
|
135
|
+
const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2, ?3)').bind(`homie_claim:${origin}`, 'homie_claim', `homie_repo:${origin}`).all()).results ?? [];
|
|
136
|
+
const of = (k) => rows.find((r) => r.key === k)?.value ?? null;
|
|
137
|
+
return { own: of(`homie_claim:${origin}`), older: of('homie_claim'), repo: of(`homie_repo:${origin}`) };
|
|
120
138
|
} catch { return null; }
|
|
121
139
|
}
|
|
122
140
|
|
|
141
|
+
/*
|
|
142
|
+
* THE STUDIO'S GITHUB REPOSITORY (HOMIE_REPO, set by `homie-studio deploy` from the studio's own git remote or
|
|
143
|
+
* studio.json "github"; lib/repo.mjs). The site tells its directory which repository it is built from, with its
|
|
144
|
+
* claim, so the Claude app's hand-off opens Claude Code on the studio's own repository. It is never put in a public
|
|
145
|
+
* page: only the directory hears it, from this Worker (Cloudflare's CF-Worker header names the zone it runs on),
|
|
146
|
+
* and it is said again only when it changes.
|
|
147
|
+
*/
|
|
148
|
+
const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
|
|
149
|
+
const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return REPO.test(r) && r.toLowerCase() !== 'homie-rocks/homie' ? r : null; };
|
|
150
|
+
const repoSaid = new Map();
|
|
151
|
+
|
|
123
152
|
export function directoryOf(cat) {
|
|
124
153
|
const d = cat?.studio?.directory;
|
|
125
154
|
if (d === null || d === false) return null;
|
|
@@ -133,28 +162,34 @@ export function directoryOf(cat) {
|
|
|
133
162
|
/** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
|
|
134
163
|
async function claimOf(env, cat, url) {
|
|
135
164
|
const origin = url.origin;
|
|
136
|
-
|
|
165
|
+
const repo = repoOf(env);
|
|
166
|
+
if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
|
|
137
167
|
const kept = await storedClaim(env, origin);
|
|
138
|
-
if (kept?.own) { claimed.set(origin, kept.own); return kept.own; }
|
|
168
|
+
if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
|
|
139
169
|
const directory = directoryOf(cat);
|
|
140
170
|
const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
141
171
|
const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
|
|
142
172
|
&& Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
|
|
143
173
|
if (may) {
|
|
144
174
|
try {
|
|
145
|
-
|
|
175
|
+
// Asked when there is no claim yet, or to say a repository the directory has not heard from this site.
|
|
176
|
+
const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}${repo ? `&repo=${encodeURIComponent(repo)}` : ''}`, {
|
|
146
177
|
headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
|
|
147
178
|
});
|
|
148
179
|
const body = await res.json().catch(() => null);
|
|
149
180
|
if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
|
|
150
|
-
await env.DB.
|
|
181
|
+
await env.DB.batch([
|
|
182
|
+
env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim),
|
|
183
|
+
...(repo ? [env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_repo:${origin}`, repo)] : []),
|
|
184
|
+
]);
|
|
151
185
|
claimed.set(origin, body.claim);
|
|
186
|
+
if (repo) repoSaid.set(origin, repo);
|
|
152
187
|
return body.claim;
|
|
153
188
|
}
|
|
154
189
|
} catch { /* the directory is optional for a live site */ }
|
|
155
190
|
claimMissed.set(origin, Date.now());
|
|
156
191
|
}
|
|
157
|
-
return kept?.older ?? null;
|
|
192
|
+
return kept?.own ?? kept?.older ?? null;
|
|
158
193
|
}
|
|
159
194
|
|
|
160
195
|
/** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
|
|
@@ -273,9 +308,13 @@ async function gameDocument(request, env, url, game, meta, cat) {
|
|
|
273
308
|
const want = url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
|
|
274
309
|
const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
|
|
275
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;
|
|
276
315
|
const cfg = {
|
|
277
316
|
v: 1,
|
|
278
|
-
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}`,
|
|
317
|
+
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}`,
|
|
279
318
|
room,
|
|
280
319
|
...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
|
|
281
320
|
...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
|
|
@@ -373,7 +412,10 @@ async function route(request, env, ctx) {
|
|
|
373
412
|
const parts = path.split('/').filter(Boolean);
|
|
374
413
|
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
375
414
|
let catP = null;
|
|
376
|
-
|
|
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)));
|
|
377
419
|
|
|
378
420
|
// "Connect to Claude" (the template's first-run band): the directory's setup page, for this site's address.
|
|
379
421
|
if (path === '/_studio/connect' && read) {
|
|
@@ -381,17 +423,17 @@ async function route(request, env, ctx) {
|
|
|
381
423
|
if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
|
|
382
424
|
return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
|
|
383
425
|
}
|
|
384
|
-
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 });
|
|
385
427
|
if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
|
|
386
428
|
if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
|
|
387
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' } });
|
|
388
430
|
if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
|
|
389
431
|
if (path === '/api/stats') {
|
|
390
432
|
if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
|
|
391
|
-
if ((await ownerAllowed(request, env, { kinds: ['read'] }))
|
|
433
|
+
if (!(await ownerAllowed(request, env, { kinds: ['read', 'office'] }))) {
|
|
392
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' });
|
|
393
435
|
}
|
|
394
|
-
const cat = await
|
|
436
|
+
const cat = await getAll();
|
|
395
437
|
const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
|
|
396
438
|
try {
|
|
397
439
|
return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
|
|
@@ -417,16 +459,20 @@ async function route(request, env, ctx) {
|
|
|
417
459
|
// What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
|
|
418
460
|
// its mark is here). A Preview says it is one.
|
|
419
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 } : {}) } } : {}),
|
|
420
|
-
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) => {
|
|
421
464
|
// The card picture is the landing's hero still (what the landing leads with), else the game's cover.
|
|
422
465
|
const cover = gameCover(g);
|
|
466
|
+
const remix = remixOf(g, await settingsOf(env));
|
|
423
467
|
return {
|
|
424
468
|
id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
|
|
425
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` } : {}),
|
|
426
472
|
// Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
|
|
427
473
|
...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
|
|
428
474
|
};
|
|
429
|
-
}),
|
|
475
|
+
})),
|
|
430
476
|
songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
|
|
431
477
|
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
|
|
432
478
|
// The studio's posts, for the hub (the full text is in /posts/feed.json).
|
|
@@ -461,6 +507,17 @@ async function route(request, env, ctx) {
|
|
|
461
507
|
return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
|
|
462
508
|
}
|
|
463
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
|
+
|
|
464
521
|
// A page the studio made itself (site/pages) wins at its address.
|
|
465
522
|
if (read && (path.endsWith('/') || !path.includes('.'))) {
|
|
466
523
|
const cat = await getCat();
|
|
@@ -529,14 +586,24 @@ async function route(request, env, ctx) {
|
|
|
529
586
|
|
|
530
587
|
const game = parts[0];
|
|
531
588
|
if (game && GAME_ID.test(game)) {
|
|
532
|
-
const
|
|
533
|
-
const meta = (
|
|
589
|
+
const all = await getAll();
|
|
590
|
+
const meta = (all.games ?? []).find((g) => g.id === game);
|
|
534
591
|
if (!meta) return fallback(request, env, url, getCat);
|
|
535
592
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
593
|
+
const cat = await getCat();
|
|
536
594
|
const sub = parts.slice(1).join('/');
|
|
537
|
-
|
|
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);
|
|
538
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') ?? '' });
|
|
539
605
|
if (sub === '') {
|
|
606
|
+
if (!door.ok) return shut();
|
|
540
607
|
await countVisit(request, env, ctx, game);
|
|
541
608
|
shareDaily(cat, url, ctx);
|
|
542
609
|
const { rooms, live } = await roomsOf(env, [meta]);
|
|
@@ -544,50 +611,72 @@ async function route(request, env, ctx) {
|
|
|
544
611
|
return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week });
|
|
545
612
|
}
|
|
546
613
|
if (sub === 'live') {
|
|
614
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
547
615
|
const { rooms, live } = await roomsOf(env, [meta]);
|
|
548
616
|
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
549
617
|
// The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
|
|
550
|
-
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' });
|
|
551
619
|
}
|
|
552
620
|
if (sub === 'credits') {
|
|
621
|
+
if (!door.ok) return shut();
|
|
553
622
|
let texts = [];
|
|
554
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 = []; }
|
|
555
624
|
if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
|
|
556
625
|
return creditsPage(cat, meta, texts, { origin: url.origin });
|
|
557
626
|
}
|
|
558
|
-
if (sub === 'tv' ||
|
|
627
|
+
if (sub === 'tv' || sub === 'play') {
|
|
628
|
+
const screen = sub === 'tv' || url.searchParams.get('screen') === '1';
|
|
559
629
|
const asked = url.searchParams.get('room');
|
|
560
|
-
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen
|
|
561
|
-
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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 });
|
|
566
651
|
}
|
|
567
|
-
const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
568
|
-
let qr = null;
|
|
569
|
-
try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
570
|
-
return playPage(cat, meta, { screen: true, joinUrl, qr, room });
|
|
571
|
-
}
|
|
572
|
-
if (sub === 'play') {
|
|
573
|
-
const asked = url.searchParams.get('room');
|
|
574
|
-
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked);
|
|
575
652
|
await countVisit(request, env, ctx, game, 'play');
|
|
576
653
|
shareDaily(cat, url, ctx);
|
|
577
|
-
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' });
|
|
578
661
|
}
|
|
579
|
-
if (sub === 'api/lobby') return lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
|
|
580
662
|
if (sub === '__net' || sub === '__watch') {
|
|
581
663
|
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
582
664
|
const room = url.searchParams.get('room') || 'main';
|
|
583
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') : '';
|
|
584
670
|
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
585
|
-
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)}` : ''}`;
|
|
586
672
|
return stub.fetch(new Request(target, request));
|
|
587
673
|
}
|
|
588
674
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
589
675
|
if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
|
|
590
|
-
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
|
+
}
|
|
591
680
|
if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
|
|
592
681
|
}
|
|
593
682
|
return fallback(request, env, url, getCat);
|
|
@@ -628,9 +717,13 @@ export class Table {
|
|
|
628
717
|
this.lastReport = -1;
|
|
629
718
|
/** This room was counted as opened (worker/stats.mjs `room`); cleared when its seats are all forgotten. */
|
|
630
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();
|
|
631
723
|
ctx.blockConcurrencyWhile(async () => {
|
|
632
724
|
this.saved = (await ctx.storage.get('net')) ?? null;
|
|
633
725
|
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
726
|
+
this.officeSaved = (await ctx.storage.get('office')) ?? null;
|
|
634
727
|
});
|
|
635
728
|
}
|
|
636
729
|
|
|
@@ -649,6 +742,7 @@ export class Table {
|
|
|
649
742
|
},
|
|
650
743
|
});
|
|
651
744
|
if (this.saved) this.room.restore(this.saved);
|
|
745
|
+
if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
|
|
652
746
|
return this.room;
|
|
653
747
|
}
|
|
654
748
|
|
|
@@ -658,10 +752,30 @@ export class Table {
|
|
|
658
752
|
const code = url.searchParams.get('room');
|
|
659
753
|
const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
|
|
660
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));
|
|
661
770
|
const [client, server] = Object.values(new WebSocketPair());
|
|
662
771
|
server.accept();
|
|
772
|
+
const via = url.searchParams.get('via');
|
|
663
773
|
const conn = {
|
|
664
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,
|
|
665
779
|
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
666
780
|
qa: isQa(request),
|
|
667
781
|
send: (text) => { try { server.send(text); } catch { /* closed */ } },
|
|
@@ -679,6 +793,8 @@ export class Table {
|
|
|
679
793
|
const fresh = room.seats.size === 0;
|
|
680
794
|
server.addEventListener('message', (e) => {
|
|
681
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();
|
|
682
798
|
if (fresh && !this.openCounted && room.seats.size > 0) {
|
|
683
799
|
this.openCounted = true;
|
|
684
800
|
if (conn.qa) return;
|
|
@@ -699,6 +815,8 @@ export class Table {
|
|
|
699
815
|
const room = this.room;
|
|
700
816
|
if (!room) return;
|
|
701
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(() => {}); }
|
|
702
820
|
n += 1;
|
|
703
821
|
if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); }
|
|
704
822
|
if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
|
|
@@ -706,6 +824,14 @@ export class Table {
|
|
|
706
824
|
}, 250);
|
|
707
825
|
}
|
|
708
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
|
+
|
|
709
835
|
/** Tell the Lobby how many players this room has, when it changes. */
|
|
710
836
|
report() {
|
|
711
837
|
const room = this.room;
|
|
@@ -757,9 +883,16 @@ export class Lobby {
|
|
|
757
883
|
/** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
|
|
758
884
|
this.live = new Map();
|
|
759
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();
|
|
760
889
|
ctx.blockConcurrencyWhile(async () => {
|
|
761
890
|
const saved = await ctx.storage.get('lobby');
|
|
762
|
-
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
|
+
}
|
|
763
896
|
});
|
|
764
897
|
}
|
|
765
898
|
|
|
@@ -783,7 +916,7 @@ export class Lobby {
|
|
|
783
916
|
if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
|
|
784
917
|
}
|
|
785
918
|
|
|
786
|
-
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(() => {}); }
|
|
787
920
|
|
|
788
921
|
prune(now) {
|
|
789
922
|
for (const [name, r] of this.rooms) {
|
|
@@ -798,14 +931,19 @@ export class Lobby {
|
|
|
798
931
|
this.prune(now);
|
|
799
932
|
if (url.pathname === '/join') {
|
|
800
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);
|
|
801
937
|
let best = null;
|
|
802
938
|
for (const r of this.rooms.values()) {
|
|
939
|
+
if (not.has(r.name) || this.closed.has(r.name)) continue;
|
|
803
940
|
const fill = r.players + r.pending.length;
|
|
804
941
|
if (fill >= max) continue;
|
|
805
942
|
if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
|
|
806
943
|
}
|
|
807
944
|
let room = best?.r;
|
|
808
945
|
if (!room) {
|
|
946
|
+
while (not.has(`pub-${this.next}`) || this.closed.has(`pub-${this.next}`)) this.next += 1;
|
|
809
947
|
room = { name: `pub-${this.next}`, players: 0, at: now, pending: [] };
|
|
810
948
|
this.next += 1;
|
|
811
949
|
this.rooms.set(room.name, room);
|
|
@@ -817,6 +955,17 @@ export class Lobby {
|
|
|
817
955
|
if (url.pathname === '/report' && request.method === 'POST') {
|
|
818
956
|
const body = await request.json().catch(() => ({}));
|
|
819
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 });
|
|
820
969
|
this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
|
|
821
970
|
// Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
|
|
822
971
|
if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
|
|
@@ -831,6 +980,14 @@ export class Lobby {
|
|
|
831
980
|
return json({ ok: true });
|
|
832
981
|
}
|
|
833
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
|
+
}
|
|
834
991
|
if (url.pathname === '/now') {
|
|
835
992
|
let players = 0;
|
|
836
993
|
for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }
|