@homie-rocks/studio 0.3.0 → 0.6.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.
@@ -161,6 +161,8 @@ export interface NetStats {
161
161
  snapHzOut: number;
162
162
  inputHzIn: number;
163
163
  inputHzOut: number;
164
+ /** Idle input frames not sent (identical to the last one, inside the keepalive interval). */
165
+ idleInputsSkipped?: number;
164
166
  lastSnapBytes: number;
165
167
  maxSnapBytes: number;
166
168
  bytesInPerS: number;
@@ -507,6 +509,13 @@ const IN_SLOW_CAP_PER_S = 15;
507
509
  const IN_SLOW_MS = 8000;
508
510
  /** A socket with this much still unsent does not queue another input frame (the newest one waits instead). */
509
511
  const IN_BACKLOG_BYTES = 2048;
512
+ /**
513
+ * An input frame identical to the last one sent (same avatar or intent, same held keys, no press, same reset epoch)
514
+ * goes out at most this often: a player standing still says so four times a second, not twenty. The host already
515
+ * holds that frame, so nothing it reads changes; every incoming socket message is a Durable Object request
516
+ * (billed 20:1), and in a 32-seat room the replicas' inputs are nearly all of them (NETPLAY.md §11).
517
+ */
518
+ const IDLE_INPUT_MS = 250;
510
519
 
511
520
  function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
512
521
  if (!held) return [];
@@ -635,9 +644,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
635
644
  let prevHeld = new Set<string>();
636
645
  let pendingPresses: Record<string, number> = {};
637
646
  let inputTimer: ReturnType<typeof setTimeout> | null = null;
647
+ /** The last input frame sent, as text (idle frames repeat it at most every IDLE_INPUT_MS). */
648
+ let lastInputSig = '';
638
649
  /** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
639
650
  const inSent: number[] = [];
640
651
  let inSlowUntil = 0;
652
+ let idleSkipped = 0;
641
653
  /** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
642
654
  let closedWhy: string | null = null;
643
655
  /** Wall time the socket went down (0 while connected): how long a reconnect has taken. */
@@ -1071,12 +1083,17 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1071
1083
  const budget = inputBudgetWait();
1072
1084
  const backlog = (ws?.bufferedAmount ?? 0) > IN_BACKLOG_BYTES;
1073
1085
  if (budget > 0 || backlog) { inputTimer = setTimeout(flushInput, backlog ? Math.max(budget, 30) : budget); return; }
1086
+ // Standing still: the host has this exact frame already. Say it again only as a keepalive.
1087
+ const pressed = Object.keys(pendingPresses).length > 0;
1088
+ let sig = '';
1089
+ try { sig = JSON.stringify([pendingA, pendingHeld, mine.rs]); } catch { sig = ''; }
1090
+ if (!pressed && sig && sig === lastInputSig && wall() - lastInputSentAt < IDLE_INPUT_MS) { idleSkipped += 1; return; }
1074
1091
  inSent.push(wall());
1075
1092
  inSeq += 1;
1076
1093
  const msg: Record<string, unknown> = { t: 'in', q: inSeq, a: pendingA, h: pendingHeld, r: mine.rs };
1077
1094
  if (Object.keys(pendingPresses).length) msg['p'] = pendingPresses;
1078
1095
  if (raw(msg)) {
1079
- inputOut.hit(wall()); pendingPresses = {};
1096
+ inputOut.hit(wall()); pendingPresses = {}; lastInputSig = sig;
1080
1097
  sentHist.push({ q: inSeq, a: pendingA, h: pendingHeld, at: wall(), r: mine.rs });
1081
1098
  if (sentHist.length > 64) sentHist.shift();
1082
1099
  }
@@ -1095,7 +1112,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1095
1112
  const t = wall();
1096
1113
  return {
1097
1114
  role, seat, host, connected, offline, rtt, offset: Math.round(offset),
1098
- snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t),
1115
+ snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t), idleInputsSkipped: idleSkipped,
1099
1116
  lastSnapBytes, maxSnapBytes, bytesInPerS: perSecond(bytesIn), bytesOutPerS: perSecond(bytesOut),
1100
1117
  interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
1101
1118
  owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.3.0",
4
- "description": "A game studio on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects, D1, R2), deploy, and the homie.rocks directory.",
3
+ "version": "0.6.0",
4
+ "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), song and video pages from the music/ and videos/ manifests, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "homepage": "https://homie.rocks/studio/",
@@ -36,6 +36,7 @@
36
36
  "port/",
