@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.
- package/README.md +76 -11
- package/bin/homie-studio.mjs +143 -29
- package/lib/build.mjs +61 -9
- package/lib/cloudflare.mjs +170 -35
- package/lib/directory.mjs +2 -2
- package/lib/media.mjs +126 -0
- package/lib/scaffold.mjs +87 -20
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +43 -1
- package/media/MEDIA.md +81 -0
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +3 -2
- package/worker/index.mjs +238 -29
- package/worker/pages.mjs +131 -3
- package/worker/room.mjs +9 -2
- package/worker/seats.mjs +20 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +311 -0
package/netplay/netplay.ts
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "A
|
|
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
|
-
* /
|
|
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,
|
|
23
|
-
*
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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(
|
|
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
|
}
|