@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/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 studio's home: every game, with a Play button
7
- * /<game>/ the game's page
8
- * /<game>/play the play shell: asks the Lobby for a public room and
9
- * boots the game in a sandboxed frame, seated at once
10
- * /<game>/tv the big screen: the same room as a spectator, with a
11
- * QR code phones scan to join (also /<game>/play?screen=1)
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 the catalogue plus live counts (D1 + Lobby)
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 2), run
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 { gamePage, homePage, mediaIndexPage, notFoundPage, playPage, songPage, videoPage } from './pages.mjs';
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 ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
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
- async function liveCounts(env, games) {
57
- const out = {};
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
- try {
60
- const res = await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms');
61
- const rooms = (await res.json()).rooms ?? [];
62
- out[g.id] = rooms.reduce((n, r) => n + (r.players || 0), 0);
63
- } catch { out[g.id] = 0; }
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
- return out;
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 = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : 'main';
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': "sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors 'self'",
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 res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/${rest}`));
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
- const url = new URL(request.url);
178
- const path = url.pathname;
179
- const parts = path.split('/').filter(Boolean);
180
-
181
- if (path === '/.well-known/homie-studio.json') {
182
- const cat = await catalogue(env, url.origin);
183
- return json({
184
- v: 1,
185
- kind: 'homie-studio',
186
- name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
187
- slug: cat.studio?.slug ?? null,
188
- site: url.origin,
189
- studio: cat.studio?.version ?? null,
190
- claim: await claimOf(env),
191
- games: (cat.games ?? []).map((g) => ({
192
- id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
193
- page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
194
- })),
195
- songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
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
- if (path.startsWith('/media/')) {
204
- if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
205
- const key = decodeURIComponent(path.slice('/media/'.length));
206
- if (!key || key.includes('..')) return new Response('not found', { status: 404 });
207
- return mediaObject(request, env, key);
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
- if (parts[0] === 'music' || parts[0] === 'videos') {
210
- const kind = parts[0];
211
- if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
212
- const cat = await catalogue(env, url.origin);
213
- if (parts.length === 1) return mediaIndexPage(cat, kind);
214
- const slug = parts[1];
215
- const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
216
- const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
217
- if (parts.length === 2) {
218
- if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`);
219
- if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
220
- return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
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
- if (path === '/' || path === '/index.html') {
228
- const cat = await catalogue(env, url.origin);
229
- return homePage(cat, await liveCounts(env, cat.games ?? []));
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
- const game = parts[0];
233
- if (game && GAME_ID.test(game)) {
234
- const cat = await catalogue(env, url.origin);
235
- const meta = (cat.games ?? []).find((g) => g.id === game);
236
- if (!meta) return env.ASSETS.fetch(request);
237
- if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
238
- const sub = parts.slice(1).join('/');
239
- if (sub === '') return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0);
240
- if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
241
- // The big screen picks its room now, so the QR it shows puts every phone in that same room.
242
- let room = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : null;
243
- if (!room) {
244
- try {
245
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
246
- room = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null;
247
- } catch { room = null; }
248
- }
249
- const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
250
- let qr = null;
251
- try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
252
- return playPage(cat, meta, { screen: true, joinUrl, qr, room });
253
- }
254
- if (sub === 'play') return playPage(cat, meta);
255
- if (sub === 'api/lobby') {
256
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
257
- return env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
258
- }
259
- if (sub === '__net' || sub === '__watch') {
260
- if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
261
- const room = url.searchParams.get('room') || 'main';
262
- if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
263
- const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
264
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
265
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
266
- return stub.fetch(new Request(target, request));
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
- if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta);
273
- if (sub.startsWith('__game/')) return gameAsset(env, url, game, sub.slice('__game/'.length));
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
- const asset = await env.ASSETS.fetch(request);
276
- if (asset.status === 404) return notFoundPage('Nothing here.');
277
- return asset;
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(16, Number(url.searchParams.get('max')) || 8));
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
- server.addEventListener('message', (e) => { h.onMessage(typeof e.data === 'string' ? e.data : ''); });
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
- if (players === this.lastReport) return;
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('https://lobby/report', { method: 'POST', body: JSON.stringify({ room: this.code, players }) }).catch(() => {}));
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, 16) : [];
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
- this.ctx.waitUntil(this.env.DB.prepare(
381
- 'INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)',
382
- ).bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString()).run().catch(() => {}));
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(16, Number(url.searchParams.get('max')) || 8));
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
  }