37
37
  "worker/",
38
38
  "starters/",
39
+ "media/",
39
40
  "README.md",
40
41
  "LICENSE",
41
42
  "NOTICE"
package/worker/index.mjs CHANGED
@@ -15,23 +15,40 @@
15
15
  * /<game>/api/lobby which public room to join (Lobby Durable Object)
16
16
  * /api/games the catalogue plus live counts (D1 + Lobby)
17
17
  * /.well-known/homie-studio.json what the homie.rocks directory reads
18
- * /media/<key> the studio's large media, from R2 (when bound)
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
+ * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
21
+ * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
22
+ * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
23
+ *
24
+ * It counts, and never tracks (worker/stats.mjs): a page opened, a Play press, a room opened, a round finished,
25
+ * the peak of players, a song or video started, and which site sent the visitor (a host name), as daily counters
26
+ * in the studio's own D1. No cookie on a visitor, nothing sent anywhere.
19
27
  *
20
28
  * Bindings (site/wrangler.jsonc, written by `homie-studio new`): ASSETS (the
21
29
  * built site), TABLE and LOBBY (SQLite-backed Durable Objects, free plan
22
- * friendly), DB (D1: plays, finished rounds, the directory claim) and, when
23
- * the account has R2, MEDIA.
30
+ * friendly), DB (D1: plays, finished rounds, the directory claim) and, only
31
+ * after `homie-studio storage add` (R2 needs a payment method on the account),
32
+ * MEDIA. Everything else runs on Cloudflare's free Workers plan.
24
33
  *
25
- * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 2), run
34
+ * The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 3), run
26
35
  * unchanged inside the Table, so a game that plays in `homie-studio dev`
27
36
  * plays the same way here.
28
37
  */
29
38
  import { NetRoom } from './room.mjs';
30
- import { gamePage, homePage, notFoundPage, playPage } from './pages.mjs';
39
+ import { gamePage, homePage, mediaIndexPage, notFoundPage, playPage, songPage, videoPage } from './pages.mjs';
31
40
  import { qrSvg } from './qr.mjs';
