@homie-rocks/studio 0.14.4 → 0.16.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 +43 -6
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +48 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +50 -3
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +55 -0
- package/netplay/NETPLAY.md +317 -21
- package/netplay/netplay.ts +649 -33
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/hud.ts +10 -7
- package/port/index.ts +5 -2
- package/port/room.ts +48 -10
- package/port/skill.ts +28 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +71 -0
- package/starters/ember-vale/src/main.ts +58 -12
- package/starters/gem-rush/src/main.ts +128 -33
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +353 -68
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +622 -18
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +547 -41
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +166 -10
- package/worker/stats.mjs +9 -3
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -16,10 +16,21 @@
|
|
|
16
16
|
* room button shares it (Invite, Big screen, the room code)
|
|
17
17
|
* /<game>/tv the big screen: the same room as a spectator, with a QR code phones scan to join
|
|
18
18
|
* (also /<game>/play?screen=1)
|
|
19
|
+
* /<game>/watch?room= watch a live room from any player's view: the game itself, rendered by this browser
|
|
20
|
+
* as a watcher that never takes a seat, with a strip of the players to switch between
|
|
21
|
+
* (keys 1-9, A for Auto, O for the overview); no room: the busiest public room
|
|
22
|
+
* (NETPLAY.md section 16; game.json "watch": "overview" or false narrows it)
|
|
19
23
|
* /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
|
|
20
24
|
* /<game>/__net?room= the room's netplay socket (Table Durable Object)
|
|
21
25
|
* /<game>/__watch?room= the room's facts, for the shell
|
|
22
|
-
* /<game>/api/lobby which public room to join (Lobby Durable Object)
|
|
26
|
+
* /<game>/api/lobby which public room to join (Lobby Durable Object); ?server=<id>: a room of that server
|
|
27
|
+
* /<game>/api/watch which public room to watch: the busiest one now (nothing is reserved)
|
|
28
|
+
* /<game>/servers/ the game's servers (worker/servers.mjs): named, lasting room pools with their own
|
|
29
|
+
* policy (open, humans-only, hybrid, beginner) and door; /<game>/api/servers as JSON
|
|
30
|
+
* /<game>/s/<id>/ a server's page; /<game>/s/<id>/play its door, then the play page in its pool;
|
|
31
|
+
* POST /<game>/s/<id>/home joins it, leaves it or makes it home (a signed-in player)
|
|
32
|
+
* /<game>/api/agent an AI's seat (POST, Bearer agent pass; worker/agents.mjs): a room with people in it,
|
|
33
|
+
* its ticket and socket, and the frame to load (NETPLAY.md section 17)
|
|
23
34
|
* /api/games, /api/rooms the catalogue plus live counts; every public room playing now (/api/rooms may be
|
|
24
35
|
* cached for 15 s)
|
|
25
36
|
* /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
|
|
@@ -51,24 +62,30 @@
|
|
|
51
62
|
* after `homie-studio storage add` (R2 needs a payment method on the account),
|
|
52
63
|
* MEDIA. Everything else runs on Cloudflare's free Workers plan.
|
|
53
64
|
*
|
|
54
|
-
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev
|
|
65
|
+
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 6), run
|
|
55
66
|
* unchanged inside the Table, so a game that plays in `homie-studio dev`
|
|
56
67
|
* plays the same way here.
|
|
57
68
|
*/
|
|
58
|
-
import { NetRoom } from './room.mjs';
|
|
59
|
-
import { ROOM_ID, badRoomPage, frameAncestors, playPage } from './pages.mjs';
|
|
69
|
+
import { NetRoom, WATCH_POLICIES } from './room.mjs';
|
|
70
|
+
import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
71
|
+
import {
|
|
72
|
+
PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
|
|
73
|
+
setMembership,
|
|
74
|
+
} from './servers.mjs';
|
|
75
|
+
import { agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
|
|
76
|
+
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
60
77
|
import { qrSvg } from './qr.mjs';
|
|
61
78
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
62
79
|
import {
|
|
63
80
|
SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
|
|
64
|
-
notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
|
|
81
|
+
notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
|
|
65
82
|
} from './site.mjs';
|
|
66
|
-
import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
|
|
83
|
+
import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
|
|
67
84
|
import { ownerRoutes } from './stats-page.mjs';
|
|
68
85
|
import { playerRoutes, players as playerAccounts } from './players.mjs';
|
|
69
86
|
import {
|
|
70
|
-
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, remixOf, seatsFor, settingsOf, ticketAllows,
|
|
71
|
-
ticketSub, usePlayers, verifyControl,
|
|
87
|
+
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, remixOf, sameOrigin, seatsFor, settingsOf, ticketAllows,
|
|
88
|
+
ticketFor, ticketSub, usePlayers, verifyControl,
|
|
72
89
|
} from './office.mjs';
|
|
73
90
|
|
|
74
91
|
// The back office knows the studio's player accounts: a signed-in player is named in the office and held by a kick on
|
|
@@ -200,8 +217,12 @@ function named(cat, env) {
|
|
|
200
217
|
return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
|
|
201
218
|
}
|
|
202
219
|
|
|
203
|
-
/**
|
|
204
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Every public room with people in it, per game (the Lobby's list), and the number playing per game. A room of a
|
|
222
|
+
* server (section 17) says which, its policy, and how many AI are in it; a server that is not listed, closed, or has
|
|
223
|
+
* a door is left out of the lists (its own page shows its rooms to whoever it lets in).
|
|
224
|
+
*/
|
|
225
|
+
async function roomsOf(env, games, { servers = null } = {}) {
|
|
205
226
|
const rooms = [];
|
|
206
227
|
const live = {};
|
|
207
228
|
await Promise.all(games.map(async (g) => {
|
|
@@ -209,7 +230,13 @@ async function roomsOf(env, games) {
|
|
|
209
230
|
try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
|
|
210
231
|
const busy = list.filter((r) => (r.players || 0) > 0);
|
|
211
232
|
live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
|
|
212
|
-
|
|
233
|
+
const known = busy.some((r) => r.server && r.server !== 'public') ? (servers?.[g.id] ?? await serversOf(env, g).catch(() => [])) : [];
|
|
234
|
+
for (const r of busy) {
|
|
235
|
+
const sid = r.server ?? roomServer(r.name);
|
|
236
|
+
const srv = sid === 'public' ? (known.find((x) => x.id === 'public') ?? PUBLIC_SERVER) : known.find((x) => x.id === sid);
|
|
237
|
+
if (!srv || (sid !== 'public' && (!srv.listed || srv.state !== 'open' || srv.door !== 'open'))) continue;
|
|
238
|
+
rooms.push(roomView(g, r, seatsOf(g), srv));
|
|
239
|
+
}
|
|
213
240
|
}));
|
|
214
241
|
rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
|
|
215
242
|
return { rooms, live };
|
|
@@ -299,15 +326,23 @@ function notAHomie(request) {
|
|
|
299
326
|
return json({ ok: false, error: 'not-a-homie', message: 'this is a studio site, not a Homie box; a game here plays on its own' }, 200, KNOCK_CORS);
|
|
300
327
|
}
|
|
301
328
|
|
|
302
|
-
/**
|
|
303
|
-
|
|
329
|
+
/**
|
|
330
|
+
* The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). `agent`: the
|
|
331
|
+
* frame was opened with an agent's ticket (section 17): the game plays as that AI, named "<label> · AI".
|
|
332
|
+
*/
|
|
333
|
+
async function gameDocument(request, env, url, game, meta, cat, { agent = null } = {}) {
|
|
304
334
|
const asked = url.searchParams.get('room');
|
|
305
335
|
// A room code the relay cannot use is refused here too, never swapped for another room.
|
|
306
336
|
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' } });
|
|
307
337
|
const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
|
|
308
338
|
if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`, cat);
|
|
309
339
|
const room = asked ?? 'main';
|
|
310
|
-
|
|
340
|
+
// A watcher (section 16): a screen that never takes a seat, following the player the watch page asks for.
|
|
341
|
+
const policy = watchOf(meta);
|
|
342
|
+
const watching = !agent && url.searchParams.get('watch') === '1';
|
|
343
|
+
if (watching && policy === 'off') return new Response(`${meta?.name ?? game} cannot be watched; play it instead.\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
344
|
+
const follow = /^(?:auto|overview|\d{1,2})$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
|
|
345
|
+
const want = !agent && (watching || url.searchParams.get('want') === 'screen') ? 'screen' : 'play';
|
|
311
346
|
const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
|
|
312
347
|
const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
|
|
313
348
|
// The browser's room key (b: what a kick holds out) and the play page's ticket (t: a game that is not public) ride
|
|
@@ -316,12 +351,17 @@ async function gameDocument(request, env, url, game, meta, cat) {
|
|
|
316
351
|
const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
|
|
317
352
|
const cfg = {
|
|
318
353
|
v: 1,
|
|
319
|
-
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}`,
|
|
354
|
+
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}`,
|
|
320
355
|
room,
|
|
321
356
|
...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
|
|
322
|
-
...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
|
|
357
|
+
...(agent ? { name: agent.name } : url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
|
|
358
|
+
// An AI that runs the game itself (hands `self`): its hello says so; the relay knows it from its ticket anyway.
|
|
359
|
+
...(agent ? { agent: { hands: agent.hands, role: agent.role } } : {}),
|
|
360
|
+
// The play page's "Quiet AI": this browser hides AI speech (the helper's `net.hushed`).
|
|
361
|
+
...(url.searchParams.get('hush') === '1' ? { hush: true } : {}),
|
|
323
362
|
...(device ? { device } : {}),
|
|
324
363
|
want,
|
|
364
|
+
...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
|
|
325
365
|
debug: url.searchParams.get('debug') === '1',
|
|
326
366
|
...(meta?.movement ? { movement: meta.movement } : {}),
|
|
327
367
|
// game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
|
|
@@ -610,7 +650,7 @@ async function route(request, env, ctx) {
|
|
|
610
650
|
if (!meta) return fallback(request, env, url, getCat);
|
|
611
651
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
612
652
|
const cat = await getCat();
|
|
613
|
-
|
|
653
|
+
let sub = parts.slice(1).join('/');
|
|
614
654
|
// Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
|
|
615
655
|
const settings = await settingsOf(env, { fresh: true });
|
|
616
656
|
// The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
|
|
@@ -619,19 +659,48 @@ async function route(request, env, ctx) {
|
|
|
619
659
|
const max = seatsFor(meta, settings);
|
|
620
660
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
621
661
|
if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
|
|
622
|
-
|
|
662
|
+
|
|
663
|
+
// SERVERS (worker/servers.mjs, NETPLAY.md section 17): which server a request is for — its path (/s/<id>/), its
|
|
664
|
+
// room (s-<id>-<n>; pub-N and any named room are the public server's), or for a bare Play the signed-in player's
|
|
665
|
+
// home server, else public. Every door follows the game's launch state first, as it always did.
|
|
666
|
+
const servers = await serversOf(env, meta);
|
|
667
|
+
const serverById = (id) => servers.find((x) => x.id === id) ?? null;
|
|
668
|
+
const pathServer = /^s\/([a-z0-9][a-z0-9-]{1,19})(?:\/(play|tv|home))?\/?$/.exec(sub);
|
|
669
|
+
if (/^s(?:\/|$)/.test(sub) && !pathServer) return notFoundPage(`Nothing here in ${meta.name}.`, cat);
|
|
670
|
+
if (pathServer && (!serverById(pathServer[1]) || pathServer[1] === 'public' || serverById(pathServer[1]).state === 'archived')) return notFoundPage(`${meta.name} has no server called "${pathServer[1]}".`, cat);
|
|
671
|
+
if (pathServer && !pathServer[2] && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/s/${pathServer[1]}/${url.search}`, 301);
|
|
672
|
+
const policyFor = (srv, named = false) => policyOf(srv, { seats: max, named });
|
|
673
|
+
const humanSeats = (pol) => Math.max(1, max - (pol.aiSeats + pol.guides));
|
|
674
|
+
const askedRoom = url.searchParams.get('room');
|
|
675
|
+
if (pathServer) sub = pathServer[2] === 'play' ? 'play' : pathServer[2] === 'tv' ? 'tv' : pathServer[2] === 'home' ? 's-home' : 's-page';
|
|
676
|
+
|
|
677
|
+
const doorSubs = ['', 'live', 'credits', 'tv', 'play', 'watch', 'api/lobby', 'api/watch', 'servers', 'api/servers', 's-page', 's-home'];
|
|
678
|
+
const door = launch !== 'public' && doorSubs.includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
|
|
623
679
|
const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
|
|
680
|
+
/** A page's own door to a server: who this browser is (owner, invited, signed in) and whether it may come in. */
|
|
681
|
+
const pageDoor = async (srv, { watching = false } = {}) => {
|
|
682
|
+
const owner = door.owner || (launch === 'public' && await isOwner(request, env));
|
|
683
|
+
const acct = await accountSub(request, env);
|
|
684
|
+
const sPass = srv && srv.door === 'invite' && !owner ? await serverPassOf(request, env, game, srv.id, cookieValues) : null;
|
|
685
|
+
const list = [owner ? 'o' : null, door.sub, sPass ? `i-${sPass.invite}` : null, acct].filter(Boolean).flatMap(holders);
|
|
686
|
+
const access = srv ? await serverAccess(env, { game, server: srv, holders: list, watching }) : { ok: true };
|
|
687
|
+
return { ...access, owner, acct, holder: joinHolders(door.sub ?? (owner ? 'o' : null), sPass ? `i-${sPass.invite}` : null, acct) };
|
|
688
|
+
};
|
|
689
|
+
/** The public server is hidden (closed by its owner): Play shows the other servers instead, and named rooms are off. */
|
|
690
|
+
const publicHidden = () => (serverById('public') ?? PUBLIC_SERVER).state !== 'open';
|
|
691
|
+
|
|
624
692
|
if (sub === '') {
|
|
625
693
|
if (!door.ok) return shut();
|
|
626
694
|
await countVisit(request, env, ctx, game);
|
|
627
695
|
shareDaily(cat, url, ctx);
|
|
628
|
-
const { rooms, live } = await roomsOf(env, [meta]);
|
|
696
|
+
const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
629
697
|
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
630
|
-
|
|
698
|
+
const band = servers.some((x) => x.id !== 'public' && x.listed && x.state === 'open') ? await serverLive(env, meta, servers, url.origin, lobby) : null;
|
|
699
|
+
return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings), servers: band });
|
|
631
700
|
}
|
|
632
701
|
if (sub === 'live') {
|
|
633
702
|
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
634
|
-
const { rooms, live } = await roomsOf(env, [meta]);
|
|
703
|
+
const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
635
704
|
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
636
705
|
// The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
|
|
637
706
|
return json({ ok: true, game, counted: true, playing: live[game] ?? 0, waiting: 0, max, rooms, road: { seats: 'any', start: 'now', room: launch === 'public' ? 'public' : launch, big: true }, ...(week ? { week } : {}) }, 200, { 'cache-control': 'no-store' });
|
|
@@ -643,40 +712,138 @@ async function route(request, env, ctx) {
|
|
|
643
712
|
if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
|
|
644
713
|
return creditsPage(cat, meta, texts, { origin: url.origin });
|
|
645
714
|
}
|
|
715
|
+
if (sub === 'servers') {
|
|
716
|
+
if (!door.ok) return shut();
|
|
717
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/servers/`, 301);
|
|
718
|
+
await countVisit(request, env, ctx, `${game}/servers`);
|
|
719
|
+
return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: publicHidden() });
|
|
720
|
+
}
|
|
721
|
+
if (sub === 'api/servers') {
|
|
722
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
723
|
+
const list = (await serverLive(env, meta, servers, url.origin, lobby)).filter((x) => x.id === 'public' || (x.listed && x.state === 'open'));
|
|
724
|
+
return json({ ok: true, game, servers: list }, 200, { 'cache-control': 'public, max-age=15' });
|
|
725
|
+
}
|
|
726
|
+
if (sub === 's-page') {
|
|
727
|
+
if (!door.ok) return shut();
|
|
728
|
+
const srv = serverById(pathServer[1]);
|
|
729
|
+
const d = await pageDoor(srv);
|
|
730
|
+
const pid = d.acct?.startsWith('p-') ? d.acct.slice(2) : null;
|
|
731
|
+
const member = pid ? await memberOf(env, game, srv.id, pid) : null;
|
|
732
|
+
await countVisit(request, env, ctx, `${game}/s/${srv.id}`);
|
|
733
|
+
const [row] = await serverLive(env, meta, [srv], url.origin, lobby);
|
|
734
|
+
const { rooms } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
735
|
+
return serverPage(cat, meta, row, { origin: url.origin, rooms: rooms.filter((r) => r.server?.id === srv.id), door: d, member, signedIn: Boolean(pid) });
|
|
736
|
+
}
|
|
737
|
+
if (sub === 's-home') {
|
|
738
|
+
// Join this server, leave it, or make it home: the signed-in player's own choice (a same-origin JSON POST).
|
|
739
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin' }, 403);
|
|
740
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
741
|
+
const srv = serverById(pathServer[1]);
|
|
742
|
+
const acct = await accountSub(request, env);
|
|
743
|
+
if (!acct?.startsWith('p-')) return json({ ok: false, error: 'sign-in', message: 'Sign in to the studio first (your account page).' }, 401);
|
|
744
|
+
let body = {};
|
|
745
|
+
try { body = JSON.parse((await request.text()) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
|
|
746
|
+
const d = await pageDoor(srv);
|
|
747
|
+
if (body.join !== false && !d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
|
|
748
|
+
try { return json(await setMembership(env, game, srv.id, acct.slice(2), { join: body.join !== false, home: body.home === true })); } catch { return json({ ok: false, error: 'not-migrated', message: 'This studio\'s database has no servers yet (migration 0006).' }, 503); }
|
|
749
|
+
}
|
|
646
750
|
if (sub === 'tv' || sub === 'play') {
|
|
647
751
|
const screen = sub === 'tv' || url.searchParams.get('screen') === '1';
|
|
648
|
-
const asked =
|
|
752
|
+
const asked = askedRoom;
|
|
649
753
|
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
|
|
650
754
|
if (!door.ok) return shut();
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
755
|
+
// Which server: the path's, the room's, else (Play) the player's home server, else public.
|
|
756
|
+
let srv = pathServer ? serverById(pathServer[1]) : asked !== null ? serverById(roomServer(asked)) : null;
|
|
757
|
+
if (asked !== null && !srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
|
|
758
|
+
const named = asked !== null && !pooledRoom(asked);
|
|
759
|
+
if (!srv) {
|
|
760
|
+
const acct = await accountSub(request, env);
|
|
761
|
+
const home = acct?.startsWith('p-') ? await homeOf(env, game, acct.slice(2)) : null;
|
|
762
|
+
const h = home ? serverById(home) : null;
|
|
763
|
+
srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
|
|
764
|
+
}
|
|
765
|
+
const d = await pageDoor(srv);
|
|
766
|
+
// The owner hid the public server: Play shows the servers to pick from, and a named room is off (owner aside).
|
|
767
|
+
if (srv.id === 'public' && publicHidden() && !d.owner) {
|
|
768
|
+
if (named) return doorPage(cat, meta, srv, { why: 'closed' });
|
|
769
|
+
return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: true, pick: true });
|
|
770
|
+
}
|
|
771
|
+
if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, days: d.days, origin: url.origin, next: `${url.pathname}${url.search}`, code: url.searchParams.get('invite') ?? '' });
|
|
772
|
+
if (d.acct?.startsWith('p-') && srv.id !== 'public') ctx?.waitUntil?.(noteMember(env, game, srv.id, d.acct.slice(2)));
|
|
773
|
+
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
774
|
+
const pol = policyFor(srv, named);
|
|
775
|
+
const server = srv.id === 'public' && !pol.server ? null : { ...serverView(srv, { origin: url.origin, game }), mentor: Boolean(d.mentor) };
|
|
659
776
|
if (screen) {
|
|
660
777
|
await countVisit(request, env, ctx, game, 'screen');
|
|
661
778
|
// The big screen picks its room now, so the QR it shows puts every phone in that same room.
|
|
662
779
|
let room = asked;
|
|
663
780
|
if (!room) {
|
|
664
|
-
try { room = (await (await lobby().fetch(`https://lobby/join?max=${
|
|
781
|
+
try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
|
|
665
782
|
}
|
|
666
783
|
const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
667
784
|
let qr = null;
|
|
668
785
|
try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
669
|
-
return playPage(cat, meta, { screen: true, joinUrl, qr, room, ticket, owner, launch });
|
|
786
|
+
return playPage(cat, meta, { screen: true, joinUrl, qr, room, ticket, owner: d.owner, launch, server });
|
|
670
787
|
}
|
|
671
788
|
await countVisit(request, env, ctx, game, 'play');
|
|
672
789
|
shareDaily(cat, url, ctx);
|
|
673
|
-
return playPage(cat, meta, { ticket, owner, launch });
|
|
790
|
+
return playPage(cat, meta, { ticket, owner: d.owner, launch, server });
|
|
791
|
+
}
|
|
792
|
+
if (sub === 'watch') {
|
|
793
|
+
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
794
|
+
const asked = askedRoom;
|
|
795
|
+
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { watch: true });
|
|
796
|
+
if (!door.ok) return shut();
|
|
797
|
+
const policy = watchOf(meta);
|
|
798
|
+
if (policy === 'off') return noWatchPage(cat, meta);
|
|
799
|
+
// A server's door is its watch door too (an invite or an account); a beginner server is watched by anyone.
|
|
800
|
+
const srv = asked !== null ? serverById(roomServer(asked)) : serverById('public') ?? PUBLIC_SERVER;
|
|
801
|
+
if (!srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
|
|
802
|
+
const d = await pageDoor(srv, { watching: true });
|
|
803
|
+
if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, origin: url.origin, next: `${url.pathname}${url.search}`, watch: true });
|
|
804
|
+
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
805
|
+
await countVisit(request, env, ctx, game, 'watch');
|
|
806
|
+
shareDaily(cat, url, ctx);
|
|
807
|
+
return watchPage(cat, meta, { room: asked, ticket, policy });
|
|
808
|
+
}
|
|
809
|
+
if (sub === 'api/watch') {
|
|
810
|
+
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
811
|
+
// The busiest room now, to watch, of a server anyone may watch; nothing is reserved (a watcher takes no seat).
|
|
812
|
+
const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
|
|
813
|
+
const pools = servers.filter((x) => x.state === 'open' && x.door === 'open').map((x) => x.id).join(',');
|
|
814
|
+
let best = null;
|
|
815
|
+
try { best = await (await lobby().fetch(`https://lobby/busiest?pools=${encodeURIComponent(pools)}${not ? `¬=${encodeURIComponent(not)}` : ''}`)).json(); } catch { best = null; }
|
|
816
|
+
return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
|
|
674
817
|
}
|
|
675
818
|
if (sub === 'api/lobby') {
|
|
676
819
|
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
820
|
+
// ?server=<id>: a room of that server's pool (its door first); none: public. Strangers never meet across servers.
|
|
821
|
+
const sid = url.searchParams.get('server') ?? 'public';
|
|
822
|
+
const srv = SERVER_ID.test(sid) || sid === 'public' ? serverById(sid) : null;
|
|
823
|
+
if (!srv) return json({ ok: false, error: 'no-server', message: `${meta.name} has no server called ${String(sid).slice(0, 24)}.` }, 404);
|
|
824
|
+
if (srv.id === 'public' && publicHidden() && !(await pageDoor(srv)).owner) return json({ ok: false, error: 'closed', message: 'Pick a server to play on.' }, 403);
|
|
825
|
+
const d = await pageDoor(srv);
|
|
826
|
+
if (!d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
|
|
677
827
|
// A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
|
|
678
828
|
const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
|
|
679
|
-
|
|
829
|
+
const pol = policyFor(srv);
|
|
830
|
+
return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `¬=${encodeURIComponent(not)}` : ''}`, { method: 'POST' });
|
|
831
|
+
}
|
|
832
|
+
if (sub === 'api/agent') {
|
|
833
|
+
// An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
|
|
834
|
+
return sitRoute(request, env, url, {
|
|
835
|
+
meta, launch,
|
|
836
|
+
serverOf: (id) => serverById(id),
|
|
837
|
+
policyOf: (srv) => policyFor(srv),
|
|
838
|
+
ticketFor: (holder) => ticketFor(env, game, holder),
|
|
839
|
+
join: async (srv, pol) => {
|
|
840
|
+
try {
|
|
841
|
+
const reserve = pol.aiSeats + pol.guides;
|
|
842
|
+
const r = await (await lobby().fetch(`https://lobby/join?max=${max}&server=${srv.id}&agent=1&ai=${reserve}`, { method: 'POST' })).json();
|
|
843
|
+
return r.room ?? null;
|
|
844
|
+
} catch { return null; }
|
|
845
|
+
},
|
|
846
|
+
});
|
|
680
847
|
}
|
|
681
848
|
if (sub === '__net' || sub === '__watch') {
|
|
682
849
|
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
@@ -686,21 +853,65 @@ async function route(request, env, ctx) {
|
|
|
686
853
|
const who = url.searchParams.get('t') ? await ticketSub(env, game, url.searchParams.get('t')) : null;
|
|
687
854
|
if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('this game is not open to you', { status: 403 });
|
|
688
855
|
const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : '';
|
|
856
|
+
// A watcher's socket (section 16): what the game lets watchers see goes with every socket, so a helper that only
|
|
857
|
+
// says `watch` in its hello is held to it too; a game that cannot be watched refuses a watch door's socket here.
|
|
858
|
+
const policy = watchOf(meta);
|
|
859
|
+
const w = sub === '__net' && url.searchParams.get('w') === '1';
|
|
860
|
+
if (w && policy === 'off') return new Response('this game cannot be watched', { status: 403 });
|
|
861
|
+
// The room's server (section 17): its door for a person, and the policy the room applies. An AI's ticket
|
|
862
|
+
// (a-<pass>) is checked against its pass and the server: a humans-only server refuses it here and again at the relay.
|
|
863
|
+
const srv = serverById(roomServer(room));
|
|
864
|
+
if (!srv) return new Response('this server does not exist', { status: 404 });
|
|
865
|
+
const parts = holders(who);
|
|
866
|
+
const agentPart = parts.find((x) => x.startsWith('a-'));
|
|
867
|
+
const pol = policyFor(srv, !pooledRoom(room));
|
|
868
|
+
let ag = null;
|
|
869
|
+
if (agentPart && sub === '__net') {
|
|
870
|
+
const pass = await passById(env, agentPart.slice(2));
|
|
871
|
+
const why = passRefusal(pass, { game, server: srv.id, policy: pol, launch });
|
|
872
|
+
if (why) return new Response(why.error === 'agents-off' ? 'This server is for humans only\n' : `${why.message}\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
873
|
+
ag = { ...agentFacts(pass), name: aiName(pass.label) };
|
|
874
|
+
} else if (srv.state !== 'open' || srv.door !== 'open' || (srv.policy === 'beginner' && !w && sub === '__net')) {
|
|
875
|
+
const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
|
|
876
|
+
if (!access.ok) return new Response('this server is not open to you', { status: 403 });
|
|
877
|
+
}
|
|
689
878
|
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
690
|
-
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}`;
|
|
879
|
+
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}${ag ? `&ag=${encodeFacts(ag)}` : ''}`;
|
|
691
880
|
return stub.fetch(new Request(target, request));
|
|
692
881
|
}
|
|
693
882
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
694
883
|
if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
|
|
695
884
|
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
|
|
696
|
-
|
|
697
|
-
|
|
885
|
+
const who = await ticketSub(env, game, url.searchParams.get('t'));
|
|
886
|
+
if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
887
|
+
// An agent's frame (hands `self`): the game plays as that AI.
|
|
888
|
+
const agentPart = holders(who).find((x) => x.startsWith('a-'));
|
|
889
|
+
const pass = agentPart ? await passById(env, agentPart.slice(2)) : null;
|
|
890
|
+
return gameDocument(request, env, url, game, meta, cat, { agent: pass?.live ? { name: aiName(pass.label), hands: pass.hands, role: pass.role } : null });
|
|
698
891
|
}
|
|
699
892
|
if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
|
|
700
893
|
}
|
|
701
894
|
return fallback(request, env, url, getCat);
|
|
702
895
|
}
|
|
703
896
|
|
|
897
|
+
/**
|
|
898
|
+
* A game's servers with what is happening on them now (the Lobby's rooms by server, one request) and how many
|
|
899
|
+
* belong to each: the landing's Servers band, /<game>/servers/, a server's page and /<game>/api/servers.
|
|
900
|
+
*/
|
|
901
|
+
async function serverLive(env, meta, servers, origin, lobby) {
|
|
902
|
+
let list = [];
|
|
903
|
+
try { list = (await (await lobby().fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
|
|
904
|
+
const by = {};
|
|
905
|
+
for (const r of list) {
|
|
906
|
+
if (!(r.players > 0)) continue;
|
|
907
|
+
const sid = r.server ?? roomServer(r.name);
|
|
908
|
+
const b = (by[sid] ??= { playing: 0, ai: 0, rooms: 0 });
|
|
909
|
+
b.playing += r.players; b.ai += Number(r.ai ?? r.agents ?? 0) || 0; b.rooms += 1;
|
|
910
|
+
}
|
|
911
|
+
const members = env.DB ? await memberCounts(env, meta.id) : {};
|
|
912
|
+
return servers.map((s) => ({ ...serverView(s, { origin, game: meta.id }), playing: by[s.id]?.playing ?? 0, ai: by[s.id]?.ai ?? 0, live: by[s.id]?.rooms ?? 0, members: members[s.id] ?? 0 }));
|
|
913
|
+
}
|
|
914
|
+
|
|
704
915
|
/** A game's "played this week" on its landing, when the studio shares its numbers: Play presses and rounds with people. */
|
|
705
916
|
async function weekOf(env, game) {
|
|
706
917
|
const w = await playedThisWeek(env, game);
|
|
@@ -786,6 +997,10 @@ export class Table {
|
|
|
786
997
|
}
|
|
787
998
|
// The owner's room size: a room already open takes it from the next visitor on.
|
|
788
999
|
if (room.seatCap !== max) room.setSeats(max, perAddress(max));
|
|
1000
|
+
// The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
|
|
1001
|
+
const pol = decodeFacts(url.searchParams.get('pol'));
|
|
1002
|
+
if (pol) room.setPolicy(pol);
|
|
1003
|
+
const agent = decodeFacts(url.searchParams.get('ag'));
|
|
789
1004
|
const [client, server] = Object.values(new WebSocketPair());
|
|
790
1005
|
server.accept();
|
|
791
1006
|
const via = url.searchParams.get('via');
|
|
@@ -795,6 +1010,11 @@ export class Table {
|
|
|
795
1010
|
browser: url.searchParams.get('b') || null,
|
|
796
1011
|
via: via || null,
|
|
797
1012
|
player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
|
|
1013
|
+
// A socket opened through the game's watch door, and what the game lets watchers see (section 16).
|
|
1014
|
+
watch: url.searchParams.get('w') === '1',
|
|
1015
|
+
// An AI with a pass the Worker verified (section 17): its pass, role, hands and name. Only the Worker sets it.
|
|
1016
|
+
...(agent && typeof agent === 'object' && url.pathname === '/__net' ? { agent } : {}),
|
|
1017
|
+
watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
|
|
798
1018
|
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
799
1019
|
qa: isQa(request),
|
|
800
1020
|
send: (text) => { try { server.send(text); } catch { /* closed */ } },
|
|
@@ -803,6 +1023,8 @@ export class Table {
|
|
|
803
1023
|
};
|
|
804
1024
|
if (url.pathname === '/__watch') {
|
|
805
1025
|
const w = room.watch(conn);
|
|
1026
|
+
// The play page's vote card speaks on this socket (section 17); nothing else it says is read.
|
|
1027
|
+
server.addEventListener('message', (e) => { if (typeof e.data === 'string') w.onMessage(e.data); });
|
|
806
1028
|
server.addEventListener('close', () => w.onClose());
|
|
807
1029
|
server.addEventListener('error', () => w.onClose());
|
|
808
1030
|
} else {
|
|
@@ -820,8 +1042,18 @@ export class Table {
|
|
|
820
1042
|
this.ctx.waitUntil(count(this.env, null, { metric: 'room', subject: this.game ?? '', source: /^pub-\d+$/.test(this.code ?? '') ? 'public' : 'named' }));
|
|
821
1043
|
}
|
|
822
1044
|
});
|
|
823
|
-
|
|
824
|
-
|
|
1045
|
+
// An AI's time in a seat, counted once as it leaves (stats `agent-minutes`; never for house QA).
|
|
1046
|
+
const left = () => {
|
|
1047
|
+
const c = room.clients.get(h.id);
|
|
1048
|
+
if (c?.agent && c.helloed && c.seat !== null && !conn.qa) {
|
|
1049
|
+
const minutes = Math.round((Date.now() - c.joinedAt) / 60_000);
|
|
1050
|
+
if (minutes > 0) this.ctx.waitUntil(count(this.env, null, { metric: 'agent-minutes', subject: this.game ?? '', source: c.agent.role, n: minutes }).catch(() => {}));
|
|
1051
|
+
}
|
|
1052
|
+
h.onClose();
|
|
1053
|
+
this.report();
|
|
1054
|
+
};
|
|
1055
|
+
server.addEventListener('close', left);
|
|
1056
|
+
server.addEventListener('error', left);
|
|
825
1057
|
}
|
|
826
1058
|
this.start();
|
|
827
1059
|
return new Response(null, { status: 101, webSocket: client });
|
|
@@ -874,17 +1106,23 @@ export class Table {
|
|
|
874
1106
|
report() {
|
|
875
1107
|
const room = this.room;
|
|
876
1108
|
if (!room || !this.game) return;
|
|
877
|
-
const
|
|
1109
|
+
const counts = room.facts().counts;
|
|
1110
|
+
// Players are people (an agent never counts as one, DESIGN D9); agents and AI bodies are reported apart.
|
|
1111
|
+
const players = counts.players;
|
|
1112
|
+
const agents = counts.agents ?? 0;
|
|
1113
|
+
const ai = counts.ai ?? 0;
|
|
878
1114
|
// People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
|
|
879
1115
|
let people = 0;
|
|
880
|
-
for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.conn?.qa) people += 1;
|
|
1116
|
+
for (const c of room.clients.values()) if (c.helloed && c.seat !== null && !c.agent && !c.conn?.qa) people += 1;
|
|
881
1117
|
// On a change, and once a minute while anybody is here (so the Lobby's "playing now" forgets a room that died).
|
|
882
|
-
if (players === this.lastReport && people === this.lastPeople && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
|
|
1118
|
+
if (players === this.lastReport && people === this.lastPeople && agents === this.lastAgents && ai === this.lastAi && (!players || Date.now() - (this.lastReportAt ?? 0) < 60_000)) return;
|
|
883
1119
|
this.lastReport = players;
|
|
884
1120
|
this.lastPeople = people;
|
|
1121
|
+
this.lastAgents = agents;
|
|
1122
|
+
this.lastAi = ai;
|
|
885
1123
|
this.lastReportAt = Date.now();
|
|
886
1124
|
const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
|
|
887
|
-
this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people }) }).catch(() => {}));
|
|
1125
|
+
this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people, agents, ai }) }).catch(() => {}));
|
|
888
1126
|
}
|
|
889
1127
|
|
|
890
1128
|
/** A round the host called over becomes one D1 row (once per round number). */
|
|
@@ -894,9 +1132,10 @@ export class Table {
|
|
|
894
1132
|
this.recorded = r.n;
|
|
895
1133
|
this.ctx.storage.put('recorded', r.n).catch(() => {});
|
|
896
1134
|
const results = Array.isArray(r.results) ? r.results.slice(0, SEAT_MAX * 2) : [];
|
|
897
|
-
|
|
1135
|
+
// An AI's row is never a person's (the relay labelled it `agent`): rounds count agents as AI.
|
|
1136
|
+
const humans = results.filter((row) => row && !row.bot && !row.agent).length;
|
|
898
1137
|
// A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
|
|
899
|
-
const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null).every((c) => c.conn?.qa);
|
|
1138
|
+
const qaOnly = [...this.room.clients.values()].filter((c) => c.helloed && c.seat !== null && !c.agent).every((c) => c.conn?.qa);
|
|
900
1139
|
const db = this.env.DB;
|
|
901
1140
|
const insert = db.prepare('INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
902
1141
|
.bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
|
|
@@ -907,17 +1146,22 @@ export class Table {
|
|
|
907
1146
|
}
|
|
908
1147
|
|
|
909
1148
|
/**
|
|
910
|
-
* LOBBY — one per game: which
|
|
911
|
-
*
|
|
912
|
-
* there who have not connected yet, so two people pressing Play together land
|
|
1149
|
+
* LOBBY — one per game: which room a new visitor joins. Strangers meet: the fullest room that still has a seat
|
|
1150
|
+
* wins, counting visitors it just sent there who have not connected yet, so two people pressing Play together land
|
|
913
1151
|
* in the same room.
|
|
1152
|
+
*
|
|
1153
|
+
* SERVERS (section 17): one pool per server (`/join?server=`), never matched across: `public` is the game's pub-N
|
|
1154
|
+
* rooms, a server's are s-<id>-<n>. A server whose `rooms_max` rooms are all full sends the visitor to its fullest
|
|
1155
|
+
* room, where it waits as a spectator and is seated when a seat frees (`full: true`). An AI (`agent=1`) is only ever
|
|
1156
|
+
* sent to a room with people in it; it never opens one. Players are people: agents are reported apart.
|
|
914
1157
|
*/
|
|
915
1158
|
export class Lobby {
|
|
916
1159
|
constructor(ctx, env) {
|
|
917
1160
|
this.ctx = ctx;
|
|
918
1161
|
this.env = env;
|
|
919
1162
|
this.rooms = new Map();
|
|
920
|
-
|
|
1163
|
+
/** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
|
|
1164
|
+
this.next = { public: 1 };
|
|
921
1165
|
/** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
|
|
922
1166
|
this.live = new Map();
|
|
923
1167
|
this.peak = { day: null, players: 0, room: 0 };
|
|
@@ -927,8 +1171,8 @@ export class Lobby {
|
|
|
927
1171
|
ctx.blockConcurrencyWhile(async () => {
|
|
928
1172
|
const saved = await ctx.storage.get('lobby');
|
|
929
1173
|
if (saved) {
|
|
930
|
-
this.next = saved.next
|
|
931
|
-
for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] });
|
|
1174
|
+
this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
|
|
1175
|
+
for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, pending: [], agentPending: [] });
|
|
932
1176
|
for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
|
|
933
1177
|
}
|
|
934
1178
|
});
|
|
@@ -954,11 +1198,12 @@ export class Lobby {
|
|
|
954
1198
|
if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
|
|
955
1199
|
}
|
|
956
1200
|
|
|
957
|
-
save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })), closed: [...this.closed] }).catch(() => {}); }
|
|
1201
|
+
save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at }) => ({ name, players, agents, ai, server, at })), closed: [...this.closed] }).catch(() => {}); }
|
|
958
1202
|
|
|
959
1203
|
prune(now) {
|
|
960
1204
|
for (const [name, r] of this.rooms) {
|
|
961
1205
|
r.pending = r.pending.filter((t) => now - t < 20_000);
|
|
1206
|
+
r.agentPending = (r.agentPending ?? []).filter((t) => now - t < 20_000);
|
|
962
1207
|
if (r.players === 0 && !r.pending.length && now - r.at > 120_000) this.rooms.delete(name);
|
|
963
1208
|
}
|
|
964
1209
|
}
|
|
@@ -969,26 +1214,49 @@ export class Lobby {
|
|
|
969
1214
|
this.prune(now);
|
|
970
1215
|
if (url.pathname === '/join') {
|
|
971
1216
|
const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
|
|
1217
|
+
const sid = url.searchParams.get('server') ?? 'public';
|
|
1218
|
+
const server = sid === 'public' || SERVER_ID.test(sid) ? sid : 'public';
|
|
1219
|
+
const roomsMax = Math.max(1, Math.min(16, Math.floor(Number(url.searchParams.get('rooms'))) || 16));
|
|
972
1220
|
// A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
|
|
973
1221
|
const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
|
|
974
1222
|
for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
|
|
1223
|
+
const pool = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server && !not.has(r.name) && !this.closed.has(r.name));
|
|
1224
|
+
if (url.searchParams.get('agent') === '1') {
|
|
1225
|
+
// An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
|
|
1226
|
+
const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
|
|
1227
|
+
let best = null;
|
|
1228
|
+
for (const r of pool) {
|
|
1229
|
+
const agents = (r.agents ?? 0) + (r.agentPending ?? []).length;
|
|
1230
|
+
if (!(r.players > 0) || (reserve ? agents >= reserve : r.players + r.pending.length + agents >= max)) continue;
|
|
1231
|
+
if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
|
|
1232
|
+
}
|
|
1233
|
+
if (!best) return json({ room: null, players: 0, max });
|
|
1234
|
+
(best.agentPending ??= []).push(now);
|
|
1235
|
+
return json({ room: best.name, players: best.players, max });
|
|
1236
|
+
}
|
|
975
1237
|
let best = null;
|
|
976
|
-
for (const r of
|
|
977
|
-
if (not.has(r.name) || this.closed.has(r.name)) continue;
|
|
1238
|
+
for (const r of pool) {
|
|
978
1239
|
const fill = r.players + r.pending.length;
|
|
979
1240
|
if (fill >= max) continue;
|
|
980
1241
|
if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
|
|
981
1242
|
}
|
|
982
1243
|
let room = best?.r;
|
|
1244
|
+
let full = false;
|
|
1245
|
+
if (!room && server !== 'public' && pool.length >= roomsMax) {
|
|
1246
|
+
// Every room this server may have is full: the fullest one, where the visitor waits for a seat.
|
|
1247
|
+
room = pool.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), pool[0]);
|
|
1248
|
+
full = true;
|
|
1249
|
+
}
|
|
983
1250
|
if (!room) {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1251
|
+
let n = this.next[server] ?? 1;
|
|
1252
|
+
while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
|
|
1253
|
+
room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, pending: [], agentPending: [] };
|
|
1254
|
+
this.next[server] = n + 1;
|
|
987
1255
|
this.rooms.set(room.name, room);
|
|
988
1256
|
}
|
|
989
1257
|
room.pending.push(now);
|
|
990
1258
|
this.save();
|
|
991
|
-
return json({ room: room.name, players: room.players, max });
|
|
1259
|
+
return json({ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) });
|
|
992
1260
|
}
|
|
993
1261
|
if (url.pathname === '/report' && request.method === 'POST') {
|
|
994
1262
|
const body = await request.json().catch(() => ({}));
|
|
@@ -1001,30 +1269,47 @@ export class Lobby {
|
|
|
1001
1269
|
return json({ ok: true, closed: body.closed > now });
|
|
1002
1270
|
}
|
|
1003
1271
|
const seated = Math.max(0, Math.floor(Number(body.players) || 0));
|
|
1004
|
-
|
|
1272
|
+
const agents = Math.max(0, Math.floor(Number(body.agents) || 0));
|
|
1273
|
+
const ai = Math.max(agents, Math.floor(Number(body.ai) || 0));
|
|
1274
|
+
if (seated > 0) this.seen.set(body.room, { room: body.room, players: seated, people: Math.max(0, Math.floor(Number(body.people ?? seated) || 0)), agents, ai, at: now });
|
|
1005
1275
|
else this.seen.delete(body.room);
|
|
1006
1276
|
if (this.closed.has(body.room)) return json({ ok: true, closed: true });
|
|
1007
1277
|
this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
|
|
1008
|
-
// Only
|
|
1009
|
-
if (!this.rooms.has(body.room) &&
|
|
1010
|
-
const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
|
|
1011
|
-
const grew = Math.max(0,
|
|
1012
|
-
const changed = !this.rooms.has(r.name) || r.players !==
|
|
1278
|
+
// Only rooms of a pool are matched with strangers (pub-N, s-<id>-<n>); a named room (?room=) stays private.
|
|
1279
|
+
if (!this.rooms.has(body.room) && !pooledRoom(body.room)) return json({ ok: true, private: true });
|
|
1280
|
+
const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, pending: [], agentPending: [] };
|
|
1281
|
+
const grew = Math.max(0, seated - r.players);
|
|
1282
|
+
const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai;
|
|
1013
1283
|
r.pending.splice(0, grew);
|
|
1014
|
-
r.
|
|
1284
|
+
if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
|
|
1285
|
+
r.players = seated;
|
|
1286
|
+
r.agents = agents;
|
|
1287
|
+
r.ai = ai;
|
|
1015
1288
|
r.at = now;
|
|
1016
1289
|
this.rooms.set(r.name, r);
|
|
1017
1290
|
if (changed) this.save(); // the minute's "still here" report writes nothing
|
|
1018
1291
|
return json({ ok: true });
|
|
1019
1292
|
}
|
|
1020
|
-
if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
|
|
1293
|
+
if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at }) => ({ name, players, agents: agents ?? 0, ai: ai ?? 0, server: server ?? roomServer(name), at })) });
|
|
1294
|
+
if (url.pathname === '/busiest') {
|
|
1295
|
+
// The pooled room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
|
|
1296
|
+
// no seat. A closed room, one named in `not`, or one of a pool not in `pools` (a server with a door) is never it.
|
|
1297
|
+
const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
|
|
1298
|
+
const pools = url.searchParams.has('pools') ? new Set(String(url.searchParams.get('pools')).split(',').filter(Boolean)) : null;
|
|
1299
|
+
let best = null;
|
|
1300
|
+
for (const r of this.rooms.values()) {
|
|
1301
|
+
if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
|
|
1302
|
+
if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
|
|
1303
|
+
}
|
|
1304
|
+
return json({ room: best?.name ?? null, players: best?.players ?? 0 });
|
|
1305
|
+
}
|
|
1021
1306
|
if (url.pathname === '/office') {
|
|
1022
1307
|
// The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
|
|
1023
1308
|
for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
|
|
1024
1309
|
const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
|
|
1025
|
-
for (const r of this.rooms.values()) if (r.players > 0 && !rooms.has(r.name)) rooms.set(r.name, { room: r.name, players: r.players, people: r.players, at: r.at });
|
|
1310
|
+
for (const r of this.rooms.values()) if (r.players > 0 && !rooms.has(r.name)) rooms.set(r.name, { room: r.name, players: r.players, people: r.players, agents: r.agents ?? 0, ai: r.ai ?? 0, at: r.at });
|
|
1026
1311
|
for (const [name, until] of this.closed) if (until > now && !rooms.has(name)) rooms.set(name, { room: name, players: 0, people: 0, at: now, closedUntil: until });
|
|
1027
|
-
return json({ rooms: [...rooms.values()].sort((a, b) => b.players - a.players).slice(0, 100) });
|
|
1312
|
+
return json({ rooms: [...rooms.values()].map((r) => ({ ...r, server: roomServer(r.room) })).sort((a, b) => b.players - a.players).slice(0, 100) });
|
|
1028
1313
|
}
|
|
1029
1314
|
if (url.pathname === '/now') {
|
|
1030
1315
|
let players = 0;
|