@homie-rocks/studio 0.5.0 → 0.7.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 +54 -2
- package/bin/homie-studio.mjs +75 -11
- package/lib/build.mjs +72 -9
- package/lib/cloudflare.mjs +28 -11
- package/lib/directory.mjs +2 -2
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/media.mjs +2 -2
- package/lib/scaffold.mjs +115 -8
- package/lib/site.mjs +437 -0
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +43 -1
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +416 -136
- package/worker/pages.mjs +151 -208
- package/worker/room.mjs +37 -3
- package/worker/seats.mjs +20 -0
- package/worker/site.mjs +1052 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +314 -0
- package/worker/version.mjs +2 -0
package/worker/index.mjs
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @homie-rocks/studio/worker — a studio's own site, on the studio's own Cloudflare.
|
|
3
3
|
*
|
|
4
|
-
* One Worker serves the studio's pages and its public rooms:
|
|
4
|
+
* One Worker serves the studio's pages and its public rooms (site/SITE.md: the sections, the landings, posts and
|
|
5
|
+
* what a studio's site/ folder overrides):
|
|
5
6
|
*
|
|
6
|
-
* / the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* /<game>/
|
|
11
|
-
*
|
|
7
|
+
* / Home: the featured game, live rooms, games, latest posts, videos, music
|
|
8
|
+
* /games/ /rooms/ /posts/ the sections, as on homie.rocks (a section with nothing in it answers 404)
|
|
9
|
+
* /music/ /videos/ songs and videos (music/manifest.json, videos/manifest.json)
|
|
10
|
+
* /posts/<slug>/ a post (posts/*.md); /posts/feed.xml (Atom) and /posts/feed.json (JSON Feed)
|
|
11
|
+
* /<game>/ the game's landing: hero, Play, phone / computer / TV, live rooms, how to play, credits
|
|
12
|
+
* /<game>/credits the licence texts the game ships with
|
|
13
|
+
* /<game>/live the landing's live line and rooms (JSON)
|
|
14
|
+
* /<game>/play the play shell: asks the Lobby for a public room and boots the game in a
|
|
15
|
+
* sandboxed frame, seated at once; the room goes into the address, and a small
|
|
16
|
+
* room button shares it (Invite, Big screen, the room code)
|
|
17
|
+
* /<game>/tv the big screen: the same room as a spectator, with a QR code phones scan to join
|
|
18
|
+
* (also /<game>/play?screen=1)
|
|
12
19
|
* /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
|
|
13
20
|
* /<game>/__net?room= the room's netplay socket (Table Durable Object)
|
|
14
21
|
* /<game>/__watch?room= the room's facts, for the shell
|
|
15
22
|
* /<game>/api/lobby which public room to join (Lobby Durable Object)
|
|
16
|
-
* /api/games
|
|
23
|
+
* /api/games, /api/rooms the catalogue plus live counts; every public room playing now
|
|
17
24
|
* /.well-known/homie-studio.json what the homie.rocks directory reads
|
|
18
|
-
* /music/, /music/<slug>/ the studio's songs, scores and loops (music/manifest.json)
|
|
19
|
-
* /videos/, /videos/<slug>/ its trailers, music videos and cutscenes (videos/manifest.json)
|
|
20
25
|
* /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
|
|
26
|
+
* /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
|
|
27
|
+
* /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
|
|
28
|
+
* /_homie/site.js the pages' one script
|
|
29
|
+
*
|
|
30
|
+
* A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
|
|
31
|
+
* `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
|
|
32
|
+
*
|
|
33
|
+
* It counts, and never tracks (worker/stats.mjs): a page opened, a Play press, a room opened, a round finished,
|
|
34
|
+
* the peak of players, a song or video started, and which site sent the visitor (a host name), as daily counters
|
|
35
|
+
* in the studio's own D1. No cookie on a visitor, nothing sent anywhere.
|
|
21
36
|
*
|
|
22
37
|
* Bindings (site/wrangler.jsonc, written by `homie-studio new`): ASSETS (the
|
|
23
38
|
* built site), TABLE and LOBBY (SQLite-backed Durable Objects, free plan
|
|
@@ -25,16 +40,32 @@
|
|
|
25
40
|
* after `homie-studio storage add` (R2 needs a payment method on the account),
|
|
26
41
|
* MEDIA. Everything else runs on Cloudflare's free Workers plan.
|
|
27
42
|
*
|
|
28
|
-
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev
|
|
43
|
+
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 3), run
|
|
29
44
|
* unchanged inside the Table, so a game that plays in `homie-studio dev`
|
|
30
45
|
* plays the same way here.
|
|
31
46
|
*/
|
|
32
47
|
import { NetRoom } from './room.mjs';
|
|
33
|
-
import {
|
|
48
|
+
import { ROOM_ID, badRoomPage, frameAncestors, playPage } from './pages.mjs';
|
|
34
49
|
import { qrSvg } from './qr.mjs';
|
|
50
|
+
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
51
|
+
import {
|
|
52
|
+
SITE_JS, atomFeed, creditsPage, customPage, gameLanding, gamesPage, homePage, jsonFeed, mediaIndexPage, notFoundPage,
|
|
53
|
+
postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
|
|
54
|
+
} from './site.mjs';
|
|
55
|
+
import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
|
|
56
|
+
import { ownerRoutes } from './stats-page.mjs';
|
|
57
|
+
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
58
|
+
|
|
59
|
+
export { SEAT_MAX } from './seats.mjs';
|
|
60
|
+
/** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
|
|
61
|
+
export { countVisit } from './stats.mjs';
|
|
35
62
|
|
|
36
63
|
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
37
|
-
const
|
|
64
|
+
const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
65
|
+
/** A game's own files, framed by its play page: never x-frame-options DENY. */
|
|
66
|
+
const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/__game(?:\/|$)/;
|
|
67
|
+
const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
|
|
68
|
+
|
|
38
69
|
const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
|
|
39
70
|
status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
|
|
40
71
|
});
|
|
@@ -48,23 +79,37 @@ async function catalogue(env, origin) {
|
|
|
48
79
|
} catch { return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] }; }
|
|
49
80
|
}
|
|
50
81
|
|
|
82
|
+
/** The posts with their HTML (site/dist/_site/posts.json), for a post's page and the feeds. */
|
|
83
|
+
async function postsOf(env, origin) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await env.ASSETS.fetch(new Request(`${origin}/_site/posts.json`));
|
|
86
|
+
return res.ok ? ((await res.json()).posts ?? []) : [];
|
|
87
|
+
} catch { return []; }
|
|
88
|
+
}
|
|
89
|
+
|
|
51
90
|
async function claimOf(env) {
|
|
52
91
|
if (!env.DB) return null;
|
|
53
92
|
try { return (await env.DB.prepare('SELECT value FROM meta WHERE key = ?').bind('homie_claim').first())?.value ?? null; } catch { return null; }
|
|
54
93
|
}
|
|
55
94
|
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
/** Every public room with people in it, per game (the Lobby's list), and the number playing per game. */
|
|
96
|
+
async function roomsOf(env, games) {
|
|
97
|
+
const rooms = [];
|
|
98
|
+
const live = {};
|
|
58
99
|
await Promise.all(games.map(async (g) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
let list = [];
|
|
101
|
+
try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
|
|
102
|
+
const busy = list.filter((r) => (r.players || 0) > 0);
|
|
103
|
+
live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
|
|
104
|
+
for (const r of busy) rooms.push(roomView(g, r, seatsOf(g)));
|
|
64
105
|
}));
|
|
65
|
-
|
|
106
|
+
rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
|
|
107
|
+
return { rooms, live };
|
|
66
108
|
}
|
|
67
109
|
|
|
110
|
+
/** Kept for a studio's own wrapper Worker that reads `/api/games`'s `live`. */
|
|
111
|
+
async function liveCounts(env, games) { return (await roomsOf(env, games)).live; }
|
|
112
|
+
|
|
68
113
|
const MEDIA_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
69
114
|
|
|
70
115
|
/** `bytes=a-b`, `bytes=a-`, `bytes=-n` against a length, or null (a malformed or multi-range header is served whole). */
|
|
@@ -78,7 +123,7 @@ function parseRange(header, size) {
|
|
|
78
123
|
return { start, end };
|
|
79
124
|
}
|
|
80
125
|
|
|
81
|
-
/** A media file from the site's own assets, answering a byte range (phones seek audio and video with them). */
|
|
126
|
+
/** A media file from the site's own assets, answering a byte range (phones seek audio and video with them; Safari needs them to play a video at all). */
|
|
82
127
|
async function assetWithRange(request, env, url) {
|
|
83
128
|
const res = await env.ASSETS.fetch(new Request(url.toString(), { method: 'GET' }));
|
|
84
129
|
const range = request.headers.get('range');
|
|
@@ -131,10 +176,13 @@ function mediaRow(e, origin, kind) {
|
|
|
131
176
|
}
|
|
132
177
|
|
|
133
178
|
/** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
|
|
134
|
-
async function gameDocument(request, env, url, game, meta) {
|
|
179
|
+
async function gameDocument(request, env, url, game, meta, cat) {
|
|
180
|
+
const asked = url.searchParams.get('room');
|
|
181
|
+
// A room code the relay cannot use is refused here too, never swapped for another room.
|
|
182
|
+
if (asked !== null && !ROOM_ID.test(asked)) return new Response('That room link does not work: a room code is 1 to 32 letters, digits, - or _.\n', { status: 400, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
135
183
|
const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
|
|
136
|
-
if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet
|
|
137
|
-
const room =
|
|
184
|
+
if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`, cat);
|
|
185
|
+
const room = asked ?? 'main';
|
|
138
186
|
const want = url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
|
|
139
187
|
const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
|
|
140
188
|
const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
|
|
@@ -155,128 +203,302 @@ async function gameDocument(request, env, url, game, meta) {
|
|
|
155
203
|
return new Response(html, {
|
|
156
204
|
headers: {
|
|
157
205
|
'content-type': 'text/html; charset=utf-8',
|
|
158
|
-
'cache-control': 'no-store',
|
|
206
|
+
'cache-control': 'no-store, no-transform',
|
|
159
207
|
'access-control-allow-origin': '*',
|
|
160
208
|
// The game runs in an opaque origin: it cannot read this site's storage or cookies.
|
|
161
|
-
'content-security-policy':
|
|
209
|
+
'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${frameAncestors(cat)}`,
|
|
162
210
|
},
|
|
163
211
|
});
|
|
164
212
|
}
|
|
165
213
|
|
|
166
|
-
async function gameAsset(env, url, game, rest) {
|
|
214
|
+
async function gameAsset(request, env, url, game, rest) {
|
|
167
215
|
if (rest.includes('..')) return new Response('not found', { status: 404 });
|
|
168
|
-
const
|
|
216
|
+
const target = new URL(`${url.origin}/games/${game}/${rest}`);
|
|
217
|
+
const res = MEDIA_FILE.test(rest) && request.headers.get('range') ? await assetWithRange(request, env, target) : await env.ASSETS.fetch(new Request(target));
|
|
169
218
|
const out = new Response(res.body, res);
|
|
170
219
|
// Module scripts from the opaque-origin frame are CORS requests.
|
|
171
220
|
out.headers.set('access-control-allow-origin', '*');
|
|
172
221
|
return out;
|
|
173
222
|
}
|
|
174
223
|
|
|
224
|
+
/**
|
|
225
|
+
* A studio that shares its "played this week" (studio.json `stats.share`) asks the directory to re-read its
|
|
226
|
+
* manifest once a day per Worker instance: the directory reads the number from the studio's own site, so nothing
|
|
227
|
+
* it is told can be forged by anyone else. Nothing else is ever sent.
|
|
228
|
+
*/
|
|
229
|
+
let sharedDay = null;
|
|
230
|
+
function shareDaily(cat, url, ctx) {
|
|
231
|
+
if (!cat.studio?.stats?.share || url.protocol !== 'https:' || sharedDay === today()) return;
|
|
232
|
+
sharedDay = today();
|
|
233
|
+
const directory = String(cat.studio.directory ?? 'https://homie.rocks').replace(/\/+$/, '');
|
|
234
|
+
const ping = fetch(`${directory}/api/studio/played`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url.origin }) }).catch(() => {});
|
|
235
|
+
if (ctx?.waitUntil) ctx.waitUntil(ping);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A song's or video's player started (the page's one beacon, sent once per page): one counter. */
|
|
239
|
+
async function mediaBeat(request, env, ctx, url) {
|
|
240
|
+
const site = request.headers.get('sec-fetch-site');
|
|
241
|
+
const origin = request.headers.get('origin');
|
|
242
|
+
if (origin && origin !== 'null' ? origin !== url.origin : site !== 'same-origin') return new Response(null, { status: 403 });
|
|
243
|
+
if (Number(request.headers.get('content-length') ?? 0) > 256) return new Response(null, { status: 413 });
|
|
244
|
+
let body = null;
|
|
245
|
+
try { body = JSON.parse(await request.text()); } catch { return new Response(null, { status: 400 }); }
|
|
246
|
+
const kind = body?.k === 'song' || body?.k === 'video' ? body.k : null;
|
|
247
|
+
const slug = typeof body?.s === 'string' && MEDIA_SLUG.test(body.s) ? body.s : null;
|
|
248
|
+
if (!kind || !slug) return new Response(null, { status: 400 });
|
|
249
|
+
const cat = await catalogue(env, url.origin);
|
|
250
|
+
const known = (kind === 'song' ? cat.songs : cat.videos) ?? [];
|
|
251
|
+
if (!known.some((e) => e.slug === slug)) return new Response(null, { status: 404 });
|
|
252
|
+
if (!isQa(request)) await count(env, ctx, { metric: kind, subject: slug }); // house QA is never counted
|
|
253
|
+
return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* EVERY HTML ANSWER IS no-transform, AND NEVER FRAMED BY ANOTHER SITE. A custom domain on a zone with Cloudflare Web
|
|
258
|
+
* Analytics' automatic setup gets a beacon injected into every HTML answer not marked no-transform, and the beacon
|
|
259
|
+
* posts to /cdn-cgi/rum, which a Worker custom domain does not serve. Pages that name no frame rule get
|
|
260
|
+
* x-frame-options DENY (the game's own files are framed by its play page, so they keep theirs).
|
|
261
|
+
*/
|
|
262
|
+
function finish(res, path) {
|
|
263
|
+
if (!res || res.status === 101 || res.webSocket) return res;
|
|
264
|
+
if (!/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
|
|
265
|
+
const cache = res.headers.get('cache-control') ?? '';
|
|
266
|
+
const addNt = !/no-transform/i.test(cache);
|
|
267
|
+
const addXfo = !res.headers.has('x-frame-options') && !GAME_FILES.test(path);
|
|
268
|
+
if (!addNt && !addXfo) return res;
|
|
269
|
+
const headers = new Headers(res.headers);
|
|
270
|
+
if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
|
|
271
|
+
if (addXfo) headers.set('x-frame-options', 'DENY');
|
|
272
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
273
|
+
}
|
|
274
|
+
|
|
175
275
|
export default {
|
|
176
|
-
async fetch(request, env) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos')),
|
|
197
|
-
}, 200, { 'access-control-allow-origin': '*' });
|
|
198
|
-
}
|
|
199
|
-
if (path === '/api/games') {
|
|
200
|
-
const cat = await catalogue(env, url.origin);
|
|
201
|
-
return json({ studio: cat.studio, games: cat.games, live: await liveCounts(env, cat.games ?? []) });
|
|
276
|
+
async fetch(request, env, ctx) {
|
|
277
|
+
return finish(await route(request, env, ctx), new URL(request.url).pathname);
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
async function route(request, env, ctx) {
|
|
282
|
+
const url = new URL(request.url);
|
|
283
|
+
const path = url.pathname;
|
|
284
|
+
const parts = path.split('/').filter(Boolean);
|
|
285
|
+
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
286
|
+
let catP = null;
|
|
287
|
+
const getCat = () => (catP ??= catalogue(env, url.origin));
|
|
288
|
+
|
|
289
|
+
if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
|
|
290
|
+
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' } });
|
|
291
|
+
if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
|
|
292
|
+
if (path === '/api/stats') {
|
|
293
|
+
if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
|
|
294
|
+
if ((await ownerAllowed(request, env, { kinds: ['read'] })) !== 'read') {
|
|
295
|
+
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' });
|
|
202
296
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
297
|
+
const cat = await getCat();
|
|
298
|
+
const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
|
|
299
|
+
try {
|
|
300
|
+
return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return json({ ok: false, error: 'no-stats', message: `The counters are not in this studio's D1 yet: \`npm run deploy\` applies migration 0002_studio_stats.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
|
|
208
303
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
304
|
+
}
|
|
305
|
+
if (path === '/.well-known/homie-studio.json') {
|
|
306
|
+
const cat = await getCat();
|
|
307
|
+
const played = cat.studio?.stats?.share ? await playedThisWeek(env) : null;
|
|
308
|
+
return json({
|
|
309
|
+
v: 1,
|
|
310
|
+
kind: 'homie-studio',
|
|
311
|
+
name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
|
|
312
|
+
slug: cat.studio?.slug ?? null,
|
|
313
|
+
...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
|
|
314
|
+
site: url.origin,
|
|
315
|
+
studio: cat.studio?.version ?? null,
|
|
316
|
+
claim: await claimOf(env),
|
|
317
|
+
games: (cat.games ?? []).map((g) => ({
|
|
318
|
+
id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
|
|
319
|
+
page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
|
|
320
|
+
})),
|
|
321
|
+
songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
|
|
322
|
+
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos')),
|
|
323
|
+
// The studio's posts, for the hub (the full text is in /posts/feed.json).
|
|
324
|
+
posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
|
|
325
|
+
rooms: `${url.origin}/api/rooms`,
|
|
326
|
+
// Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
|
|
327
|
+
...(played ? { played } : {}),
|
|
328
|
+
}, 200, { 'access-control-allow-origin': '*' });
|
|
329
|
+
}
|
|
330
|
+
if (path === '/api/games') {
|
|
331
|
+
const cat = await getCat();
|
|
332
|
+
return json({ studio: cat.studio, games: cat.games, live: await liveCounts(env, cat.games ?? []) });
|
|
333
|
+
}
|
|
334
|
+
if (path === '/api/rooms') {
|
|
335
|
+
const cat = await getCat();
|
|
336
|
+
const { rooms } = await roomsOf(env, cat.games ?? []);
|
|
337
|
+
return json({ ok: true, playing: rooms.reduce((n, r) => n + r.players, 0), rooms }, 200, { 'access-control-allow-origin': '*' });
|
|
338
|
+
}
|
|
339
|
+
if (path.startsWith('/media/')) {
|
|
340
|
+
if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
|
|
341
|
+
const key = decodeURIComponent(path.slice('/media/'.length));
|
|
342
|
+
if (!key || key.includes('..')) return new Response('not found', { status: 404 });
|
|
343
|
+
return mediaObject(request, env, key);
|
|
344
|
+
}
|
|
345
|
+
if (path === '/posts/feed.xml' || path === '/posts/feed.json') {
|
|
346
|
+
const cat = await getCat();
|
|
347
|
+
if (!(cat.posts ?? []).length) return notFoundPage('This studio has no posts yet.', cat);
|
|
348
|
+
const posts = await postsOf(env, url.origin);
|
|
349
|
+
return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// A page the studio made itself (site/pages) wins at its address.
|
|
353
|
+
if (read && (path.endsWith('/') || !path.includes('.'))) {
|
|
354
|
+
const cat = await getCat();
|
|
355
|
+
const pages = cat.site?.pages ?? [];
|
|
356
|
+
const want = path.endsWith('/') ? path : `${path}/`;
|
|
357
|
+
if (pages.includes(want)) {
|
|
358
|
+
if (want !== path) return Response.redirect(`${url.origin}${want}${url.search}`, 301);
|
|
359
|
+
const res = await env.ASSETS.fetch(new Request(`${url.origin}/_site/pages${want}index.html`));
|
|
360
|
+
if (res.ok) {
|
|
361
|
+
const top = parts[0] ?? '';
|
|
362
|
+
const game = (cat.games ?? []).find((g) => g.id === top);
|
|
363
|
+
await countVisit(request, env, ctx, parts.length === 0 ? 'home' : game && parts.length === 1 ? game.id : parts.join('/').slice(0, 80));
|
|
364
|
+
const active = game ? 'games' : sectionsOf(cat).some((s) => s.key === top) ? top : null;
|
|
365
|
+
return customPage(cat, await res.text(), { active });
|
|
221
366
|
}
|
|
222
|
-
if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.');
|
|
223
|
-
const file = await assetWithRange(request, env, url);
|
|
224
|
-
if (file.status === 404) return notFoundPage('Nothing here.');
|
|
225
|
-
return file;
|
|
226
367
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (parts[0] === 'music' || parts[0] === 'videos') {
|
|
371
|
+
const kind = parts[0];
|
|
372
|
+
const cat = await getCat();
|
|
373
|
+
const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
|
|
374
|
+
// A studio with no songs has no Music: the tab is gone and the page is not found (the same for videos).
|
|
375
|
+
if (!list.length) return notFoundPage(kind === 'music' ? 'This studio has no music yet.' : 'This studio has no videos yet.', cat);
|
|
376
|
+
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
|
|
377
|
+
if (parts.length === 1) { await countVisit(request, env, ctx, kind); return mediaIndexPage(cat, kind, { origin: url.origin }); }
|
|
378
|
+
const slug = parts[1];
|
|
379
|
+
const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
|
|
380
|
+
if (parts.length === 2) {
|
|
381
|
+
if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`, cat);
|
|
382
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
|
|
383
|
+
await countVisit(request, env, ctx, `${kind}/${slug}`);
|
|
384
|
+
return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
|
|
230
385
|
}
|
|
386
|
+
if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.', cat);
|
|
387
|
+
const file = await assetWithRange(request, env, url);
|
|
388
|
+
if (file.status === 404) return notFoundPage('Nothing here.', cat);
|
|
389
|
+
return file;
|
|
390
|
+
}
|
|
391
|
+
if (path === '/' || path === '/index.html') {
|
|
392
|
+
const cat = await getCat();
|
|
393
|
+
await countVisit(request, env, ctx, 'home');
|
|
394
|
+
shareDaily(cat, url, ctx);
|
|
395
|
+
const { rooms, live } = await roomsOf(env, cat.games ?? []);
|
|
396
|
+
return homePage(cat, { origin: url.origin, rooms, live });
|
|
397
|
+
}
|
|
398
|
+
if (['games', 'rooms', 'posts'].includes(parts[0]) && parts.length === 1) {
|
|
399
|
+
const cat = await getCat();
|
|
400
|
+
const has = sectionsOf(cat).some((s) => s.key === parts[0]);
|
|
401
|
+
if (!has) return notFoundPage(parts[0] === 'posts' ? 'This studio has no posts yet.' : 'This studio has no games yet.', cat);
|
|
402
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${parts[0]}/`, 301);
|
|
403
|
+
await countVisit(request, env, ctx, parts[0]);
|
|
404
|
+
if (parts[0] === 'posts') return postsPage(cat, { origin: url.origin });
|
|
405
|
+
const { rooms, live } = await roomsOf(env, cat.games ?? []);
|
|
406
|
+
return parts[0] === 'games' ? gamesPage(cat, { origin: url.origin, live }) : roomsPage(cat, { origin: url.origin, rooms });
|
|
407
|
+
}
|
|
408
|
+
if (parts[0] === 'posts' && parts.length === 2 && POST_SLUG.test(parts[1])) {
|
|
409
|
+
const cat = await getCat();
|
|
410
|
+
const posts = (cat.posts ?? []).length ? await postsOf(env, url.origin) : [];
|
|
411
|
+
const post = posts.find((p) => p.slug === parts[1]);
|
|
412
|
+
if (!post) return notFoundPage(`No post called "${parts[1]}" here.`, cat);
|
|
413
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/posts/${post.slug}/`, 301);
|
|
414
|
+
await countVisit(request, env, ctx, `posts/${post.slug}`);
|
|
415
|
+
return postPage(cat, post, { origin: url.origin });
|
|
416
|
+
}
|
|
231
417
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (sub.startsWith('__homie/')) {
|
|
269
|
-
// A game made with Homie's arcade controls asks here for a Homie host; "not-a-homie" makes it stop asking (the same answer homie.rocks gives).
|
|
270
|
-
return json({ ok: false, error: 'not-a-homie' }, 200, { 'access-control-allow-origin': '*' });
|
|
418
|
+
const game = parts[0];
|
|
419
|
+
if (game && GAME_ID.test(game)) {
|
|
420
|
+
const cat = await getCat();
|
|
421
|
+
const meta = (cat.games ?? []).find((g) => g.id === game);
|
|
422
|
+
if (!meta) return fallback(request, env, url, getCat);
|
|
423
|
+
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
424
|
+
const sub = parts.slice(1).join('/');
|
|
425
|
+
const max = seatsOf(meta);
|
|
426
|
+
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
427
|
+
if (sub === '') {
|
|
428
|
+
await countVisit(request, env, ctx, game);
|
|
429
|
+
shareDaily(cat, url, ctx);
|
|
430
|
+
const { rooms, live } = await roomsOf(env, [meta]);
|
|
431
|
+
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
432
|
+
return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week });
|
|
433
|
+
}
|
|
434
|
+
if (sub === 'live') {
|
|
435
|
+
const { rooms, live } = await roomsOf(env, [meta]);
|
|
436
|
+
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
437
|
+
// The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
|
|
438
|
+
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' });
|
|
439
|
+
}
|
|
440
|
+
if (sub === 'credits') {
|
|
441
|
+
let texts = [];
|
|
442
|
+
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 = []; }
|
|
443
|
+
if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
|
|
444
|
+
return creditsPage(cat, meta, texts, { origin: url.origin });
|
|
445
|
+
}
|
|
446
|
+
if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
|
|
447
|
+
const asked = url.searchParams.get('room');
|
|
448
|
+
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen: true });
|
|
449
|
+
await countVisit(request, env, ctx, game, 'screen');
|
|
450
|
+
// The big screen picks its room now, so the QR it shows puts every phone in that same room.
|
|
451
|
+
let room = asked;
|
|
452
|
+
if (!room) {
|
|
453
|
+
try { room = (await (await lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
|
|
271
454
|
}
|
|
272
|
-
|
|
273
|
-
|
|
455
|
+
const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
456
|
+
let qr = null;
|
|
457
|
+
try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
458
|
+
return playPage(cat, meta, { screen: true, joinUrl, qr, room });
|
|
274
459
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
460
|
+
if (sub === 'play') {
|
|
461
|
+
const asked = url.searchParams.get('room');
|
|
462
|
+
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked);
|
|
463
|
+
await countVisit(request, env, ctx, game, 'play');
|
|
464
|
+
shareDaily(cat, url, ctx);
|
|
465
|
+
return playPage(cat, meta);
|
|
466
|
+
}
|
|
467
|
+
if (sub === 'api/lobby') return lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
|
|
468
|
+
if (sub === '__net' || sub === '__watch') {
|
|
469
|
+
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
470
|
+
const room = url.searchParams.get('room') || 'main';
|
|
471
|
+
if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
|
|
472
|
+
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
473
|
+
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
|
|
474
|
+
return stub.fetch(new Request(target, request));
|
|
475
|
+
}
|
|
476
|
+
if (sub.startsWith('__homie/')) {
|
|
477
|
+
// A game made with Homie's arcade controls asks here for a Homie host; "not-a-homie" makes it stop asking (the same answer homie.rocks gives).
|
|
478
|
+
return json({ ok: false, error: 'not-a-homie' }, 200, { 'access-control-allow-origin': '*' });
|
|
479
|
+
}
|
|
480
|
+
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta, cat);
|
|
481
|
+
if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
|
|
482
|
+
}
|
|
483
|
+
return fallback(request, env, url, getCat);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** A game's "played this week" on its landing, when the studio shares its numbers: Play presses and rounds with people. */
|
|
487
|
+
async function weekOf(env, game) {
|
|
488
|
+
const w = await playedThisWeek(env, game);
|
|
489
|
+
if (!w) return null;
|
|
490
|
+
let peak = 0;
|
|
491
|
+
try { peak = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
|
|
492
|
+
return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Everything else is a file of the built site (site/public, the games' files), with byte ranges for media. */
|
|
496
|
+
async function fallback(request, env, url, getCat) {
|
|
497
|
+
const asset = MEDIA_FILE.test(url.pathname) && request.headers.get('range') ? await assetWithRange(request, env, url) : await env.ASSETS.fetch(request);
|
|
498
|
+
if (asset.status === 404) return notFoundPage('Nothing here.', await getCat());
|
|
499
|
+
return asset;
|
|
500
|
+
}
|
|
501
|
+
|
|
280
502
|
|
|
281
503
|
/**
|
|
282
504
|
* TABLE — one public room: the netplay relay (room.mjs) for every browser in it.
|
|
@@ -294,6 +516,8 @@ export class Table {
|
|
|
294
516
|
this.code = null;
|
|
295
517
|
this.recorded = 0;
|
|
296
518
|
this.lastReport = -1;
|
|
519
|
+
/** This room was counted as opened (worker/stats.mjs `room`); cleared when its seats are all forgotten. */
|
|
520
|
+
this.openCounted = false;
|
|
297
521
|
ctx.blockConcurrencyWhile(async () => {
|
|
298
522
|
this.saved = (await ctx.storage.get('net')) ?? null;
|
|
299
523
|
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
@@ -308,6 +532,7 @@ export class Table {
|
|
|
308
532
|
this.room = new NetRoom({
|
|
309
533
|
code,
|
|
310
534
|
maxPlayers: max,
|
|
535
|
+
perIp: perAddress(max),
|
|
311
536
|
store: {
|
|
312
537
|
save: (o) => { storage.put('net', o).catch(() => {}); },
|
|
313
538
|
clear: () => { storage.delete('net').catch(() => {}); },
|
|
@@ -321,12 +546,14 @@ export class Table {
|
|
|
321
546
|
const url = new URL(request.url);
|
|
322
547
|
const game = url.searchParams.get('game');
|
|
323
548
|
const code = url.searchParams.get('room');
|
|
324
|
-
const max = Math.max(1, Math.min(
|
|
549
|
+
const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
|
|
325
550
|
const room = this.roomFor(game, code, max);
|
|
326
551
|
const [client, server] = Object.values(new WebSocketPair());
|
|
327
552
|
server.accept();
|
|
328
553
|
const conn = {
|
|
329
554
|
ip: request.headers.get('cf-connecting-ip'),
|
|
555
|
+
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
556
|
+
qa: isQa(request),
|
|
330
557
|
send: (text) => { try { server.send(text); } catch { /* closed */ } },
|
|
331
558
|
close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
|
|
332
559
|
buffered: () => 0,
|
|
@@ -337,7 +564,17 @@ export class Table {
|
|
|
337
564
|
server.addEventListener('error', () => w.onClose());
|
|
338
565
|
} else {
|
|
339
566
|
const h = room.attach(conn);
|
|
340
|
-
|
|
567
|
+
// A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
|
|
568
|
+
// seats, so it is not counted twice). One counter write per room.
|
|
569
|
+
const fresh = room.seats.size === 0;
|
|
570
|
+
server.addEventListener('message', (e) => {
|
|
571
|
+
h.onMessage(typeof e.data === 'string' ? e.data : '');
|
|
572
|
+
if (fresh && !this.openCounted && room.seats.size > 0) {
|
|
573
|
+
this.openCounted = true;
|
|
574
|
+
if (conn.qa) return;
|
|
575
|
+
this.ctx.waitUntil(count(this.env, null, { metric: 'room', subject: this.game ?? '', source: /^pub-\d+$/.test(this.code ?? '') ? 'public' : 'named' }));
|
|
576
|
+
}
|
|
577
|
+
});
|
|
341
578
|
server.addEventListener('close', () => { h.onClose(); this.report(); });
|
|
342
579
|
server.addEventListener('error', () => { h.onClose(); this.report(); });
|
|
343
580
|
}
|
|
@@ -354,6 +591,7 @@ export class Table {
|
|
|
354
591
|
room.tick();
|
|
355
592
|
n += 1;
|
|
356
593
|
if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); }
|
|
594
|
+
if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
|
|
357
595
|
if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); }
|
|
358
596
|
}, 250);
|
|
359
597
|
}
|
|
@@ -363,10 +601,16 @@ export class Table {
|
|
|
363
601
|
const room = this.room;
|
|
364
602
|
if (!room || !this.game) return;
|
|
365
603
|
const players = room.facts().counts.players;
|
|
366
|
-
|
|
604
|
+
// People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
|
|
605
|
+
let people = 0;
|
|
606
|
+
for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.conn?.qa) people += 1;
|
|
607
|
+
// On a change, and once a minute while anybody is here (so the Lobby's "playing now" forgets a room that died).
|
|
608
|
+
if (players === this.lastReport && people === this.lastPeople && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
|
|
367
609
|
this.lastReport = players;
|
|
610
|
+
this.lastPeople = people;
|
|
611
|
+
this.lastReportAt = Date.now();
|
|
368
612
|
const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
|
|
369
|
-
this.ctx.waitUntil(lobby.fetch(
|
|
613
|
+
this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people }) }).catch(() => {}));
|
|
370
614
|
}
|
|
371
615
|
|
|
372
616
|
/** A round the host called over becomes one D1 row (once per round number). */
|
|
@@ -375,11 +619,16 @@ export class Table {
|
|
|
375
619
|
if (!r || r.phase !== 'over' || !Number.isFinite(r.n) || r.n <= this.recorded || !this.env.DB) return;
|
|
376
620
|
this.recorded = r.n;
|
|
377
621
|
this.ctx.storage.put('recorded', r.n).catch(() => {});
|
|
378
|
-
const results = Array.isArray(r.results) ? r.results.slice(0,
|
|
622
|
+
const results = Array.isArray(r.results) ? r.results.slice(0, SEAT_MAX * 2) : [];
|
|
379
623
|
const humans = results.filter((row) => row && !row.bot).length;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
624
|
+
// A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
|
|
625
|
+
const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null).every((c) => c.conn?.qa);
|
|
626
|
+
const db = this.env.DB;
|
|
627
|
+
const insert = db.prepare('INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
628
|
+
.bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
|
|
629
|
+
// The round and the people in it, on the studio's daily counters (worker/stats.mjs). One batch, per round.
|
|
630
|
+
const counters = qaOnly ? [] : [counter(this.env, { metric: 'round', subject: this.game ?? '', source: humans ? 'people' : 'bots' }), humans ? counter(this.env, { metric: 'humans', subject: this.game ?? '', n: humans }) : null].filter(Boolean);
|
|
631
|
+
this.ctx.waitUntil(db.batch([insert, ...counters]).catch(() => insert.run().catch(() => {})));
|
|
383
632
|
}
|
|
384
633
|
}
|
|
385
634
|
|
|
@@ -390,16 +639,40 @@ export class Table {
|
|
|
390
639
|
* in the same room.
|
|
391
640
|
*/
|
|
392
641
|
export class Lobby {
|
|
393
|
-
constructor(ctx) {
|
|
642
|
+
constructor(ctx, env) {
|
|
394
643
|
this.ctx = ctx;
|
|
644
|
+
this.env = env;
|
|
395
645
|
this.rooms = new Map();
|
|
396
646
|
this.next = 1;
|
|
647
|
+
/** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
|
|
648
|
+
this.live = new Map();
|
|
649
|
+
this.peak = { day: null, players: 0, room: 0 };
|
|
397
650
|
ctx.blockConcurrencyWhile(async () => {
|
|
398
651
|
const saved = await ctx.storage.get('lobby');
|
|
399
652
|
if (saved) { this.next = saved.next ?? 1; for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] }); }
|
|
400
653
|
});
|
|
401
654
|
}
|
|
402
655
|
|
|
656
|
+
/**
|
|
657
|
+
* The day's peak for this game, all rooms together and in one room: a counter write only when a new high is
|
|
658
|
+
* reached (in memory per day; after an eviction the stored max still wins), so at most a few a day.
|
|
659
|
+
*/
|
|
660
|
+
notePeak(room, players, game) {
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
|
|
663
|
+
for (const [name, r] of this.live) if (now - r.at > 180_000) this.live.delete(name); // a room that stopped reporting
|
|
664
|
+
const day = today();
|
|
665
|
+
if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
|
|
666
|
+
let all = 0;
|
|
667
|
+
for (const r of this.live.values()) all += r.players;
|
|
668
|
+
const writes = [];
|
|
669
|
+
if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
|
|
670
|
+
if (players > this.peak.room) { this.peak.room = players; writes.push(counter(this.env, { metric: 'peak-room', subject: game ?? this.game ?? '', n: players, day })); }
|
|
671
|
+
if (game) this.game = game;
|
|
672
|
+
const list = writes.filter(Boolean);
|
|
673
|
+
if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
|
|
674
|
+
}
|
|
675
|
+
|
|
403
676
|
save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) }).catch(() => {}); }
|
|
404
677
|
|
|
405
678
|
prune(now) {
|
|
@@ -414,7 +687,7 @@ export class Lobby {
|
|
|
414
687
|
const now = Date.now();
|
|
415
688
|
this.prune(now);
|
|
416
689
|
if (url.pathname === '/join') {
|
|
417
|
-
const max = Math.max(1, Math.min(
|
|
690
|
+
const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
|
|
418
691
|
let best = null;
|
|
419
692
|
for (const r of this.rooms.values()) {
|
|
420
693
|
const fill = r.players + r.pending.length;
|
|
@@ -434,18 +707,25 @@ export class Lobby {
|
|
|
434
707
|
if (url.pathname === '/report' && request.method === 'POST') {
|
|
435
708
|
const body = await request.json().catch(() => ({}));
|
|
436
709
|
if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
|
|
710
|
+
this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
|
|
437
711
|
// Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
|
|
438
712
|
if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
|
|
439
713
|
const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
|
|
440
714
|
const grew = Math.max(0, (Number(body.players) || 0) - r.players);
|
|
715
|
+
const changed = !this.rooms.has(r.name) || r.players !== Math.max(0, Number(body.players) || 0);
|
|
441
716
|
r.pending.splice(0, grew);
|
|
442
717
|
r.players = Math.max(0, Number(body.players) || 0);
|
|
443
718
|
r.at = now;
|
|
444
719
|
this.rooms.set(r.name, r);
|
|
445
|
-
this.save();
|
|
720
|
+
if (changed) this.save(); // the minute's "still here" report writes nothing
|
|
446
721
|
return json({ ok: true });
|
|
447
722
|
}
|
|
448
723
|
if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
|
|
724
|
+
if (url.pathname === '/now') {
|
|
725
|
+
let players = 0;
|
|
726
|
+
for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }
|
|
727
|
+
return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
|
|
728
|
+
}
|
|
449
729
|
return json({ error: 'not found' }, 404);
|
|
450
730
|
}
|
|
451
731
|
}
|