41
+ import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
42
+ import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
43
+ import { ownerRoutes } from './stats-page.mjs';
44
+
45
+ export { SEAT_MAX } from './seats.mjs';
46
+ /** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
47
+ export { countVisit } from './stats.mjs';
32
48
 
33
49
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
34
50
  const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
51
+
35
52
  const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
36
53
  status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
37
54
  });
@@ -62,6 +79,71 @@ async function liveCounts(env, games) {
62
79
  return out;
63
80
  }
64
81
 
82
+ const MEDIA_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
83
+
84
+ /** `bytes=a-b`, `bytes=a-`, `bytes=-n` against a length, or null (a malformed or multi-range header is served whole). */
85
+ function parseRange(header, size) {
86
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim());
87
+ if (!m || (m[1] === '' && m[2] === '')) return null;
88
+ let start; let end;
89
+ if (m[1] === '') { const n = Number(m[2]); start = Math.max(0, size - n); end = size - 1; }
90
+ else { start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1); }
91
+ if (!(start <= end) || start >= size) return { unsatisfiable: true };
92
+ return { start, end };
93
+ }
94
+
95
+ /** A media file from the site's own assets, answering a byte range (phones seek audio and video with them). */
96
+ async function assetWithRange(request, env, url) {
97
+ const res = await env.ASSETS.fetch(new Request(url.toString(), { method: 'GET' }));
98
+ const range = request.headers.get('range');
99
+ if (!res.ok || !range || res.status === 206) return res;
100
+ const body = new Uint8Array(await res.arrayBuffer());
101
+ const r = parseRange(range, body.byteLength);
102
+ const headers = new Headers(res.headers);
103
+ headers.set('accept-ranges', 'bytes');
104
+ if (!r) return new Response(body, { status: 200, headers });
105
+ if (r.unsatisfiable) { headers.set('content-range', `bytes */${body.byteLength}`); return new Response(null, { status: 416, headers }); }
106
+ headers.set('content-range', `bytes ${r.start}-${r.end}/${body.byteLength}`);
107
+ headers.set('content-length', String(r.end - r.start + 1));
108
+ return new Response(body.subarray(r.start, r.end + 1), { status: 206, headers });
109
+ }
110
+
111
+ /** A file from the studio's R2 bucket, with byte ranges. */
112
+ async function mediaObject(request, env, key) {
113
+ const range = request.headers.get('range');
114
+ let head = null;
115
+ if (range) head = await env.MEDIA.head(key);
116
+ if (range && !head) return new Response('not found', { status: 404 });
117
+ const r = head ? parseRange(range, head.size) : null;
118
+ if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}` } });
119
+ const obj = await env.MEDIA.get(key, r ? { range: { offset: r.start, length: r.end - r.start + 1 } } : undefined);
120
+ if (!obj) return new Response('not found', { status: 404 });
121
+ const headers = new Headers();
122
+ obj.writeHttpMetadata(headers);
123
+ headers.set('etag', obj.httpEtag);
124
+ headers.set('cache-control', 'public, max-age=3600');
125
+ headers.set('accept-ranges', 'bytes');
126
+ if (r) {
127
+ headers.set('content-range', `bytes ${r.start}-${r.end}/${head.size}`);
128
+ headers.set('content-length', String(r.end - r.start + 1));
129
+ return new Response(obj.body, { status: 206, headers });
130
+ }
131
+ headers.set('content-length', String(obj.size));
132
+ return new Response(obj.body, { headers });
133
+ }
134
+
135
+ /** A catalogue media row as the directory reads it (absolute addresses). */
136
+ function mediaRow(e, origin, kind) {
137
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u ?? null);
138
+ const main = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'audio' : 'video'));
139
+ const art = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'cover' : 'poster'));
140
+ return {
141
+ slug: e.slug, kind: e.kind, title: e.title, blurb: e.blurb ?? '', duration: e.duration ?? null,
142
+ page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(art?.url),
143
+ credits: e.credits ?? null, rights: e.rights ?? null, for: e.for ?? null,
144
+ };
145
+ }
146
+
65
147
  /** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
66
148
  async function gameDocument(request, env, url, game, meta) {
67
149
  const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
@@ -104,14 +186,62 @@ async function gameAsset(env, url, game, rest) {
104
186
  return out;
105
187
  }
106
188
 
189
+ /**
190
+ * A studio that shares its "played this week" (studio.json `stats.share`) asks the directory to re-read its
191
+ * manifest once a day per Worker instance: the directory reads the number from the studio's own site, so nothing
192
+ * it is told can be forged by anyone else. Nothing else is ever sent.
193
+ */
194
+ let sharedDay = null;
195
+ function shareDaily(cat, url, ctx) {
196
+ if (!cat.studio?.stats?.share || url.protocol !== 'https:' || sharedDay === today()) return;
197
+ sharedDay = today();
198
+ const directory = String(cat.studio.directory ?? 'https://homie.rocks').replace(/\/+$/, '');
199
+ const ping = fetch(`${directory}/api/studio/played`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url.origin }) }).catch(() => {});
200
+ if (ctx?.waitUntil) ctx.waitUntil(ping);
201
+ }
202
+
203
+ /** A song's or video's player started (the page's one beacon, sent once per page): one counter. */
204
+ async function mediaBeat(request, env, ctx, url) {
205
+ const site = request.headers.get('sec-fetch-site');
206
+ const origin = request.headers.get('origin');
207
+ if (origin && origin !== 'null' ? origin !== url.origin : site !== 'same-origin') return new Response(null, { status: 403 });
208
+ if (Number(request.headers.get('content-length') ?? 0) > 256) return new Response(null, { status: 413 });
209
+ let body = null;
210
+ try { body = JSON.parse(await request.text()); } catch { return new Response(null, { status: 400 }); }
211
+ const kind = body?.k === 'song' || body?.k === 'video' ? body.k : null;
212
+ const slug = typeof body?.s === 'string' && MEDIA_SLUG.test(body.s) ? body.s : null;
213
+ if (!kind || !slug) return new Response(null, { status: 400 });
214
+ const cat = await catalogue(env, url.origin);
215
+ const known = (kind === 'song' ? cat.songs : cat.videos) ?? [];
216
+ if (!known.some((e) => e.slug === slug)) return new Response(null, { status: 404 });
217
+ if (!isQa(request)) await count(env, ctx, { metric: kind, subject: slug }); // house QA is never counted
218
+ return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });
219
+ }
220
+
107
221
  export default {
108
- async fetch(request, env) {
222
+ async fetch(request, env, ctx) {
109
223
  const url = new URL(request.url);
110
224
  const path = url.pathname;
111
225
  const parts = path.split('/').filter(Boolean);
112
226
 
227
+ if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: () => catalogue(env, url.origin) });
228
+ if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
229
+ if (path === '/api/stats') {
230
+ if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
231
+ if ((await ownerAllowed(request, env, { kinds: ['read'] })) !== 'read') {
232
+ 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' });
233
+ }
234
+ const cat = await catalogue(env, url.origin);
235
+ const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
236
+ try {
237
+ return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
238
+ } catch (error) {
239
+ 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);
240
+ }
241
+ }
113
242
  if (path === '/.well-known/homie-studio.json') {
114
243
  const cat = await catalogue(env, url.origin);
244
+ const played = cat.studio?.stats?.share ? await playedThisWeek(env) : null;
115
245
  return json({
116
246
  v: 1,
117
247
  kind: 'homie-studio',
@@ -124,6 +254,10 @@ export default {
124
254
  id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
125
255
  page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
126
256
  })),
257
+ songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
258
+ videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos')),
259
+ // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
260
+ ...(played ? { played } : {}),
127
261
  }, 200, { 'access-control-allow-origin': '*' });
128
262
  }
129
263
  if (path === '/api/games') {
@@ -133,16 +267,32 @@ export default {
133
267
  if (path.startsWith('/media/')) {
134
268
  if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
135
269
  const key = decodeURIComponent(path.slice('/media/'.length));
136
- const obj = await env.MEDIA.get(key);
137
- if (!obj) return new Response('not found', { status: 404 });
138
- const headers = new Headers();
139
- obj.writeHttpMetadata(headers);
140
- headers.set('etag', obj.httpEtag);
141
- headers.set('cache-control', 'public, max-age=3600');
142
- return new Response(obj.body, { headers });
270
+ if (!key || key.includes('..')) return new Response('not found', { status: 404 });
271
+ return mediaObject(request, env, key);
272
+ }
273
+ if (parts[0] === 'music' || parts[0] === 'videos') {
274
+ const kind = parts[0];
275
+ if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
276
+ const cat = await catalogue(env, url.origin);
277
+ if (parts.length === 1) { await countVisit(request, env, ctx, kind); return mediaIndexPage(cat, kind); }
278
+ const slug = parts[1];
279
+ const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
280
+ const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
281
+ if (parts.length === 2) {
282
+ if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`);
283
+ if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
284
+ await countVisit(request, env, ctx, `${kind}/${slug}`);
285
+ return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
286
+ }
287
+ if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.');
288
+ const file = await assetWithRange(request, env, url);
289
+ if (file.status === 404) return notFoundPage('Nothing here.');
290
+ return file;
143
291
  }
