@homie-rocks/studio 0.30.1 → 0.31.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/CHANGELOG.md +111 -1
- package/README.md +361 -22
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +142 -123
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +15 -5
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +15 -5
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +141 -23
- package/worker/parts.mjs +234 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/worker/arrival.mjs
CHANGED
|
@@ -136,7 +136,10 @@ export const ARRIVAL_JS = String.raw`(function () {
|
|
|
136
136
|
var stepEl = el.querySelector('[data-arrive-step]');
|
|
137
137
|
var roomEl = el.querySelector('[data-arrive-room]');
|
|
138
138
|
var bar = el.querySelector('[data-arrive-bar]');
|
|
139
|
-
|
|
139
|
+
// mode: who was to say when the game is playable ('auto': the helper, 'game': the game, 'seat': an older helper);
|
|
140
|
+
// explicitMs: when the game itself said so (the page's clock; null: it never did); lateMs: how long after an
|
|
141
|
+
// automatic arrival had already lifted the card that was (null: it was not late). The shell's script fills both.
|
|
142
|
+
var a = { phase: 'room', step: stepEl.textContent, p: 0.06, lookMs: null, liftedMs: null, by: null, mode: null, explicitMs: null, lateMs: null };
|
|
140
143
|
api.state = a;
|
|
141
144
|
// The card's first frame on the screen: what perf reads as the time to the first meaningful frame.
|
|
142
145
|
frame(function () { frame(function () { a.lookMs = Math.round(now()); mark('homie:look'); }); });
|
package/worker/discover.mjs
CHANGED
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
* /sitemap.xml every public page (Home, Games and each landing, Music, Videos, Rooms, Posts, the studio's own
|
|
9
9
|
* pages), with lastmod only where a real date says when it changed
|
|
10
10
|
* /llms.txt the llms.txt convention (an H1, a > summary, then ## sections of links): what the studio is, each
|
|
11
|
-
* public game with its pitch, players, Play, Watch, the big screen
|
|
12
|
-
*
|
|
11
|
+
* public game with its pitch, players, Play, Watch, the big screen, the licence it names and the
|
|
12
|
+
* game it was based on (when it has either), songs,
|
|
13
13
|
* videos, posts with their feeds, the studio's own pages, and a link to homie.rocks's llms.txt
|
|
14
14
|
* /llms-full.txt the same, with each game's whole description, how to play, controls and credits, and every
|
|
15
15
|
* post's text
|
|
16
16
|
*
|
|
17
|
-
* Only public games are ever named (a private or invite-only game is in none of them), and
|
|
18
|
-
*
|
|
17
|
+
* Only public games are ever named (a private or invite-only game is in none of them), and no game is offered to
|
|
18
|
+
* be taken whole (the pieces a studio shares are its parts, at /parts/, worker/parts.mjs). A file of the
|
|
19
19
|
* studio's own in site/public (robots.txt, sitemap.xml, llms.txt, llms-full.txt) wins over the made one.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
21
|
+
import { basedOnRow, licenseLabel } from './license.mjs';
|
|
22
22
|
|
|
23
23
|
export const DISCOVERY_FILES = ['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt'];
|
|
24
24
|
|
|
@@ -111,15 +111,6 @@ export function sitemapXml(cat, origin, opts = {}) {
|
|
|
111
111
|
|
|
112
112
|
/* ------------------------------------------------------------------ llms.txt */
|
|
113
113
|
|
|
114
|
-
const HOMIE_INSTALL = 'in Claude Code, `/plugin marketplace add homie-rocks/homie` and then `/plugin install homie@homie` (Codex takes the same marketplace)';
|
|
115
|
-
|
|
116
|
-
function licenceWords(g) {
|
|
117
|
-
const l = licenseOf(g.license);
|
|
118
|
-
const name = `${LICENSE_KINDS[l.kind]}${l.spdx ? `, ${l.spdx}` : ''}`;
|
|
119
|
-
if (l.kind === 'remix-freely') return `${name}: a credit is welcome, not asked for`;
|
|
120
|
-
return `${name}: a remix says "Remix of ${one(g.name, 80)} by ${one(g.studio, 80)}" with a link back`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
114
|
function summaryOf(cat, origin) {
|
|
124
115
|
const name = studioName(cat);
|
|
125
116
|
const tag = one(cat.studio?.tagline, 140);
|
|
@@ -137,8 +128,8 @@ function summaryOf(cat, origin) {
|
|
|
137
128
|
];
|
|
138
129
|
}
|
|
139
130
|
|
|
140
|
-
/** /llms.txt
|
|
141
|
-
export function llmsTxt(cat, origin, {
|
|
131
|
+
/** /llms.txt (and, with `full`, /llms-full.txt). */
|
|
132
|
+
export function llmsTxt(cat, origin, { directory = 'https://homie.rocks', full = false, posts = null, all = null } = {}) {
|
|
142
133
|
const name = studioName(cat);
|
|
143
134
|
const games = (cat.games ?? []).map((g) => ({ ...g, studio: name }));
|
|
144
135
|
const songs = cat.songs ?? [];
|
|
@@ -154,9 +145,10 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
|
|
|
154
145
|
const L = g.landing ?? {};
|
|
155
146
|
const doors = [`Play: ${origin}/${g.id}/play`, ...(watchable(g) ? [`Watch a live room: ${origin}/${g.id}/watch`] : []), ...(tv(g) ? [`On a TV: ${origin}/${g.id}/tv`] : [])];
|
|
156
147
|
const facts = [players(g), rounds(g.roundSeconds), ...(Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : [])].filter(Boolean).join(', ');
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
148
|
+
// The credit a game owes the one it was made from, and the licence it names for itself: facts, when it has them.
|
|
149
|
+
const lineage = basedOnRow(g.basedOn ?? g.remixOf);
|
|
150
|
+
const licence = licenseLabel(g.license);
|
|
151
|
+
out.push(`- [${label(g.name)}](${origin}/${g.id}/): ${one(L.pitch || g.blurb, 240)}${/[.!?]$/.test(one(L.pitch || g.blurb)) ? '' : '.'} ${facts}.${lineage ? ` Based on ${one(lineage.name, 80)}${lineage.studio ? ` by ${one(lineage.studio, 80)}` : ''}.` : ''} ${doors.join('. ')}.${licence ? ` Licence: ${licence}.` : ''}`);
|
|
160
152
|
if (full) {
|
|
161
153
|
const C = L.credits ?? {};
|
|
162
154
|
const lines = [];
|
|
@@ -173,12 +165,6 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
|
|
|
173
165
|
for (const l of lines) out.push(` ${l}`);
|
|
174
166
|
}
|
|
175
167
|
}
|
|
176
|
-
const open = games.filter((g) => remixable(g));
|
|
177
|
-
if (open.length) {
|
|
178
|
-
out.push('', '## Remix', '');
|
|
179
|
-
out.push(`Homie is a plugin for Claude Code and Codex that makes multiplayer games in a studio of your own, on your own free Cloudflare account. To remix one of these games, ${HOMIE_INSTALL}, then say "Remix <game> from <its source address> into a game of my own in my Homie studio". The plugin's \`game_remix\` tool (or \`npx --no-install homie-studio game remix <source address> --id <new-id>\` in a studio's folder) copies it in and writes the credit its licence asks for.`, '');
|
|
180
|
-
for (const g of open) out.push(`- [${label(g.name)} source](${origin}/games/${g.id}/source.json): ${licenceWords(g)}. Say: "Remix ${one(g.name, 80)} from ${origin}/games/${g.id}/source.json into a game of my own in my Homie studio".`);
|
|
181
|
-
}
|
|
182
168
|
}
|
|
183
169
|
if (songs.length) {
|
|
184
170
|
out.push('', '## Music', '');
|
package/worker/index.mjs
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt for search engines and AI agents, made from the public
|
|
40
40
|
* catalogue (worker/discover.mjs); every generated page also carries schema.org JSON-LD
|
|
41
41
|
* (worker/schema.mjs)
|
|
42
|
-
* /games/<id>/source.json a
|
|
42
|
+
* /games/<id>/source.json 410, for a client that still asks: remix was retired, no game's source is served whole
|
|
43
43
|
* /media/<key> a loose file in the studio's R2 (`media put`), with byte ranges
|
|
44
44
|
* /music/<slug>/<file>, /videos/<slug>/<file> a song's or video's files: from the site's own files, or, for a
|
|
45
45
|
* file `media move` put in the studio's R2 (the catalogue names its key), from R2 at the
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
|
|
48
48
|
* /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
|
|
49
49
|
* /_studio/office the owner's back office: live rooms and who is in them, kick, mute, announce, close,
|
|
50
|
-
* each game's launch state (private, invite-only beta, public),
|
|
50
|
+
* each game's launch state (private, invite-only beta, public), room size
|
|
51
51
|
* and invites (worker/office.mjs); /_studio/api/* is the same as JSON
|
|
52
52
|
* /<game>/invite an invite code spent for this browser's pass to an invite-only game
|
|
53
53
|
* /_homie/site.js the pages' one script
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
* on the Table's alarm: Workers AI through the optional AI binding (deploy adds
|
|
83
83
|
* it when a server uses it), or the owner's own key (secret HOMIE_BRAIN_KEY).
|
|
84
84
|
*/
|
|
85
|
-
import { NetRoom, WATCH_POLICIES } from './room.mjs';
|
|
85
|
+
import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
|
|
86
86
|
import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
87
87
|
import {
|
|
88
88
|
PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
|
|
@@ -96,7 +96,7 @@ import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs'
|
|
|
96
96
|
import { isLoungePath, loungeRoutes } from './lounge.mjs';
|
|
97
97
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
98
98
|
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
99
|
-
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
99
|
+
import { SEAT_MAX, paramsFrom, perAddress, seatsOf } from './seats.mjs';
|
|
100
100
|
import {
|
|
101
101
|
SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homeLd, homePage, jsonFeed, landingLd, mediaArt, mediaIndexPage,
|
|
102
102
|
notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
|
|
@@ -105,7 +105,7 @@ import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, p
|
|
|
105
105
|
import { ownerRoutes } from './stats-page.mjs';
|
|
106
106
|
import { playerRoutes, players as playerAccounts } from './players.mjs';
|
|
107
107
|
import {
|
|
108
|
-
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite,
|
|
108
|
+
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
|
|
109
109
|
ticketFor, ticketSub, usePlayers, verifyControl,
|
|
110
110
|
} from './office.mjs';
|
|
111
111
|
|
|
@@ -117,7 +117,9 @@ import { readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs'
|
|
|
117
117
|
import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
|
|
118
118
|
import { ldScript, studioNode } from './schema.mjs';
|
|
119
119
|
import { arrivalCookie, manifestReferrals } from './referrals.mjs';
|
|
120
|
-
import { licenseOf
|
|
120
|
+
import { licenseOf } from './license.mjs';
|
|
121
|
+
// GAME PARTS (parts/PARTS.md section 4): three call-outs below, everything else is worker/parts.mjs.
|
|
122
|
+
import { PART_FILES, isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
|
|
121
123
|
|
|
122
124
|
export { SEAT_MAX } from './seats.mjs';
|
|
123
125
|
/** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
|
|
@@ -462,10 +464,19 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
462
464
|
// in the socket's address, which every netplay helper opens as it is given.
|
|
463
465
|
const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : null;
|
|
464
466
|
const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
|
|
467
|
+
// The game's revision (game.json `netplay.version`, NETPLAY.md section 23) as this page is served: it rides in the
|
|
468
|
+
// socket's address (`gv`), so a build whose helper predates revisions is still known for the build it is.
|
|
469
|
+
const ver = versionOf(meta?.netplay?.version);
|
|
470
|
+
// The play page's switches for the game (section 24): the allowed names only, checked again here.
|
|
471
|
+
const params = paramsFrom(url.searchParams, meta);
|
|
465
472
|
const cfg = {
|
|
466
473
|
v: 1,
|
|
467
|
-
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}`,
|
|
474
|
+
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
|
|
468
475
|
room,
|
|
476
|
+
...(ver ? { ver } : {}),
|
|
477
|
+
...(Object.keys(params).length ? { params } : {}),
|
|
478
|
+
// The page around this frame keeps net.prefs (the play page says so; a watch page or an older page does not).
|
|
479
|
+
...(url.searchParams.get('pf') === '1' ? { prefs: true } : {}),
|
|
469
480
|
...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
|
|
470
481
|
...(agent ? { name: agent.name } : url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
|
|
471
482
|
// An AI that runs the game itself (hands `self`): its hello says so; the relay knows it from its ticket anyway.
|
|
@@ -553,7 +564,8 @@ function finish(res, path) {
|
|
|
553
564
|
if (!/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
|
|
554
565
|
const cache = res.headers.get('cache-control') ?? '';
|
|
555
566
|
const addNt = !/no-transform/i.test(cache);
|
|
556
|
-
|
|
567
|
+
// GAME PARTS: a shared part's preview page is framed by the part's page and the hub, sandboxed (worker/parts.mjs).
|
|
568
|
+
const addXfo = !res.headers.has('x-frame-options') && !GAME_FILES.test(path) && !PART_FILES.test(path);
|
|
557
569
|
if (!addNt && !addXfo) return res;
|
|
558
570
|
const headers = new Headers(res.headers);
|
|
559
571
|
if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
|
|
@@ -623,6 +635,11 @@ async function route(request, env, ctx) {
|
|
|
623
635
|
return json({ ok: false, error: 'no-stats', message: `The counters are not in this studio's D1 yet: \`npm run deploy\` applies migration 0002_studio_stats.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
|
|
624
636
|
}
|
|
625
637
|
}
|
|
638
|
+
// GAME PARTS: /.well-known/homie-parts.json, /parts/<id>/<version>/… and the studio's own parts pages.
|
|
639
|
+
if (isPartsPath(path)) {
|
|
640
|
+
const shared = await partsRoutes(request, env, url, { catalogueOf: getCat });
|
|
641
|
+
if (shared) return shared;
|
|
642
|
+
}
|
|
626
643
|
if (path === '/.well-known/homie-studio.json') {
|
|
627
644
|
const cat = await getCat();
|
|
628
645
|
const sharing = Boolean(cat.studio?.stats?.share);
|
|
@@ -645,17 +662,17 @@ async function route(request, env, ctx) {
|
|
|
645
662
|
games: await Promise.all((cat.games ?? []).map(async (g) => {
|
|
646
663
|
// The card picture is the landing's hero still (what the landing leads with), else the game's cover.
|
|
647
664
|
const cover = gameCover(g);
|
|
648
|
-
//
|
|
665
|
+
// The licence the game names for itself (an SPDX id), when it names one (worker/license.mjs). Nothing here
|
|
666
|
+
// offers the game to be taken whole: `remix`, `source` and `remixOf` left this manifest with remix.
|
|
649
667
|
const license = licenseOf(g.license);
|
|
650
|
-
const remix = remixOf(g, await settingsOf(env)) && remixAllowed(license);
|
|
651
|
-
const lineage = remixRow(g.remixOf);
|
|
652
668
|
return {
|
|
653
669
|
id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
|
|
654
670
|
page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
//
|
|
658
|
-
|
|
671
|
+
...(license ? { license } : {}),
|
|
672
|
+
// Which build of the game is live: its digest (what `homie-studio build` printed for it, and what
|
|
673
|
+
// site/dist/_site/build.json says) and the bundle its page loads. "Is the live game the one I built?" is
|
|
674
|
+
// this against that, with no file to fetch and no edge cache to wait out.
|
|
675
|
+
...(g.built?.hash ? { build: { hash: g.built.hash, ...(g.built.bundle ? { bundle: `${url.origin}/games/${g.id}/${g.built.bundle}` } : {}) } } : {}),
|
|
659
676
|
// Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
|
|
660
677
|
...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
|
|
661
678
|
};
|
|
@@ -700,28 +717,12 @@ async function route(request, env, ctx) {
|
|
|
700
717
|
return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
|
|
701
718
|
}
|
|
702
719
|
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const meta = (all.games ?? []).find((g) => g.id === src[1]);
|
|
710
|
-
if (meta) {
|
|
711
|
-
const settings = await settingsOf(env, { fresh: true });
|
|
712
|
-
if (launchOf(meta, settings, env) !== 'public' || !remixOf(meta, settings)) return json({ ok: false, error: 'not-shared', message: 'This game\'s source is not shared for remixing.' }, 404);
|
|
713
|
-
if (request.method === 'GET') {
|
|
714
|
-
const res = await env.ASSETS.fetch(new Request(`${url.origin}${path}`));
|
|
715
|
-
let body = null;
|
|
716
|
-
if (res.ok) { try { body = await res.json(); } catch { body = null; } }
|
|
717
|
-
if (body?.kind === 'homie-game-source' && body.files && typeof body.files === 'object') {
|
|
718
|
-
const credit = { studio: all.studio?.name ?? body.credit?.studio ?? null, game: meta.name ?? body.credit?.game ?? meta.id, page: `${url.origin}/${meta.id}/` };
|
|
719
|
-
const { files, ...head } = body;
|
|
720
|
-
return json({ ...head, credit, license: licenseOf(body.license ?? meta.license), files }, 200, { 'access-control-allow-origin': '*' });
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
}
|
|
720
|
+
// REMIX WAS RETIRED: no game's source is served whole. An older toolkit's `game remix`, the directory's copy of a
|
|
721
|
+
// studio, or a link somebody kept may still ask for one, so the address answers 410 (gone, on purpose, for good)
|
|
722
|
+
// with where to go instead: /parts/, the pieces of games this studio chose to share. The same answer for every
|
|
723
|
+
// id, whether or not such a game exists, and before the site's files are looked at: a source.json an older build
|
|
724
|
+
// left in the deployed folder is never reachable.
|
|
725
|
+
if (/^\/games\/[a-z0-9][a-z0-9-]{0,39}\/source\.json$/.test(path)) return json({ retired: 'remix', see: '/parts/' }, 410, { 'access-control-allow-origin': '*' });
|
|
725
726
|
|
|
726
727
|
// Search engines and AI agents (0.27.0, worker/discover.mjs): robots.txt, sitemap.xml, llms.txt and llms-full.txt,
|
|
727
728
|
// made from the public catalogue (a private or invite-only game is in none of them); a file of the studio's own in
|
|
@@ -734,10 +735,8 @@ async function route(request, env, ctx) {
|
|
|
734
735
|
if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' }), 'text/plain', { method });
|
|
735
736
|
const all = await getAll();
|
|
736
737
|
if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
|
|
737
|
-
const settings = await settingsOf(env);
|
|
738
|
-
const remixable = (g) => g.landing?.source !== false && remixOf(g, settings) && remixAllowed(g.license);
|
|
739
738
|
const full = path === '/llms-full.txt';
|
|
740
|
-
return discoveryResponse(llmsTxt(cat, url.origin, {
|
|
739
|
+
return discoveryResponse(llmsTxt(cat, url.origin, { directory: directoryOf(cat), full, posts: full ? await postsOf(env, url.origin) : null, all }), 'text/plain', { method });
|
|
741
740
|
}
|
|
742
741
|
|
|
743
742
|
// A page the studio made itself (site/pages) wins at its address.
|
|
@@ -871,7 +870,8 @@ async function route(request, env, ctx) {
|
|
|
871
870
|
const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
872
871
|
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
873
872
|
const band = servers.some((x) => x.id !== 'public' && x.listed && x.state === 'open') ? await serverLive(env, meta, servers, url.origin, lobby) : null;
|
|
874
|
-
|
|
873
|
+
// GAME PARTS: the landing's "Parts from this game" band (shared parts only).
|
|
874
|
+
return withPartsBand(gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, servers: band, listed: launch === 'public', shop: launch === 'public' ? await sellingShop(env, cat) : null }), env, url, meta);
|
|
875
875
|
}
|
|
876
876
|
if (sub === 'live') {
|
|
877
877
|
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -953,7 +953,7 @@ async function route(request, env, ctx) {
|
|
|
953
953
|
// The big screen picks its room now, so the QR it shows puts every phone in that same room.
|
|
954
954
|
let room = asked;
|
|
955
955
|
if (!room) {
|
|
956
|
-
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; }
|
|
956
|
+
try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
|
|
957
957
|
}
|
|
958
958
|
const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
959
959
|
// Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
|
|
@@ -993,7 +993,8 @@ async function route(request, env, ctx) {
|
|
|
993
993
|
const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
|
|
994
994
|
const pools = servers.filter((x) => x.state === 'open' && x.door === 'open').map((x) => x.id).join(',');
|
|
995
995
|
let best = null;
|
|
996
|
-
|
|
996
|
+
const wver = versionOf(meta?.netplay?.version);
|
|
997
|
+
try { best = await (await lobby().fetch(`https://lobby/busiest?pools=${encodeURIComponent(pools)}${not ? `¬=${encodeURIComponent(not)}` : ''}${wver ? `&ver=${encodeURIComponent(wver)}` : ''}`)).json(); } catch { best = null; }
|
|
997
998
|
return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
|
|
998
999
|
}
|
|
999
1000
|
if (sub === 'api/lobby') {
|
|
@@ -1008,7 +1009,9 @@ async function route(request, env, ctx) {
|
|
|
1008
1009
|
// A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
|
|
1009
1010
|
const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
|
|
1010
1011
|
const pol = policyFor(srv);
|
|
1011
|
-
|
|
1012
|
+
// Strangers on different builds of the game never meet (section 23): the Lobby matches within the live build.
|
|
1013
|
+
const ver = versionOf(meta?.netplay?.version);
|
|
1014
|
+
return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `¬=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}`, { method: 'POST' });
|
|
1012
1015
|
}
|
|
1013
1016
|
if (sub === 'api/agent') {
|
|
1014
1017
|
// An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
|
|
@@ -1020,7 +1023,7 @@ async function route(request, env, ctx) {
|
|
|
1020
1023
|
join: async (srv, pol) => {
|
|
1021
1024
|
try {
|
|
1022
1025
|
const reserve = pol.aiSeats + pol.guides;
|
|
1023
|
-
const r = await (await lobby().fetch(`https://lobby/join?max=${max}&server=${srv.id}&agent=1&ai=${reserve}`, { method: 'POST' })).json();
|
|
1026
|
+
const r = await (await lobby().fetch(`https://lobby/join?max=${max}&server=${srv.id}&agent=1&ai=${reserve}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json();
|
|
1024
1027
|
return r.room ?? null;
|
|
1025
1028
|
} catch { return null; }
|
|
1026
1029
|
},
|
|
@@ -1089,8 +1092,21 @@ async function route(request, env, ctx) {
|
|
|
1089
1092
|
const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
|
|
1090
1093
|
const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
|
|
1091
1094
|
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
1092
|
-
|
|
1093
|
-
|
|
1095
|
+
// Revision 9 (NETPLAY.md sections 22 and 23): the build that is live now (`cur`, always said, empty when the game
|
|
1096
|
+
// names none), the build this socket's page was served with (`gv`, from the socket's own address), and the game's
|
|
1097
|
+
// own stall time.
|
|
1098
|
+
const cur = versionOf(meta?.netplay?.version) ?? '';
|
|
1099
|
+
const gv = versionOf(url.searchParams.get('gv')) ?? '';
|
|
1100
|
+
const stall = Number(meta?.netplay?.stallMs) || 0;
|
|
1101
|
+
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)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}`;
|
|
1102
|
+
// The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
|
|
1103
|
+
// tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
|
|
1104
|
+
// uncaught error with nothing on it.
|
|
1105
|
+
try { return await stub.fetch(new Request(target, request)); } catch (error) {
|
|
1106
|
+
const gone = departure(error);
|
|
1107
|
+
try { console[gone ? 'log' : 'error'](JSON.stringify({ at: new Date().toISOString(), ev: gone ? 'socket-gone' : 'room-unreachable', game, room, sub, error: errorLine(error) })); } catch { /* no console */ }
|
|
1108
|
+
return new Response(gone ? 'gone' : 'the room did not answer; try again', { status: gone ? 499 : 503 });
|
|
1109
|
+
}
|
|
1094
1110
|
}
|
|
1095
1111
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
1096
1112
|
if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
|
|
@@ -1203,6 +1219,10 @@ export class Table {
|
|
|
1203
1219
|
save: (o) => { storage.put('net', o).catch(() => {}); },
|
|
1204
1220
|
clear: () => { storage.delete('net').catch(() => {}); },
|
|
1205
1221
|
},
|
|
1222
|
+
// The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
|
|
1223
|
+
// election, a build change, and `failed` (a room operation that threw). A departure and a failure seconds apart
|
|
1224
|
+
// are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
|
|
1225
|
+
log: (line) => this.say(line),
|
|
1206
1226
|
});
|
|
1207
1227
|
if (this.saved) this.room.restore(this.saved);
|
|
1208
1228
|
if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
|
|
@@ -1218,6 +1238,27 @@ export class Table {
|
|
|
1218
1238
|
return this.room;
|
|
1219
1239
|
}
|
|
1220
1240
|
|
|
1241
|
+
/**
|
|
1242
|
+
* One log line of this room: `{ at, ev, game, room, … }`. Never a seat token, a ticket, a browser key or an address;
|
|
1243
|
+
* a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
|
|
1244
|
+
*/
|
|
1245
|
+
say(line) {
|
|
1246
|
+
const bad = line?.ev === 'failed' || line?.ev === 'socket-error' || line?.ev === 'tick-failed' || line?.ev === 'persist-failed';
|
|
1247
|
+
if (!bad && this.env.HOMIE_ROOM_LOG === '0') return;
|
|
1248
|
+
try { console[bad ? 'error' : 'log'](JSON.stringify({ at: new Date().toISOString(), ...line, game: this.game ?? null, room: line?.room ?? this.code ?? null })); } catch { /* no console */ }
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* A socket's `error` event. The runtime raises one when the other end simply went away ("Network connection lost":
|
|
1253
|
+
* a closed tab, a phone that slept, a browser the test harness shut): that is a departure, and the room handles it
|
|
1254
|
+
* as one. Anything else is a failed socket, logged as such. Both lines carry the room, the client and its role.
|
|
1255
|
+
*/
|
|
1256
|
+
socketError(kind, event, facts = {}) {
|
|
1257
|
+
const error = event?.error ?? event?.message ?? event;
|
|
1258
|
+
const gone = departure(error) || !error || typeof error === 'object' && !error.message;
|
|
1259
|
+
this.say({ ev: gone ? 'socket-gone' : 'socket-error', kind, ...facts, error: errorLine(error) });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1221
1262
|
/** Kept chat's writes, in order, each finished before the next (D1 calls from one room never pass each other). */
|
|
1222
1263
|
keptQueue(fn) {
|
|
1223
1264
|
if (!this.env.DB) return;
|
|
@@ -1458,6 +1499,9 @@ export class Table {
|
|
|
1458
1499
|
}
|
|
1459
1500
|
// The owner's room size: a room already open takes it from the next visitor on.
|
|
1460
1501
|
if (room.seatCap !== max) room.setSeats(max, perAddress(max));
|
|
1502
|
+
// Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
|
|
1503
|
+
const said = url.searchParams.has('cur');
|
|
1504
|
+
if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
|
|
1461
1505
|
// The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
|
|
1462
1506
|
const pol = decodeFacts(url.searchParams.get('pol'));
|
|
1463
1507
|
// The room's chat rules ride beside it (they may be longer than a policy): section 19.
|
|
@@ -1486,6 +1530,9 @@ export class Table {
|
|
|
1486
1530
|
watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
|
|
1487
1531
|
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
1488
1532
|
qa: isQa(request),
|
|
1533
|
+
// The build this socket's page was served with (section 23), from the socket's own address: only when the
|
|
1534
|
+
// Worker speaks of builds at all (`cur`), so a relay behind an older Worker reads the hello's own.
|
|
1535
|
+
...(said && url.pathname === '/__net' ? { ver: url.searchParams.get('gv') || null } : {}),
|
|
1489
1536
|
// Room chat (section 19), the Worker's word: a signed-in account (a passkey), a member of this server, a page of
|
|
1490
1537
|
// another site (homie.rocks's room page) watching from elsewhere.
|
|
1491
1538
|
acct: url.searchParams.get('acct') === '1',
|
|
@@ -1505,9 +1552,12 @@ export class Table {
|
|
|
1505
1552
|
if (room.watchers.size >= 256 || same >= 24) { try { server.close(1013, 'too many watching'); } catch { /* gone */ } return new Response(null, { status: 101, webSocket: client }); }
|
|
1506
1553
|
const w = room.watch(conn);
|
|
1507
1554
|
// The play page's vote card speaks on this socket (section 17); nothing else it says is read.
|
|
1508
|
-
server.addEventListener('message', (e) => {
|
|
1555
|
+
server.addEventListener('message', (e) => {
|
|
1556
|
+
if (typeof e.data !== 'string') return;
|
|
1557
|
+
try { w.onMessage(e.data); } catch (error) { this.say({ ev: 'failed', op: 'watch-message', error: errorLine(error) }); }
|
|
1558
|
+
});
|
|
1509
1559
|
server.addEventListener('close', () => w.onClose());
|
|
1510
|
-
server.addEventListener('error', () => w.onClose());
|
|
1560
|
+
server.addEventListener('error', (e) => { this.socketError('watch', e); w.onClose(); });
|
|
1511
1561
|
} else {
|
|
1512
1562
|
const h = room.attach(conn);
|
|
1513
1563
|
// A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
|
|
@@ -1524,17 +1574,24 @@ export class Table {
|
|
|
1524
1574
|
}
|
|
1525
1575
|
});
|
|
1526
1576
|
// An AI's time in a seat, counted once as it leaves (stats `agent-minutes`; never for house QA).
|
|
1527
|
-
const left = () => {
|
|
1577
|
+
const left = (via) => {
|
|
1528
1578
|
const c = room.clients.get(h.id);
|
|
1529
1579
|
if (c?.agent && c.helloed && c.seat !== null && !conn.qa) {
|
|
1530
1580
|
const minutes = Math.round((Date.now() - c.joinedAt) / 60_000);
|
|
1531
1581
|
if (minutes > 0) this.ctx.waitUntil(count(this.env, null, { metric: 'agent-minutes', subject: this.game ?? '', source: c.agent.role, n: minutes }).catch(() => {}));
|
|
1532
1582
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1583
|
+
// The room says the departure itself (its `leave` line: who, which seat, host or not, how many are left).
|
|
1584
|
+
h.onClose(via);
|
|
1585
|
+
try { this.report(); } catch (error) { this.say({ ev: 'failed', op: 'report', error: errorLine(error) }); }
|
|
1535
1586
|
};
|
|
1536
|
-
server.addEventListener('close', left);
|
|
1537
|
-
|
|
1587
|
+
server.addEventListener('close', () => left('close'));
|
|
1588
|
+
// A lost network arrives here, not as a close: said as what it is, with the client it was, then handled as the
|
|
1589
|
+
// departure it is. (An error with a listener is never the runtime's "Uncaught".)
|
|
1590
|
+
server.addEventListener('error', (e) => {
|
|
1591
|
+
const c = room.clients.get(h.id);
|
|
1592
|
+
this.socketError('net', e, { id: h.id, seat: c?.seat ?? null, role: c && c.helloed ? room.roleOf(c) : null });
|
|
1593
|
+
left('error');
|
|
1594
|
+
});
|
|
1538
1595
|
}
|
|
1539
1596
|
this.start();
|
|
1540
1597
|
return new Response(null, { status: 101, webSocket: client });
|
|
@@ -1546,7 +1603,8 @@ export class Table {
|
|
|
1546
1603
|
this.timer = setInterval(() => {
|
|
1547
1604
|
const room = this.room;
|
|
1548
1605
|
if (!room) return;
|
|
1549
|
-
room.
|
|
1606
|
+
// A beat that throws is one failed beat, said with its room; the next one still runs.
|
|
1607
|
+
try { room.tick(); } catch (error) { this.say({ ev: 'tick-failed', clients: room.clients.size, error: errorLine(error) }); }
|
|
1550
1608
|
// A room that just opened reads its game's launch state once: a change made in the moment it opened (before the
|
|
1551
1609
|
// Lobby knew of it) still reaches it, after the current round as always.
|
|
1552
1610
|
if (room.openedAt && this.launchReadFor !== room.openedAt) { this.launchReadFor = room.openedAt; this.ctx.waitUntil(this.rereadLaunch().catch(() => {})); }
|
|
@@ -1607,7 +1665,9 @@ export class Table {
|
|
|
1607
1665
|
this.lastAi = ai;
|
|
1608
1666
|
this.lastReportAt = Date.now();
|
|
1609
1667
|
const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
|
|
1610
|
-
this
|
|
1668
|
+
// The build this room runs (section 23): the Lobby sends a visitor only to a room of the live build.
|
|
1669
|
+
const ver = room.gameVer === undefined ? undefined : room.gameVer ?? '';
|
|
1670
|
+
this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people, agents, ai, ...(ver !== undefined ? { ver } : {}) }) }).catch(() => {}));
|
|
1611
1671
|
}
|
|
1612
1672
|
|
|
1613
1673
|
/** A round the host called over becomes one D1 row (once per round number). */
|
|
@@ -1657,7 +1717,7 @@ export class Lobby {
|
|
|
1657
1717
|
const saved = await ctx.storage.get('lobby');
|
|
1658
1718
|
if (saved) {
|
|
1659
1719
|
this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
|
|
1660
|
-
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: [] });
|
|
1720
|
+
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, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
|
|
1661
1721
|
for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
|
|
1662
1722
|
}
|
|
1663
1723
|
});
|
|
@@ -1683,7 +1743,7 @@ export class Lobby {
|
|
|
1683
1743
|
if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
|
|
1684
1744
|
}
|
|
1685
1745
|
|
|
1686
|
-
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(() => {}); }
|
|
1746
|
+
save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver }) => ({ name, players, agents, ai, server, at, ...(ver ? { ver } : {}) })), closed: [...this.closed] }).catch(() => {}); }
|
|
1687
1747
|
|
|
1688
1748
|
prune(now) {
|
|
1689
1749
|
for (const [name, r] of this.rooms) {
|
|
@@ -1693,10 +1753,23 @@ export class Lobby {
|
|
|
1693
1753
|
}
|
|
1694
1754
|
}
|
|
1695
1755
|
|
|
1756
|
+
/** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
|
|
1696
1757
|
async fetch(request) {
|
|
1758
|
+
try { return await this.answer(request); } catch (error) {
|
|
1759
|
+
let path = '';
|
|
1760
|
+
try { path = new URL(request.url).pathname; } catch { /* not an address */ }
|
|
1761
|
+
try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-failed', game: this.game ?? null, path, error: errorLine(error) })); } catch { /* no console */ }
|
|
1762
|
+
return json({ ok: false, error: 'lobby' }, 500);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
async answer(request) {
|
|
1697
1767
|
const url = new URL(request.url);
|
|
1698
1768
|
const now = Date.now();
|
|
1699
1769
|
this.prune(now);
|
|
1770
|
+
// REVISIONS (section 23): rooms are matched within one build of the game. A room that never said its build is
|
|
1771
|
+
// the build of a game that names none ('').
|
|
1772
|
+
const ver = versionOf(url.searchParams.get('ver')) ?? '';
|
|
1700
1773
|
if (url.pathname === '/join') {
|
|
1701
1774
|
const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
|
|
1702
1775
|
const sid = url.searchParams.get('server') ?? 'public';
|
|
@@ -1705,7 +1778,9 @@ export class Lobby {
|
|
|
1705
1778
|
// A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
|
|
1706
1779
|
const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
|
|
1707
1780
|
for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
|
|
1708
|
-
|
|
1781
|
+
// Every room of the server counts toward its `rooms_max` and its numbering; only this build's rooms are matched.
|
|
1782
|
+
const ofServer = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server && !not.has(r.name) && !this.closed.has(r.name));
|
|
1783
|
+
const pool = ofServer.filter((r) => (r.ver ?? '') === ver);
|
|
1709
1784
|
if (url.searchParams.get('agent') === '1') {
|
|
1710
1785
|
// An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
|
|
1711
1786
|
const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
|
|
@@ -1735,7 +1810,7 @@ export class Lobby {
|
|
|
1735
1810
|
if (!room) {
|
|
1736
1811
|
let n = this.next[server] ?? 1;
|
|
1737
1812
|
while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
|
|
1738
|
-
room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, pending: [], agentPending: [] };
|
|
1813
|
+
room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, pending: [], agentPending: [] };
|
|
1739
1814
|
this.next[server] = n + 1;
|
|
1740
1815
|
this.rooms.set(room.name, room);
|
|
1741
1816
|
}
|
|
@@ -1762,9 +1837,12 @@ export class Lobby {
|
|
|
1762
1837
|
this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
|
|
1763
1838
|
// Only rooms of a pool are matched with strangers (pub-N, s-<id>-<n>); a named room (?room=) stays private.
|
|
1764
1839
|
if (!this.rooms.has(body.room) && !pooledRoom(body.room)) return json({ ok: true, private: true });
|
|
1765
|
-
const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, pending: [], agentPending: [] };
|
|
1840
|
+
const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, ver: '', pending: [], agentPending: [] };
|
|
1766
1841
|
const grew = Math.max(0, seated - r.players);
|
|
1767
|
-
|
|
1842
|
+
// The room says which build it runs (it changes only when nobody on the old one is left).
|
|
1843
|
+
const rver = typeof body.ver === 'string' ? versionOf(body.ver) ?? '' : r.ver ?? '';
|
|
1844
|
+
const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
|
|
1845
|
+
r.ver = rver;
|
|
1768
1846
|
r.pending.splice(0, grew);
|
|
1769
1847
|
if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
|
|
1770
1848
|
r.players = seated;
|
|
@@ -1784,6 +1862,8 @@ export class Lobby {
|
|
|
1784
1862
|
let best = null;
|
|
1785
1863
|
for (const r of this.rooms.values()) {
|
|
1786
1864
|
if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
|
|
1865
|
+
// A watcher's page runs the live build: a room still on an older one would turn it away.
|
|
1866
|
+
if (url.searchParams.has('ver') && (r.ver ?? '') !== ver) continue;
|
|
1787
1867
|
if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
|
|
1788
1868
|
}
|
|
1789
1869
|
return json({ room: best?.name ?? null, players: best?.players ?? 0 });
|