@homie-rocks/studio 0.15.0 → 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 +34 -4
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +45 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +41 -2
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +28 -0
- package/netplay/NETPLAY.md +207 -17
- package/netplay/netplay.ts +360 -30
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/index.ts +5 -2
- package/port/room.ts +33 -10
- package/port/skill.ts +28 -0
- package/site/SITE.md +43 -0
- package/starters/ember-vale/src/main.ts +21 -7
- package/starters/gem-rush/src/main.ts +84 -19
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +307 -74
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +206 -15
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +491 -44
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +138 -7
- package/worker/stats.mjs +6 -1
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -23,8 +23,14 @@
|
|
|
23
23
|
* /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
|
|
24
24
|
* /<game>/__net?room= the room's netplay socket (Table Durable Object)
|
|
25
25
|
* /<game>/__watch?room= the room's facts, for the shell
|
|
26
|
-
* /<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
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)
|
|
28
34
|
* /api/games, /api/rooms the catalogue plus live counts; every public room playing now (/api/rooms may be
|
|
29
35
|
* cached for 15 s)
|
|
30
36
|
* /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
|
|
@@ -56,24 +62,30 @@
|
|
|
56
62
|
* after `homie-studio storage add` (R2 needs a payment method on the account),
|
|
57
63
|
* MEDIA. Everything else runs on Cloudflare's free Workers plan.
|
|
58
64
|
*
|
|
59
|
-
* 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
|
|
60
66
|
* unchanged inside the Table, so a game that plays in `homie-studio dev`
|
|
61
67
|
* plays the same way here.
|
|
62
68
|
*/
|
|
63
69
|
import { NetRoom, WATCH_POLICIES } from './room.mjs';
|
|
64
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';
|
|
65
77
|
import { qrSvg } from './qr.mjs';
|
|
66
78
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
67
79
|
import {
|
|
68
80
|
SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
|
|
69
81
|
notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
|
|
70
82
|
} from './site.mjs';
|
|
71
|
-
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';
|
|
72
84
|
import { ownerRoutes } from './stats-page.mjs';
|
|
73
85
|
import { playerRoutes, players as playerAccounts } from './players.mjs';
|
|
74
86
|
import {
|
|
75
|
-
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, remixOf, seatsFor, settingsOf, ticketAllows,
|
|
76
|
-
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,
|
|
77
89
|
} from './office.mjs';
|
|
78
90
|
|
|
79
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
|
|
@@ -205,8 +217,12 @@ function named(cat, env) {
|
|
|
205
217
|
return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
|
|
206
218
|
}
|
|
207
219
|
|
|
208
|
-
/**
|
|
209
|
-
|
|
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 } = {}) {
|
|
210
226
|
const rooms = [];
|
|
211
227
|
const live = {};
|
|
212
228
|
await Promise.all(games.map(async (g) => {
|
|
@@ -214,7 +230,13 @@ async function roomsOf(env, games) {
|
|
|
214
230
|
try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
|
|
215
231
|
const busy = list.filter((r) => (r.players || 0) > 0);
|
|
216
232
|
live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
|
|
217
|
-
|
|
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
|
+
}
|
|
218
240
|
}));
|
|
219
241
|
rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
|
|
220
242
|
return { rooms, live };
|
|
@@ -304,8 +326,11 @@ function notAHomie(request) {
|
|
|
304
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);
|
|
305
327
|
}
|
|
306
328
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
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 } = {}) {
|
|
309
334
|
const asked = url.searchParams.get('room');
|
|
310
335
|
// A room code the relay cannot use is refused here too, never swapped for another room.
|
|
311
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' } });
|
|
@@ -314,10 +339,10 @@ async function gameDocument(request, env, url, game, meta, cat) {
|
|
|
314
339
|
const room = asked ?? 'main';
|
|
315
340
|
// A watcher (section 16): a screen that never takes a seat, following the player the watch page asks for.
|
|
316
341
|
const policy = watchOf(meta);
|
|
317
|
-
const watching = url.searchParams.get('watch') === '1';
|
|
342
|
+
const watching = !agent && url.searchParams.get('watch') === '1';
|
|
318
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' } });
|
|
319
344
|
const follow = /^(?:auto|overview|\d{1,2})$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
|
|
320
|
-
const want = watching || url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
|
|
345
|
+
const want = !agent && (watching || url.searchParams.get('want') === 'screen') ? 'screen' : 'play';
|
|
321
346
|
const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
|
|
322
347
|
const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
|
|
323
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
|
|
@@ -329,7 +354,11 @@ async function gameDocument(request, env, url, game, meta, cat) {
|
|
|
329
354
|
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}`,
|
|
330
355
|
room,
|
|
331
356
|
...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
|
|
332
|
-
...(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 } : {}),
|
|
333
362
|
...(device ? { device } : {}),
|
|
334
363
|
want,
|
|
335
364
|
...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
|
|
@@ -621,7 +650,7 @@ async function route(request, env, ctx) {
|
|
|
621
650
|
if (!meta) return fallback(request, env, url, getCat);
|
|
622
651
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
623
652
|
const cat = await getCat();
|
|
624
|
-
|
|
653
|
+
let sub = parts.slice(1).join('/');
|
|
625
654
|
// Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
|
|
626
655
|
const settings = await settingsOf(env, { fresh: true });
|
|
627
656
|
// The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
|
|
@@ -630,19 +659,48 @@ async function route(request, env, ctx) {
|
|
|
630
659
|
const max = seatsFor(meta, settings);
|
|
631
660
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
632
661
|
if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
|
|
633
|
-
|
|
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 };
|
|
634
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
|
+
|
|
635
692
|
if (sub === '') {
|
|
636
693
|
if (!door.ok) return shut();
|
|
637
694
|
await countVisit(request, env, ctx, game);
|
|
638
695
|
shareDaily(cat, url, ctx);
|
|
639
|
-
const { rooms, live } = await roomsOf(env, [meta]);
|
|
696
|
+
const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
640
697
|
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
641
|
-
|
|
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 });
|
|
642
700
|
}
|
|
643
701
|
if (sub === 'live') {
|
|
644
702
|
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
645
|
-
const { rooms, live } = await roomsOf(env, [meta]);
|
|
703
|
+
const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
646
704
|
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
647
705
|
// The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
|
|
648
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' });
|
|
@@ -654,62 +712,138 @@ async function route(request, env, ctx) {
|
|
|
654
712
|
if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
|
|
655
713
|
return creditsPage(cat, meta, texts, { origin: url.origin });
|
|
656
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
|
+
}
|
|
657
750
|
if (sub === 'tv' || sub === 'play') {
|
|
658
751
|
const screen = sub === 'tv' || url.searchParams.get('screen') === '1';
|
|
659
|
-
const asked =
|
|
752
|
+
const asked = askedRoom;
|
|
660
753
|
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
|
|
661
754
|
if (!door.ok) return shut();
|
|
662
|
-
//
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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) };
|
|
670
776
|
if (screen) {
|
|
671
777
|
await countVisit(request, env, ctx, game, 'screen');
|
|
672
778
|
// The big screen picks its room now, so the QR it shows puts every phone in that same room.
|
|
673
779
|
let room = asked;
|
|
674
780
|
if (!room) {
|
|
675
|
-
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; }
|
|
676
782
|
}
|
|
677
783
|
const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
678
784
|
let qr = null;
|
|
679
785
|
try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
680
|
-
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 });
|
|
681
787
|
}
|
|
682
788
|
await countVisit(request, env, ctx, game, 'play');
|
|
683
789
|
shareDaily(cat, url, ctx);
|
|
684
|
-
return playPage(cat, meta, { ticket, owner, launch });
|
|
790
|
+
return playPage(cat, meta, { ticket, owner: d.owner, launch, server });
|
|
685
791
|
}
|
|
686
792
|
if (sub === 'watch') {
|
|
687
793
|
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
688
|
-
const asked =
|
|
794
|
+
const asked = askedRoom;
|
|
689
795
|
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { watch: true });
|
|
690
796
|
if (!door.ok) return shut();
|
|
691
797
|
const policy = watchOf(meta);
|
|
692
798
|
if (policy === 'off') return noWatchPage(cat, meta);
|
|
693
|
-
|
|
694
|
-
const
|
|
695
|
-
|
|
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;
|
|
696
805
|
await countVisit(request, env, ctx, game, 'watch');
|
|
697
806
|
shareDaily(cat, url, ctx);
|
|
698
807
|
return watchPage(cat, meta, { room: asked, ticket, policy });
|
|
699
808
|
}
|
|
700
809
|
if (sub === 'api/watch') {
|
|
701
810
|
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
702
|
-
// The busiest
|
|
811
|
+
// The busiest room now, to watch, of a server anyone may watch; nothing is reserved (a watcher takes no seat).
|
|
703
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(',');
|
|
704
814
|
let best = null;
|
|
705
|
-
try { best = await (await lobby().fetch(`https://lobby/busiest${not ?
|
|
815
|
+
try { best = await (await lobby().fetch(`https://lobby/busiest?pools=${encodeURIComponent(pools)}${not ? `¬=${encodeURIComponent(not)}` : ''}`)).json(); } catch { best = null; }
|
|
706
816
|
return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
|
|
707
817
|
}
|
|
708
818
|
if (sub === 'api/lobby') {
|
|
709
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);
|
|
710
827
|
// A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
|
|
711
828
|
const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
|
|
712
|
-
|
|
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
|
+
});
|
|
713
847
|
}
|
|
714
848
|
if (sub === '__net' || sub === '__watch') {
|
|
715
849
|
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
@@ -724,21 +858,60 @@ async function route(request, env, ctx) {
|
|
|
724
858
|
const policy = watchOf(meta);
|
|
725
859
|
const w = sub === '__net' && url.searchParams.get('w') === '1';
|
|
726
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
|
+
}
|
|
727
878
|
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
728
|
-
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' : ''}` : ''}`;
|
|
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)}` : ''}`;
|
|
729
880
|
return stub.fetch(new Request(target, request));
|
|
730
881
|
}
|
|
731
882
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
732
883
|
if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
|
|
733
884
|
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
|
|
734
|
-
|
|
735
|
-
|
|
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 });
|
|
736
891
|
}
|
|
737
892
|
if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
|
|
738
893
|
}
|
|
739
894
|
return fallback(request, env, url, getCat);
|
|
740
895
|
}
|
|
741
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
|
+
|
|
742
915
|
/** A game's "played this week" on its landing, when the studio shares its numbers: Play presses and rounds with people. */
|
|
743
916
|
async function weekOf(env, game) {
|
|
744
917
|
const w = await playedThisWeek(env, game);
|
|
@@ -824,6 +997,10 @@ export class Table {
|
|
|
824
997
|
}
|
|
825
998
|
// The owner's room size: a room already open takes it from the next visitor on.
|
|
826
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'));
|
|
827
1004
|
const [client, server] = Object.values(new WebSocketPair());
|
|
828
1005
|
server.accept();
|
|
829
1006
|
const via = url.searchParams.get('via');
|
|
@@ -835,6 +1012,8 @@ export class Table {
|
|
|
835
1012
|
player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
|
|
836
1013
|
// A socket opened through the game's watch door, and what the game lets watchers see (section 16).
|
|
837
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 } : {}),
|
|
838
1017
|
watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
|
|
839
1018
|
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
840
1019
|
qa: isQa(request),
|
|
@@ -844,6 +1023,8 @@ export class Table {
|
|
|
844
1023
|
};
|
|
845
1024
|
if (url.pathname === '/__watch') {
|
|
846
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); });
|
|
847
1028
|
server.addEventListener('close', () => w.onClose());
|
|
848
1029
|
server.addEventListener('error', () => w.onClose());
|
|
849
1030
|
} else {
|
|
@@ -861,8 +1042,18 @@ export class Table {
|
|
|
861
1042
|
this.ctx.waitUntil(count(this.env, null, { metric: 'room', subject: this.game ?? '', source: /^pub-\d+$/.test(this.code ?? '') ? 'public' : 'named' }));
|
|
862
1043
|
}
|
|
863
1044
|
});
|
|
864
|
-
|
|
865
|
-
|
|
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);
|
|
866
1057
|
}
|
|
867
1058
|
this.start();
|
|
868
1059
|
return new Response(null, { status: 101, webSocket: client });
|
|
@@ -915,17 +1106,23 @@ export class Table {
|
|
|
915
1106
|
report() {
|
|
916
1107
|
const room = this.room;
|
|
917
1108
|
if (!room || !this.game) return;
|
|
918
|
-
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;
|
|
919
1114
|
// People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
|
|
920
1115
|
let people = 0;
|
|
921
|
-
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;
|
|
922
1117
|
// On a change, and once a minute while anybody is here (so the Lobby's "playing now" forgets a room that died).
|
|
923
|
-
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;
|
|
924
1119
|
this.lastReport = players;
|
|
925
1120
|
this.lastPeople = people;
|
|
1121
|
+
this.lastAgents = agents;
|
|
1122
|
+
this.lastAi = ai;
|
|
926
1123
|
this.lastReportAt = Date.now();
|
|
927
1124
|
const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
|
|
928
|
-
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(() => {}));
|
|
929
1126
|
}
|
|
930
1127
|
|
|
931
1128
|
/** A round the host called over becomes one D1 row (once per round number). */
|
|
@@ -935,9 +1132,10 @@ export class Table {
|
|
|
935
1132
|
this.recorded = r.n;
|
|
936
1133
|
this.ctx.storage.put('recorded', r.n).catch(() => {});
|
|
937
1134
|
const results = Array.isArray(r.results) ? r.results.slice(0, SEAT_MAX * 2) : [];
|
|
938
|
-
|
|
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;
|
|
939
1137
|
// A round played only by house QA (every seated socket is marked) is kept in `rounds`, not in the stats.
|
|
940
|
-
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);
|
|
941
1139
|
const db = this.env.DB;
|
|
942
1140
|
const insert = db.prepare('INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
943
1141
|
.bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString());
|
|
@@ -948,17 +1146,22 @@ export class Table {
|
|
|
948
1146
|
}
|
|
949
1147
|
|
|
950
1148
|
/**
|
|
951
|
-
* LOBBY — one per game: which
|
|
952
|
-
*
|
|
953
|
-
* 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
|
|
954
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.
|
|
955
1157
|
*/
|
|
956
1158
|
export class Lobby {
|
|
957
1159
|
constructor(ctx, env) {
|
|
958
1160
|
this.ctx = ctx;
|
|
959
1161
|
this.env = env;
|
|
960
1162
|
this.rooms = new Map();
|
|
961
|
-
|
|
1163
|
+
/** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
|
|
1164
|
+
this.next = { public: 1 };
|
|
962
1165
|
/** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
|
|
963
1166
|
this.live = new Map();
|
|
964
1167
|
this.peak = { day: null, players: 0, room: 0 };
|
|
@@ -968,8 +1171,8 @@ export class Lobby {
|
|
|
968
1171
|
ctx.blockConcurrencyWhile(async () => {
|
|
969
1172
|
const saved = await ctx.storage.get('lobby');
|
|
970
1173
|
if (saved) {
|
|
971
|
-
this.next = saved.next
|
|
972
|
-
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: [] });
|
|
973
1176
|
for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
|
|
974
1177
|
}
|
|
975
1178
|
});
|
|
@@ -995,11 +1198,12 @@ export class Lobby {
|
|
|
995
1198
|
if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
|
|
996
1199
|
}
|
|
997
1200
|
|
|
998
|
-
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(() => {}); }
|
|
999
1202
|
|
|
1000
1203
|
prune(now) {
|
|
1001
1204
|
for (const [name, r] of this.rooms) {
|
|
1002
1205
|
r.pending = r.pending.filter((t) => now - t < 20_000);
|
|
1206
|
+
r.agentPending = (r.agentPending ?? []).filter((t) => now - t < 20_000);
|
|
1003
1207
|
if (r.players === 0 && !r.pending.length && now - r.at > 120_000) this.rooms.delete(name);
|
|
1004
1208
|
}
|
|
1005
1209
|
}
|
|
@@ -1010,26 +1214,49 @@ export class Lobby {
|
|
|
1010
1214
|
this.prune(now);
|
|
1011
1215
|
if (url.pathname === '/join') {
|
|
1012
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));
|
|
1013
1220
|
// A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
|
|
1014
1221
|
const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
|
|
1015
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
|
+
}
|
|
1016
1237
|
let best = null;
|
|
1017
|
-
for (const r of
|
|
1018
|
-
if (not.has(r.name) || this.closed.has(r.name)) continue;
|
|
1238
|
+
for (const r of pool) {
|
|
1019
1239
|
const fill = r.players + r.pending.length;
|
|
1020
1240
|
if (fill >= max) continue;
|
|
1021
1241
|
if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
|
|
1022
1242
|
}
|
|
1023
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
|
+
}
|
|
1024
1250
|
if (!room) {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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;
|
|
1028
1255
|
this.rooms.set(room.name, room);
|
|
1029
1256
|
}
|
|
1030
1257
|
room.pending.push(now);
|
|
1031
1258
|
this.save();
|
|
1032
|
-
return json({ room: room.name, players: room.players, max });
|
|
1259
|
+
return json({ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) });
|
|
1033
1260
|
}
|
|
1034
1261
|
if (url.pathname === '/report' && request.method === 'POST') {
|
|
1035
1262
|
const body = await request.json().catch(() => ({}));
|
|
@@ -1042,30 +1269,36 @@ export class Lobby {
|
|
|
1042
1269
|
return json({ ok: true, closed: body.closed > now });
|
|
1043
1270
|
}
|
|
1044
1271
|
const seated = Math.max(0, Math.floor(Number(body.players) || 0));
|
|
1045
|
-
|
|
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 });
|
|
1046
1275
|
else this.seen.delete(body.room);
|
|
1047
1276
|
if (this.closed.has(body.room)) return json({ ok: true, closed: true });
|
|
1048
1277
|
this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
|
|
1049
|
-
// Only
|
|
1050
|
-
if (!this.rooms.has(body.room) &&
|
|
1051
|
-
const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
|
|
1052
|
-
const grew = Math.max(0,
|
|
1053
|
-
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;
|
|
1054
1283
|
r.pending.splice(0, grew);
|
|
1055
|
-
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;
|
|
1056
1288
|
r.at = now;
|
|
1057
1289
|
this.rooms.set(r.name, r);
|
|
1058
1290
|
if (changed) this.save(); // the minute's "still here" report writes nothing
|
|
1059
1291
|
return json({ ok: true });
|
|
1060
1292
|
}
|
|
1061
|
-
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 })) });
|
|
1062
1294
|
if (url.pathname === '/busiest') {
|
|
1063
|
-
// The
|
|
1064
|
-
// no seat. A closed room,
|
|
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.
|
|
1065
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;
|
|
1066
1299
|
let best = null;
|
|
1067
1300
|
for (const r of this.rooms.values()) {
|
|
1068
|
-
if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now) continue;
|
|
1301
|
+
if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
|
|
1069
1302
|
if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
|
|
1070
1303
|
}
|
|
1071
1304
|
return json({ room: best?.name ?? null, players: best?.players ?? 0 });
|
|
@@ -1074,9 +1307,9 @@ export class Lobby {
|
|
|
1074
1307
|
// The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
|
|
1075
1308
|
for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
|
|
1076
1309
|
const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
|
|
1077
|
-
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 });
|
|
1078
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 });
|
|
1079
|
-
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) });
|
|
1080
1313
|
}
|
|
1081
1314
|
if (url.pathname === '/now') {
|
|
1082
1315
|
let players = 0;
|