144
292
  if (path === '/' || path === '/index.html') {
145
293
  const cat = await catalogue(env, url.origin);
294
+ await countVisit(request, env, ctx, 'home');
295
+ shareDaily(cat, url, ctx);
146
296
  return homePage(cat, await liveCounts(env, cat.games ?? []));
147
297
  }
148
298
 
@@ -153,13 +303,14 @@ export default {
153
303
  if (!meta) return env.ASSETS.fetch(request);
154
304
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
155
305
  const sub = parts.slice(1).join('/');
156
- if (sub === '') return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0);
306
+ if (sub === '') { await countVisit(request, env, ctx, game); shareDaily(cat, url, ctx); return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0); }
157
307
  if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
308
+ await countVisit(request, env, ctx, game, 'screen');
158
309
  // The big screen picks its room now, so the QR it shows puts every phone in that same room.
159
310
  let room = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : null;
160
311
  if (!room) {
161
312
  try {
162
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
313
+ const max = seatsOf(meta);
163
314
  room = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null;
164
315
  } catch { room = null; }
165
316
  }
@@ -168,9 +319,9 @@ export default {
168
319
  try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
169
320
  return playPage(cat, meta, { screen: true, joinUrl, qr, room });
170
321
  }
171
- if (sub === 'play') return playPage(cat, meta);
322
+ if (sub === 'play') { await countVisit(request, env, ctx, game, 'play'); shareDaily(cat, url, ctx); return playPage(cat, meta); }
172
323
  if (sub === 'api/lobby') {
173
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
324
+ const max = seatsOf(meta);
174
325
  return env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
175
326
  }
176
327
  if (sub === '__net' || sub === '__watch') {
@@ -178,7 +329,7 @@ export default {
178
329
  const room = url.searchParams.get('room') || 'main';
179
330
  if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
180
331
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
181
- const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
332
+ const max = seatsOf(meta);
182
333
  const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
183
334
  return stub.fetch(new Request(target, request));
184
335
  }
@@ -211,6 +362,8 @@ export class Table {
211
362
  this.code = null;
212
363
  this.recorded = 0;
213
364
  this.lastReport = -1;
365
+ /** This room was counted as opened (worker/stats.mjs `room`); cleared when its seats are all forgotten. */
366
+ this.openCounted = false;
214
367
  ctx.blockConcurrencyWhile(async () => {
215
368
  this.saved = (await ctx.storage.get('net')) ?? null;
216
369
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
@@ -225,6 +378,7 @@ export class Table {
225
378
  this.room = new NetRoom({
226
379
  code,
227
380
  maxPlayers: max,
381
+ perIp: perAddress(max),
228
382
  store: {
229
383
  save: (o) => { storage.put('net', o).catch(() => {}); },
230
384
  clear: () => { storage.delete('net').catch(() => {}); },
@@ -238,12 +392,14 @@ export class Table {
238
392
  const url = new URL(request.url);
239
393
  const game = url.searchParams.get('game');
240
394
  const code = url.searchParams.get('room');
241
- const max = Math.max(1, Math.min(16, Number(url.searchParams.get('max')) || 8));
395
+ const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
242
396
  const room = this.roomFor(game, code, max);
243
397
  const [client, server] = Object.values(new WebSocketPair());
244
398
  server.accept();
245
399
  const conn = {
246
400
  ip: request.headers.get('cf-connecting-ip'),
401
+ // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
402
+ qa: isQa(request),
247
403
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
248
404
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
249
405
  buffered: () => 0,
@@ -254,7 +410,17 @@ export class Table {
254
410
  server.addEventListener('error', () => w.onClose());
255
411
  } else {
256
412
  const h = room.attach(conn);
257
- server.addEventListener('message', (e) => { h.onMessage(typeof e.data === 'string' ? e.data : ''); });
413
+ // A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
414
+ // seats, so it is not counted twice). One counter write per room.
415
+ const fresh = room.seats.size === 0;
416
+ server.addEventListener('message', (e) => {
417
+ h.onMessage(typeof e.data === 'string' ? e.data : '');
418
+ if (fresh && !this.openCounted && room.seats.size > 0) {
419
+ this.openCounted = true;
420
+ if (conn.qa) return;
421
+ this.ctx.waitUntil(count(this.env, null, { metric: 'room', subject: this.game ?? '', source: /^pub-\d+$/.test(this.code ?? '') ? 'public' : 'named' }));
422
+ }
423
+ });
258
424
  server.addEventListener('close', () => { h.onClose(); this.report(); });
259
425
  server.addEventListener('error', () => { h.onClose(); this.report(); });
260
426
  }
@@ -271,6 +437,7 @@ export class Table {
271
437
  room.tick();
272
438
  n += 1;
273
439
  if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); }
440
+ if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
274
441
  if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); }
275
442
  }, 250);
276
443
  }
@@ -280,10 +447,16 @@ export class Table {
280
447
  const room = this.room;
281
448
  if (!room || !this.game) return;
282
449
  const players = room.facts().counts.players;
283
- if (players === this.lastReport) return;
450
+ // People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
451
+ let people = 0;
452
+ for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.conn?.qa) people += 1;
453
+ // On a change, and once a minute while anybody is here (so the Lobby's "playing now" forgets a room that died).
454
+ if (players === this.lastReport && people === this.lastPeople && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
284
455
  this.lastReport = players;
456
+ this.lastPeople = people;
457
+ this.lastReportAt = Date.now();
285
458
  const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
286
- this.ctx.waitUntil(lobby.fetch('https://lobby/report', { method: 'POST', body: JSON.stringify({ room: this.code, players }) }).catch(() => {}));
459
+ this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people }) }).catch(() => {}));
287
460
  }
288
461
 
289
462
  /** A round the host called over becomes one D1 row (once per round number). */
@@ -292,11 +465,16 @@ export class Table {
292
465
  if (!r || r.phase !== 'over' || !Number.isFinite(r.n) || r.n <= this.recorded || !this.env.DB) return;
293
466
  this.recorded = r.n;
294
467
  this.ctx.storage.put('recorded', r.n).catch(() => {});
295
- const results = Array.isArray(r.results) ? r.results.slice(0, 16) : [];
468
+ const results = Array.isArray(r.results) ? r.results.slice(0, SEAT_MAX * 2) : [];
296
469
  const humans = results.filter((row) => row && !row.bot).length;
297
- this.ctx.waitUntil(this.env.DB.prepare(
298
- 'INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)',
299
- ).bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString()).run().catch(() => {}));
470
+ // A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
471
+ const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null).every((c) => c.conn?.qa);
472
+ const db = this.env.DB;
473
+ const insert = db.prepare('INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
474
+ .bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
475
+ // The round and the people in it, on the studio's daily counters (worker/stats.mjs). One batch, per round.
476
+ 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);
477
+ this.ctx.waitUntil(db.batch([insert, ...counters]).catch(() => insert.run().catch(() => {})));
300
478
  }
301
479
  }
302
480
 
@@ -307,16 +485,40 @@ export class Table {
307
485
  * in the same room.
308
486
  */
309
487
  export class Lobby {
310
- constructor(ctx) {
488
+ constructor(ctx, env) {
311
489
  this.ctx = ctx;
490
+ this.env = env;
312
491
  this.rooms = new Map();
313
492
  this.next = 1;
493
+ /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
494
+ this.live = new Map();
495
+ this.peak = { day: null, players: 0, room: 0 };
314
496
  ctx.blockConcurrencyWhile(async () => {
315
497
  const saved = await ctx.storage.get('lobby');
316
498
  if (saved) { this.next = saved.next ?? 1; for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] }); }
317
499
  });
318
500
  }
319
501
 
502
+ /**
503
+ * The day's peak for this game, all rooms together and in one room: a counter write only when a new high is
504
+ * reached (in memory per day; after an eviction the stored max still wins), so at most a few a day.
505
+ */
506
+ notePeak(room, players, game) {
507
+ const now = Date.now();
508
+ if (players > 0) this.live.set(room, { players, at: now }); else this.live.delete(room);
509
+ for (const [name, r] of this.live) if (now - r.at > 180_000) this.live.delete(name); // a room that stopped reporting
510
+ const day = today();
511
+ if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
512
+ let all = 0;
513
+ for (const r of this.live.values()) all += r.players;
514
+ const writes = [];
515
+ if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
516
+ if (players > this.peak.room) { this.peak.room = players; writes.push(counter(this.env, { metric: 'peak-room', subject: game ?? this.game ?? '', n: players, day })); }
517
+ if (game) this.game = game;
518
+ const list = writes.filter(Boolean);
519
+ if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
520
+ }
521
+
320
522
  save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) }).catch(() => {}); }
321
523
 
322
524
  prune(now) {
@@ -331,7 +533,7 @@ export class Lobby {
331
533
  const now = Date.now();
332
534
  this.prune(now);
333
535
  if (url.pathname === '/join') {
334
- const max = Math.max(1, Math.min(16, Number(url.searchParams.get('max')) || 8));
536
+ const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
335
537
  let best = null;
336
538
  for (const r of this.rooms.values()) {
337
539
  const fill = r.players + r.pending.length;
@@ -351,18 +553,25 @@ export class Lobby {
351
553
  if (url.pathname === '/report' && request.method === 'POST') {
352
554
  const body = await request.json().catch(() => ({}));
353
555
  if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
556
+ this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
354
557
  // Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
355
558
  if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
356
559
  const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
357
560
  const grew = Math.max(0, (Number(body.players) || 0) - r.players);
561
+ const changed = !this.rooms.has(r.name) || r.players !== Math.max(0, Number(body.players) || 0);
358
562
  r.pending.splice(0, grew);
359
563
  r.players = Math.max(0, Number(body.players) || 0);
360
564
  r.at = now;
361
565
  this.rooms.set(r.name, r);
362
- this.save();
566
+ if (changed) this.save(); // the minute's "still here" report writes nothing
363
567
  return json({ ok: true });
364
568
  }
365
569
  if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
570
+ if (url.pathname === '/now') {
571
+ let players = 0;
572
+ for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }
573
+ return json({ players, rooms: this.live.size, peak: this.peak.day === today() ? { players: this.peak.players, room: this.peak.room } : { players: 0, room: 0 } });
574
+ }
366
575
  return json({ error: 'not found' }, 404);
367
576
  }
368
577
  }