@homie-rocks/studio 0.53.0 → 0.55.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 +57 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +5 -2
- package/lib/check.mjs +10 -2
- package/lib/cloudflare.mjs +16 -1
- package/lib/dev.mjs +8 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +46 -9
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +27 -10
- package/worker/app-tools.mjs +1 -0
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +29 -12
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +58 -2772
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +105 -34
- package/worker/operational-schema.mjs +44 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +15 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +78 -0
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/servers.mjs +5 -4
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +2 -2
- package/worker/shop-page.mjs +2 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +89 -27
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/** Route dispatch; large interfaces and tool runtimes load at their own entry points. */
|
|
2
|
+
import { catalogue } from './catalogue.mjs';
|
|
3
|
+
|
|
4
|
+
import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
|
|
5
|
+
import { appAccess, appRecordsRoute } from './app-records.mjs';
|
|
6
|
+
import { openPath } from './app-format.mjs';
|
|
7
|
+
import { playerEnabled } from './embed.mjs';
|
|
8
|
+
import { departure, errorLine, versionOf } from './room-protocol.mjs';
|
|
9
|
+
import { hostedGame } from './hosted-registry.mjs';
|
|
10
|
+
import { isAppOrigin } from './standalone.mjs';
|
|
11
|
+
import { ROOM_ID, shellKey, shellStandIn } from './page-policy.mjs';
|
|
12
|
+
import { PUBLIC_SERVER, SERVER_ID, homeOf, memberOf, noteMember, policyOf, pooledRoom, roomServer, serverAccess, serverPassOf, serverView, serversOf, setMembership } from './servers.mjs';
|
|
13
|
+
import { agentFacts, aiName, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
|
|
14
|
+
import { REACTIONS } from './chat.mjs';
|
|
15
|
+
import { REPORT_REASONS, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
|
|
16
|
+
import { isLoungePath, loungeRoutes } from './lounge.mjs';
|
|
17
|
+
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
18
|
+
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
19
|
+
import { gateFor } from './gate-routing.mjs';
|
|
20
|
+
|
|
21
|
+
import { SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homeLd, homePage, jsonFeed, landingLd, mediaIndexPage, notFoundPage, postPage, postsPage, roomsPage, sectionsOf, songPage, videoPage, watchOf } from './site.mjs';
|
|
22
|
+
import { cookieValues, countVisit, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats } from './stats.mjs';
|
|
23
|
+
import { playerRoutes, players as playerAccounts } from './players.mjs';
|
|
24
|
+
import { appRoleSession, appAssetTicket, accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows, ticketFor, ticketSub } from './office.mjs';
|
|
25
|
+
import { STUDIO_BUILD } from './build-id.mjs';
|
|
26
|
+
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
27
|
+
import { roomBadge, shopOf, shopRoutes } from './shop.mjs';
|
|
28
|
+
import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
|
|
29
|
+
import { ldScript, studioNode } from './schema.mjs';
|
|
30
|
+
import { licenseOf } from './license.mjs';
|
|
31
|
+
import { isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
|
|
32
|
+
import { GAME_ID, POST_SLUG, BROWSER_KEY, json, sellingShop, availableShellShop, postsOf, directoryOf, claimOf, named, roomsOf, MEDIA_SLUG, assetWithRange, mediaObject, ASSET_CACHE, heldInR2, mediaRow, chatWho, reportAllowed, notAHomie, gameDocument, gameAsset, shareDaily, mediaBeat, studioTools, serverLive, weekOf, fallback } from './runtime-support.mjs';
|
|
33
|
+
export default async function route(request, env, ctx) {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
const path = url.pathname;
|
|
36
|
+
if (/^\/games\/[^/]+\/source\.json$/.test(path)) return json({ok:false,error:'gone',message:'Whole-game sources were retired; use parts instead.'},410);
|
|
37
|
+
const parts = path.split('/').filter(Boolean);
|
|
38
|
+
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
39
|
+
let catP = null;
|
|
40
|
+
let pubP = null;
|
|
41
|
+
// Every game (for a game's own pages and the owner's), and the public ones (every list, the rooms, the manifest).
|
|
42
|
+
const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
|
|
43
|
+
const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
|
|
44
|
+
|
|
45
|
+
// Keep tool transports and discovery on the alias; redirect browser pages only.
|
|
46
|
+
if (read && url.hostname.endsWith('.workers.dev') && env.HOMIE_PREVIEW !== '1'
|
|
47
|
+
&& request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
|
|
48
|
+
&& !/\/(?:api|__[a-z]+)(?:\/|$)/.test(path)
|
|
49
|
+
&& !/^\/(?:\.well-known(?:\/|$)|api(?:\/|$)|mcp(?:\/|$)|oauth(?:\/|$)|hooks(?:\/|$)|_studio(?:\/|$))/.test(path)) {
|
|
50
|
+
const canonical = (await getAll()).studio?.canonical;
|
|
51
|
+
if (canonical && canonical !== url.origin) return Response.redirect(canonical + url.pathname + url.search, 308);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
|
|
55
|
+
|
|
56
|
+
if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
57
|
+
const { remoteMcp } = await import('./mcp.mjs');
|
|
58
|
+
return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// "Connect this chat" (the template's first-run band): the directory's setup page, for this site's address.
|
|
62
|
+
if (path === '/_studio/connect' && read) {
|
|
63
|
+
const directory = directoryOf(await getCat());
|
|
64
|
+
if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
|
|
65
|
+
return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
|
|
66
|
+
}
|
|
67
|
+
if (path.startsWith('/_studio/')) return (await officeRoutes(request, env, url, { catalogueOf: getAll })) ?? (await import('./stats-page.mjs')).ownerRoutes(request, env, url, { catalogueOf: getAll });
|
|
68
|
+
if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
|
|
69
|
+
// The shop (0.24.0) before player accounts: /api/player/owns is the shop's.
|
|
70
|
+
{
|
|
71
|
+
const shopped = await shopRoutes(request, env, ctx, url, { catalogueOf: getAll });
|
|
72
|
+
if (shopped) return shopped;
|
|
73
|
+
}
|
|
74
|
+
if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
|
|
75
|
+
// The Lounge (0.29.0, chat/LOUNGE.md): the studio's own community room, when studio.json turns it on.
|
|
76
|
+
if (isLoungePath(path)) {
|
|
77
|
+
const lounged = await loungeRoutes(request, env, ctx, url, { catalogueOf: getCat, roomsOf });
|
|
78
|
+
if (lounged) return lounged;
|
|
79
|
+
}
|
|
80
|
+
if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
81
|
+
if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
|
|
82
|
+
if (path === '/api/stats') {
|
|
83
|
+
if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
|
|
84
|
+
if (!(await ownerAllowed(request, env, { kinds: ['read', 'office'] }))) {
|
|
85
|
+
return json({ ok: false, error: 'owner-only', message: 'The studio\'s numbers are for its owner. In the studio folder: `npx --no-install homie-studio stats` prints them; `npx --no-install homie-studio stats key` gives a read key for the Homie MCP tool studio_stats.' }, 401, { 'www-authenticate': 'Bearer' });
|
|
86
|
+
}
|
|
87
|
+
const cat = await getAll();
|
|
88
|
+
const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
|
|
89
|
+
try {
|
|
90
|
+
return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
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);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// GAME PARTS: /.well-known/homie-parts.json, /parts/<id>/<version>/… and the studio's own parts pages.
|
|
96
|
+
if (isPartsPath(path)) {
|
|
97
|
+
const shared = await partsRoutes(request, env, url, { catalogueOf: getCat });
|
|
98
|
+
if (shared) return shared;
|
|
99
|
+
}
|
|
100
|
+
if (path === '/.well-known/homie-studio.json') {
|
|
101
|
+
const cat = await getCat();
|
|
102
|
+
const canonical = cat.studio?.canonical;
|
|
103
|
+
if (canonical && env.HOMIE_PREVIEW !== '1' && url.protocol === 'https:') {
|
|
104
|
+
const address = new URL(canonical); url.protocol = address.protocol; url.host = address.host;
|
|
105
|
+
}
|
|
106
|
+
const sharing = Boolean(cat.studio?.stats?.share);
|
|
107
|
+
const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
|
|
108
|
+
// studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
|
|
109
|
+
const shareRooms = cat.studio?.rooms?.share !== false;
|
|
110
|
+
return json({
|
|
111
|
+
v: 1,
|
|
112
|
+
kind: 'homie-studio',
|
|
113
|
+
toolkit: STUDIO_BUILD,
|
|
114
|
+
name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
|
|
115
|
+
slug: cat.studio?.slug ?? null,
|
|
116
|
+
...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
|
|
117
|
+
site: url.origin,
|
|
118
|
+
studio: cat.studio?.version ?? null,
|
|
119
|
+
claim: await claimOf(env, cat, url),
|
|
120
|
+
// What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
|
|
121
|
+
// its mark is here). A Preview says it is one.
|
|
122
|
+
...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
|
|
123
|
+
// Only the public games (a private or invite-only game leaves the directory the next time it reads this).
|
|
124
|
+
games: await Promise.all((cat.games ?? []).filter((g) => g.kind !== 'app').map(async (g) => {
|
|
125
|
+
// The card picture is the landing's hero still (what the landing leads with), else the game's cover.
|
|
126
|
+
const cover = gameCover(g);
|
|
127
|
+
// The licence the game names for itself (an SPDX id), when it names one (worker/license.mjs). Nothing here
|
|
128
|
+
// offers the game to be taken whole: `remix`, `source` and `remixOf` left this manifest with remix.
|
|
129
|
+
const license = licenseOf(g.license);
|
|
130
|
+
return {
|
|
131
|
+
id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
|
|
132
|
+
page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
|
|
133
|
+
...(license ? { license } : {}),
|
|
134
|
+
// Which build of the game is live: its digest (what `homie-studio build` printed for it, and what
|
|
135
|
+
// site/dist/_site/build.json says) and the bundle its page loads. "Is the live game the one I built?" is
|
|
136
|
+
// this against that, with no file to fetch and no edge cache to wait out.
|
|
137
|
+
...(g.built?.hash ? { build: { hash: g.built.hash, ...(g.built.bundle ? { bundle: `${url.origin}/games/${g.id}/${g.built.bundle}` } : {}) } } : {}),
|
|
138
|
+
// Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
|
|
139
|
+
...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
|
|
140
|
+
};
|
|
141
|
+
})),
|
|
142
|
+
...((cat.games ?? []).some((g) => g.kind === 'app') ? { apps: cat.games.filter((g) => g.kind === 'app').map((g) => ({ id: g.id, kind: 'app', name: g.name, blurb: g.blurb, page: `${url.origin}/${g.id}/`, open: `${url.origin}/${g.id}/open`, wall: `${url.origin}/${g.id}/tv`, surfaces: Object.keys(g.surfaces ?? {}), ...(g.built ? { build: g.built } : {}) })) } : {}),
|
|
143
|
+
songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
|
|
144
|
+
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
|
|
145
|
+
// The studio's posts, for the hub (the full text is in /posts/feed.json).
|
|
146
|
+
posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
|
|
147
|
+
...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
|
|
148
|
+
// The studio's Lounge (0.29.0), for a directory that shows lounges: its page and its public facts.
|
|
149
|
+
...(cat.studio?.lounge ? { lounge: { name: cat.studio.lounge.name, page: `${url.origin}/lounge/`, now: `${url.origin}/lounge/api/now` } } : {}),
|
|
150
|
+
// Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
|
|
151
|
+
...(played ? { played } : {}),
|
|
152
|
+
// The shop: whether it sells and its till, never a key or a sale.
|
|
153
|
+
...(shopOf(cat)?.open ? { shop: { open: true, till: shopOf(cat).till, currency: shopOf(cat).currency, page: `${url.origin}/shop/` } } : {}),
|
|
154
|
+
}, 200, { 'access-control-allow-origin': '*' });
|
|
155
|
+
}
|
|
156
|
+
if (path === '/api/games') {
|
|
157
|
+
const cat = await getCat();
|
|
158
|
+
return json({ studio: cat.studio, games: (cat.games ?? []).map(g => g.room?.host === 'server' && !hostedGame(g.id) ? { ...g, rulesError: RULES_WIRING_MESSAGE } : g), live: (await roomsOf(env, cat.games ?? [])).live });
|
|
159
|
+
}
|
|
160
|
+
if (path === '/api/rooms') {
|
|
161
|
+
if(!read)return json({ok:false,error:'method'},405);
|
|
162
|
+
const cat = await getCat();
|
|
163
|
+
const response = await (await import('./rooms-cache.mjs')).cachedRooms(request,env,ctx,cat,async()=>{
|
|
164
|
+
const {rooms}=await roomsOf(env,cat.games??[]);
|
|
165
|
+
return json({ok:true,playing:rooms.reduce((n,r)=>n+r.players,0),rooms},200,{'access-control-allow-origin':'*','cache-control':'public, max-age=15'});
|
|
166
|
+
});
|
|
167
|
+
return request.method === 'HEAD' ? new Response(null,{status:response.status,headers:response.headers}) : response;
|
|
168
|
+
}
|
|
169
|
+
if (path.startsWith('/media/')) {
|
|
170
|
+
if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
|
|
171
|
+
const key = decodeURIComponent(path.slice('/media/'.length));
|
|
172
|
+
// A player's large saves live under players/ in the same bucket: never served here (saves/SAVES.md).
|
|
173
|
+
if (!key || key.includes('..') || (key.startsWith('players/') || key.startsWith('paid-parts/'))) return new Response('not found', { status: 404 });
|
|
174
|
+
return mediaObject(request, env, key);
|
|
175
|
+
}
|
|
176
|
+
if (path === '/posts/feed.xml' || path === '/posts/feed.json') {
|
|
177
|
+
const cat = await getCat();
|
|
178
|
+
if (!(cat.posts ?? []).length) return notFoundPage('This studio has no posts yet.', cat);
|
|
179
|
+
const posts = await postsOf(env, url.origin);
|
|
180
|
+
return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Search engines and AI agents (0.27.0, worker/discover.mjs): robots.txt, sitemap.xml, llms.txt and llms-full.txt,
|
|
184
|
+
// made from the public catalogue (a private or invite-only game is in none of them); a file of the studio's own in
|
|
185
|
+
// site/public wins.
|
|
186
|
+
if (read && DISCOVERY_FILES.includes(path)) {
|
|
187
|
+
const own = await env.ASSETS.fetch(new Request(`${url.origin}${path}`));
|
|
188
|
+
if (own.ok) return own;
|
|
189
|
+
const cat = await getCat();
|
|
190
|
+
const method = request.method;
|
|
191
|
+
if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' || env.HOMIE_CONCEPT === '1' }), 'text/plain', { method });
|
|
192
|
+
const all = await getAll();
|
|
193
|
+
if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
|
|
194
|
+
const full = path === '/llms-full.txt';
|
|
195
|
+
return discoveryResponse(llmsTxt(cat, url.origin, { directory: directoryOf(cat), full, posts: full ? await postsOf(env, url.origin) : null, all }), 'text/plain', { method });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A page the studio made itself (site/pages) wins at its address.
|
|
199
|
+
if (read && (path.endsWith('/') || !path.includes('.'))) {
|
|
200
|
+
const cat = await getCat();
|
|
201
|
+
const pages = cat.site?.pages ?? [];
|
|
202
|
+
const want = path.endsWith('/') ? path : `${path}/`;
|
|
203
|
+
if (pages.includes(want)) {
|
|
204
|
+
if (want !== path) return Response.redirect(`${url.origin}${want}${url.search}`, 301);
|
|
205
|
+
const res = await env.ASSETS.fetch(new Request(`${url.origin}/_site/pages${want}index.html`));
|
|
206
|
+
if (res.ok) {
|
|
207
|
+
const top = parts[0] ?? '';
|
|
208
|
+
const game = (cat.games ?? []).find((g) => g.id === top);
|
|
209
|
+
await countVisit(request, env, ctx, parts.length === 0 ? 'home' : game && parts.length === 1 ? game.id : parts.join('/').slice(0, 80));
|
|
210
|
+
const active = game ? 'games' : sectionsOf(cat).some((s) => s.key === top) ? top : null;
|
|
211
|
+
const html = await res.text();
|
|
212
|
+
// <!-- homie:schema -->: the structured data the generated page here would carry (Home's, a landing's), else the
|
|
213
|
+
// studio's own Organization (an About page).
|
|
214
|
+
const schema = !/<!--\s*homie:schema\s*-->/.test(html) ? ''
|
|
215
|
+
: want === '/' ? ldScript(homeLd(cat, url.origin))
|
|
216
|
+
: game && parts.length === 1 ? ldScript(landingLd(cat, game, url.origin, { shop: await sellingShop(env, cat) }))
|
|
217
|
+
: ldScript([studioNode(cat, url.origin, { full: true })]);
|
|
218
|
+
return customPage(cat, html, { active, schema, origin: url.origin, playerGame: game && parts.length === 1 ? game : null });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (parts[0] === 'music' || parts[0] === 'videos') {
|
|
224
|
+
const kind = parts[0];
|
|
225
|
+
const cat = await getCat();
|
|
226
|
+
const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
|
|
227
|
+
// A studio with no songs has no Music: the tab is gone and the page is not found (the same for videos).
|
|
228
|
+
if (!list.length) return notFoundPage(kind === 'music' ? 'This studio has no music yet.' : 'This studio has no videos yet.', cat);
|
|
229
|
+
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
|
|
230
|
+
if (parts.length === 1) { await countVisit(request, env, ctx, kind); return mediaIndexPage(cat, kind, { origin: url.origin }); }
|
|
231
|
+
const slug = parts[1];
|
|
232
|
+
const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
|
|
233
|
+
if (parts.length === 2) {
|
|
234
|
+
if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`, cat);
|
|
235
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
|
|
236
|
+
await countVisit(request, env, ctx, `${kind}/${slug}`);
|
|
237
|
+
return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
|
|
238
|
+
}
|
|
239
|
+
if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.', cat);
|
|
240
|
+
const file = await assetWithRange(request, env, url);
|
|
241
|
+
if (file.status === 404) {
|
|
242
|
+
// Not in the site's files: a song's or video's file that lives in the studio's R2 (media move), same address.
|
|
243
|
+
const held = await heldInR2(env, getCat, path);
|
|
244
|
+
if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
|
|
245
|
+
return notFoundPage('Nothing here.', cat);
|
|
246
|
+
}
|
|
247
|
+
return file;
|
|
248
|
+
}
|
|
249
|
+
if (path === '/' || path === '/index.html') {
|
|
250
|
+
const cat = await getCat();
|
|
251
|
+
await countVisit(request, env, ctx, 'home');
|
|
252
|
+
shareDaily(cat, url, ctx);
|
|
253
|
+
const { rooms, live } = await roomsOf(env, cat.games ?? []);
|
|
254
|
+
return homePage(cat, { origin: url.origin, rooms, live });
|
|
255
|
+
}
|
|
256
|
+
if (['games', 'apps', 'rooms', 'posts'].includes(parts[0]) && parts.length === 1) {
|
|
257
|
+
const cat = await getCat();
|
|
258
|
+
const has = sectionsOf(cat).some((s) => s.key === parts[0]);
|
|
259
|
+
if (!has) return notFoundPage(parts[0] === 'posts' ? 'This studio has no posts yet.' : 'This studio has no games yet.', cat);
|
|
260
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${parts[0]}/`, 301);
|
|
261
|
+
await countVisit(request, env, ctx, parts[0]);
|
|
262
|
+
if (parts[0] === 'posts') return postsPage(cat, { origin: url.origin });
|
|
263
|
+
const { rooms, live } = await roomsOf(env, cat.games ?? []);
|
|
264
|
+
return ['games', 'apps'].includes(parts[0]) ? gamesPage(cat, { origin: url.origin, live, kind: parts[0] === 'apps' ? 'app' : 'game' }) : roomsPage(cat, { origin: url.origin, rooms });
|
|
265
|
+
}
|
|
266
|
+
if (parts[0] === 'posts' && parts.length === 2 && POST_SLUG.test(parts[1])) {
|
|
267
|
+
const cat = await getCat();
|
|
268
|
+
const posts = (cat.posts ?? []).length ? await postsOf(env, url.origin) : [];
|
|
269
|
+
const post = posts.find((p) => p.slug === parts[1]);
|
|
270
|
+
if (!post) return notFoundPage(`No post called "${parts[1]}" here.`, cat);
|
|
271
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/posts/${post.slug}/`, 301);
|
|
272
|
+
await countVisit(request, env, ctx, `posts/${post.slug}`);
|
|
273
|
+
return postPage(cat, post, { origin: url.origin });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if(parts[0]==='games' && parts.length>2) {
|
|
277
|
+
const all=await getAll(),app=(all.games??[]).find(g=>g.id===parts[1]&&g.kind==='app');
|
|
278
|
+
if(app && launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return json({ok:false,error:'not-found'},404);
|
|
279
|
+
}
|
|
280
|
+
const game = parts[0];
|
|
281
|
+
if (game && GAME_ID.test(game)) {
|
|
282
|
+
const all = await getAll();
|
|
283
|
+
const meta = (all.games ?? []).find((g) => g.id === game);
|
|
284
|
+
if (!meta) return fallback(request, env, url, getCat);
|
|
285
|
+
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
286
|
+
let sub = parts.slice(1).join('/');
|
|
287
|
+
// Game bundles were already public assets; only the document carries access context.
|
|
288
|
+
// Apps retain their session/role checks even for individual asset files.
|
|
289
|
+
if(read&&meta.kind!=='app'&&sub.startsWith('__game/')&&!['__game/','__game/index.html'].includes(sub))return gameAsset(request,env,url,game,sub.slice('__game/'.length));
|
|
290
|
+
const cat = await getCat();
|
|
291
|
+
if (meta.kind === 'app' && /^open(?:\/(embed|preview))?$/.test(sub)) sub = sub.replace(/^open/, 'play');
|
|
292
|
+
if (sub === '__restart') {
|
|
293
|
+
if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
|
|
294
|
+
const room = url.searchParams.get('room') ?? '';
|
|
295
|
+
if (!ROOM_ID.test(room)) return new Response('invalid room', { status: 400 });
|
|
296
|
+
return env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__restart?game=${game}&room=${room}`, { method: 'POST' });
|
|
297
|
+
}
|
|
298
|
+
// Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
|
|
299
|
+
const settings = await settingsOf(env, { fresh: true });
|
|
300
|
+
// The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
|
|
301
|
+
// with access, and the owner may have made its rooms smaller than the game's own seats.
|
|
302
|
+
const launch = launchOf(meta, settings, env);
|
|
303
|
+
const max = seatsFor(meta, settings);
|
|
304
|
+
if (meta.kind === 'app') {
|
|
305
|
+
const asset=/^__assets\/([^/]+)\/(.+)$/.exec(sub);
|
|
306
|
+
if(asset) {
|
|
307
|
+
if(!(await appAssetTicket(env,game,asset[1],{request,meta,launch})))return json({ok:false,error:'not-found'},404);
|
|
308
|
+
const response=asset[2]==='index.html'?await gameDocument(request,env,url,game,meta,cat):await gameAsset(request,env,url,game,asset[2]);
|
|
309
|
+
response.headers.set('cache-control','private, no-store');
|
|
310
|
+
response.headers.set('referrer-policy','no-referrer');
|
|
311
|
+
return response;
|
|
312
|
+
}
|
|
313
|
+
const frameTicket = ['__game','__game/','__game/index.html','__net','__watch'].includes(sub) && await ticketAllows(env, await ticketSub(env,game,url.searchParams.get('t')), launch);
|
|
314
|
+
if (launch !== 'public' && !frameTicket && !(await accessOf(request, env, game, launch)).ok && (await (await import('./local-test.mjs')).localSession(request,env))?.app!==game) return json({ ok: false, error: 'not-found' }, 404);
|
|
315
|
+
if(launch==='public') {
|
|
316
|
+
const web=await (await import('./app-web.mjs')).appWeb(request,env,meta,url,sub,()=>gameDocument(request,env,url,game,meta,cat));
|
|
317
|
+
if(web)return web;
|
|
318
|
+
}
|
|
319
|
+
const apiSession=async response=>{
|
|
320
|
+
const access=await appAccess(request,env,meta,url);
|
|
321
|
+
return access.ok?appRoleSession(response,request,env,meta,launch):response;
|
|
322
|
+
};
|
|
323
|
+
if(sub==='api/app/live') {
|
|
324
|
+
if(request.method!=='GET')return new Response('GET required',{status:405});
|
|
325
|
+
const {appHub}=await import('./app-streams.mjs');
|
|
326
|
+
return apiSession(await appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'open',meta,url:request.url,headers:Object.fromEntries(request.headers),collections:url.searchParams.getAll('collection')})}));
|
|
327
|
+
}
|
|
328
|
+
if(sub==='api/app/tools')return apiSession(await (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools()));
|
|
329
|
+
const api = await appRecordsRoute(request, env, meta, url, sub);
|
|
330
|
+
if (api) return sub.startsWith('api/app/records/')?apiSession(api):api;
|
|
331
|
+
if (['play', 'tv', 'watch'].includes(sub)) {
|
|
332
|
+
let a;
|
|
333
|
+
try { a = await appAccess(request, env, meta, url); } catch { return json({ ok: false, error: 'app roles unavailable; apply studio migrations' }, 503); }
|
|
334
|
+
if (!a.ok) {
|
|
335
|
+
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
336
|
+
return json({ ok: false, error: a.error }, a.status);
|
|
337
|
+
}
|
|
338
|
+
if(!meta.room)return appRoleSession((await import('./records-page.mjs')).recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game,undefined,{request,meta,launch}):null),request,env,meta,launch);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
342
|
+
if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
|
|
343
|
+
|
|
344
|
+
// SERVERS (worker/servers.mjs, NETPLAY.md section 17): which server a request is for — its path (/s/<id>/), its
|
|
345
|
+
// 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
|
|
346
|
+
// home server, else public. Every door follows the game's launch state first, as it always did.
|
|
347
|
+
const servers = await serversOf(env, meta);
|
|
348
|
+
const serverById = (id) => servers.find((x) => x.id === id) ?? null;
|
|
349
|
+
const pathServer = /^s\/([a-z0-9][a-z0-9-]{1,19})(?:\/(play|tv|home))?\/?$/.exec(sub);
|
|
350
|
+
if (/^s(?:\/|$)/.test(sub) && !pathServer) return notFoundPage(`Nothing here in ${meta.name}.`, cat);
|
|
351
|
+
if (pathServer && (!serverById(pathServer[1]) || pathServer[1] === 'public' || serverById(pathServer[1]).state === 'archived')) return notFoundPage(`${meta.name} has no server called "${pathServer[1]}".`, cat);
|
|
352
|
+
if (pathServer && !pathServer[2] && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/s/${pathServer[1]}/${url.search}`, 301);
|
|
353
|
+
const policyFor = (srv, named = false) => policyOf(srv, { seats: max, named });
|
|
354
|
+
const humanSeats = (pol) => Math.max(1, max - (pol.aiSeats + pol.guides));
|
|
355
|
+
const askedRoom = url.searchParams.get('room');
|
|
356
|
+
if (pathServer) sub = pathServer[2] === 'play' ? 'play' : pathServer[2] === 'tv' ? 'tv' : pathServer[2] === 'home' ? 's-home' : 's-page';
|
|
357
|
+
|
|
358
|
+
const doorSubs = ['', 'live', 'credits', 'tv', 'play', 'watch', 'api/lobby', 'api/watch', 'servers', 'api/servers', 's-page', 's-home'];
|
|
359
|
+
const door = launch !== 'public' && doorSubs.includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
|
|
360
|
+
const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
|
|
361
|
+
/** A page's own door to a server: who this browser is (owner, invited, signed in) and whether it may come in. */
|
|
362
|
+
const pageDoor = async (srv, { watching = false } = {}) => {
|
|
363
|
+
const owner = door.owner || (launch === 'public' && await isOwner(request, env));
|
|
364
|
+
const acct = await accountSub(request, env);
|
|
365
|
+
const sPass = srv && srv.door === 'invite' && !owner ? await serverPassOf(request, env, game, srv.id, cookieValues) : null;
|
|
366
|
+
const list = [owner ? 'o' : null, door.sub, sPass ? `i-${sPass.invite}` : null, acct].filter(Boolean).flatMap(holders);
|
|
367
|
+
const access = srv ? await serverAccess(env, { game, server: srv, holders: list, watching }) : { ok: true };
|
|
368
|
+
return { ...access, owner, acct, holder: joinHolders(door.sub ?? (owner ? 'o' : null), sPass ? `i-${sPass.invite}` : null, acct) };
|
|
369
|
+
};
|
|
370
|
+
/** The public server is hidden (closed by its owner): Play shows the other servers instead, and named rooms are off. */
|
|
371
|
+
const publicHidden = () => (serverById('public') ?? PUBLIC_SERVER).state !== 'open';
|
|
372
|
+
|
|
373
|
+
if (sub === '') {
|
|
374
|
+
if (!door.ok) return shut();
|
|
375
|
+
await countVisit(request, env, ctx, game);
|
|
376
|
+
shareDaily(cat, url, ctx);
|
|
377
|
+
const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
378
|
+
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
379
|
+
const band = servers.some((x) => x.id !== 'public' && x.listed && x.state === 'open') ? await serverLive(env, meta, servers, url.origin, lobby) : null;
|
|
380
|
+
// GAME PARTS: the landing's "Parts from this game" band (shared parts only).
|
|
381
|
+
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);
|
|
382
|
+
}
|
|
383
|
+
if (sub === 'live') {
|
|
384
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
385
|
+
const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
386
|
+
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
387
|
+
// The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
|
|
388
|
+
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' });
|
|
389
|
+
}
|
|
390
|
+
if (sub === 'credits') {
|
|
391
|
+
if (!door.ok) return shut();
|
|
392
|
+
let texts = [];
|
|
393
|
+
try { const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/_landing/credits.json`)); if (res.ok) texts = (await res.json()).texts ?? []; } catch { texts = []; }
|
|
394
|
+
if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
|
|
395
|
+
return creditsPage(cat, meta, texts, { origin: url.origin });
|
|
396
|
+
}
|
|
397
|
+
if (sub === 'servers') {
|
|
398
|
+
if (!door.ok) return shut();
|
|
399
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/servers/`, 301);
|
|
400
|
+
await countVisit(request, env, ctx, `${game}/servers`);
|
|
401
|
+
return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: publicHidden() });
|
|
402
|
+
}
|
|
403
|
+
if (sub === 'api/servers') {
|
|
404
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
405
|
+
const list = (await serverLive(env, meta, servers, url.origin, lobby)).filter((x) => x.id === 'public' || (x.listed && x.state === 'open'));
|
|
406
|
+
return json({ ok: true, game, servers: list }, 200, { 'cache-control': 'public, max-age=15' });
|
|
407
|
+
}
|
|
408
|
+
if (sub === 's-page') {
|
|
409
|
+
if (!door.ok) return shut();
|
|
410
|
+
const srv = serverById(pathServer[1]);
|
|
411
|
+
const d = await pageDoor(srv);
|
|
412
|
+
const pid = d.acct?.startsWith('p-') ? d.acct.slice(2) : null;
|
|
413
|
+
const member = pid ? await memberOf(env, game, srv.id, pid) : null;
|
|
414
|
+
await countVisit(request, env, ctx, `${game}/s/${srv.id}`);
|
|
415
|
+
const [row] = await serverLive(env, meta, [srv], url.origin, lobby);
|
|
416
|
+
const { rooms } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
417
|
+
return serverPage(cat, meta, row, { origin: url.origin, rooms: rooms.filter((r) => r.server?.id === srv.id), door: d, member, signedIn: Boolean(pid) });
|
|
418
|
+
}
|
|
419
|
+
if (sub === 's-home') {
|
|
420
|
+
// Join this server, leave it, or make it home: the signed-in player's own choice (a same-origin JSON POST).
|
|
421
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin' }, 403);
|
|
422
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
423
|
+
const srv = serverById(pathServer[1]);
|
|
424
|
+
const acct = await accountSub(request, env);
|
|
425
|
+
if (!acct?.startsWith('p-')) return json({ ok: false, error: 'sign-in', message: 'Sign in to the studio first (your account page).' }, 401);
|
|
426
|
+
let body = {};
|
|
427
|
+
try { body = JSON.parse((await request.text()) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
|
|
428
|
+
const d = await pageDoor(srv);
|
|
429
|
+
if (body.join !== false && !d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
|
|
430
|
+
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); }
|
|
431
|
+
}
|
|
432
|
+
if (sub === 'play/preview' && env.HOMIE_EMBED_PREVIEW === '1' && isLocalOrigin(url.origin)) return (await import('./pages.mjs')).embedPreview(cat, meta, url.origin);
|
|
433
|
+
if (sub === 'tv' || sub === 'play' || sub === 'play/embed') {
|
|
434
|
+
const embed = sub === 'play/embed';
|
|
435
|
+
if (embed && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage(meta.kind === 'app' ? 'This app does not open inside a post. Open its studio page.' : 'This game does not play inside a post. Open its studio page to play.', cat);
|
|
436
|
+
const screen = !embed && (sub === 'tv' || url.searchParams.get('screen') === '1');
|
|
437
|
+
const asked = embed ? null : askedRoom;
|
|
438
|
+
if (asked !== null && !ROOM_ID.test(asked)) return (await import('./pages.mjs')).badRoomPage(cat, meta, asked, { screen });
|
|
439
|
+
if (!door.ok) return shut();
|
|
440
|
+
// Which server: the path's, the room's, else (Play) the player's home server, else public.
|
|
441
|
+
let srv = pathServer ? serverById(pathServer[1]) : asked !== null ? serverById(roomServer(asked)) : null;
|
|
442
|
+
if (asked !== null && !srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
|
|
443
|
+
const named = asked !== null && !pooledRoom(asked);
|
|
444
|
+
if (!srv) {
|
|
445
|
+
const acct = embed ? null : await accountSub(request, env);
|
|
446
|
+
const home = acct?.startsWith('p-') ? await homeOf(env, game, acct.slice(2)) : null;
|
|
447
|
+
const h = home ? serverById(home) : null;
|
|
448
|
+
srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
|
|
449
|
+
}
|
|
450
|
+
const d = await pageDoor(srv);
|
|
451
|
+
if (meta.room?.characters && !screen && !embed && (!d.acct || !(await chatWho(env, game, srv, d.acct)).acct)) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 303);
|
|
452
|
+
// The owner hid the public server: Play shows the servers to pick from, and a named room is off (owner aside).
|
|
453
|
+
if (srv.id === 'public' && publicHidden() && !d.owner) {
|
|
454
|
+
if (named) return doorPage(cat, meta, srv, { why: 'closed' });
|
|
455
|
+
return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: true, pick: true });
|
|
456
|
+
}
|
|
457
|
+
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') ?? '' });
|
|
458
|
+
let recordsGuest=null;
|
|
459
|
+
if(meta.room?.records&&!embed&&!screen&&!d.acct&&env.DB){
|
|
460
|
+
const made=await playerAccounts.recordsGuest(request,env,url);if(made.error)return made.error;
|
|
461
|
+
recordsGuest=made.session;d.acct='p-'+recordsGuest.player.id;d.holder=joinHolders(d.holder,d.acct);
|
|
462
|
+
}
|
|
463
|
+
if (d.acct?.startsWith('p-') && srv.id !== 'public') ctx?.waitUntil?.(noteMember(env, game, srv.id, d.acct.slice(2)));
|
|
464
|
+
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
465
|
+
const pol = policyFor(srv, named);
|
|
466
|
+
const server = srv.id === 'public' && !pol.server ? null : { ...serverView(srv, { origin: url.origin, game }), mentor: Boolean(d.mentor) };
|
|
467
|
+
if (screen) {
|
|
468
|
+
await countVisit(request, env, ctx, game, 'screen');
|
|
469
|
+
// The big screen picks its room now, so the QR it shows puts every phone in that same room.
|
|
470
|
+
let room = asked;
|
|
471
|
+
if (!room) {
|
|
472
|
+
try { room = (await (await lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&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; }
|
|
473
|
+
}
|
|
474
|
+
const joinUrl = `${url.origin}${openPath(meta)}${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
475
|
+
// Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
|
|
476
|
+
const local = isLocalOrigin(url.origin);
|
|
477
|
+
let qr = null;
|
|
478
|
+
if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
479
|
+
// Television checkout and kids-server visibility follow the studio shop policy.
|
|
480
|
+
const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
|
|
481
|
+
let shopQr = null;
|
|
482
|
+
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
483
|
+
return appRoleSession((await import('./pages.mjs')).playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null }),request,env,meta,launch);
|
|
484
|
+
}
|
|
485
|
+
await countVisit(request, env, ctx, game, 'play');
|
|
486
|
+
shareDaily(cat, url, ctx);
|
|
487
|
+
const page=await appRoleSession((await import('./pages.mjs')).playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) }),request,env,meta,launch);
|
|
488
|
+
for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
|
|
489
|
+
}
|
|
490
|
+
if (sub === 'watch') {
|
|
491
|
+
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
492
|
+
const asked = askedRoom;
|
|
493
|
+
if (asked !== null && !ROOM_ID.test(asked)) return (await import('./pages.mjs')).badRoomPage(cat, meta, asked, { watch: true });
|
|
494
|
+
if (!door.ok) return shut();
|
|
495
|
+
const policy = watchOf(meta);
|
|
496
|
+
if (policy === 'off') return (await import('./pages.mjs')).noWatchPage(cat, meta);
|
|
497
|
+
// A server's door is its watch door too (an invite or an account); a beginner server is watched by anyone.
|
|
498
|
+
const srv = asked !== null ? serverById(roomServer(asked)) : serverById('public') ?? PUBLIC_SERVER;
|
|
499
|
+
if (!srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
|
|
500
|
+
const d = await pageDoor(srv, { watching: true });
|
|
501
|
+
if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, origin: url.origin, next: `${url.pathname}${url.search}`, watch: true });
|
|
502
|
+
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
503
|
+
await countVisit(request, env, ctx, game, 'watch');
|
|
504
|
+
shareDaily(cat, url, ctx);
|
|
505
|
+
return (await import('./pages.mjs')).watchPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
|
|
506
|
+
}
|
|
507
|
+
if (sub === 'api/watch') {
|
|
508
|
+
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
509
|
+
// The busiest room now, to watch, of a server anyone may watch; nothing is reserved (a watcher takes no seat).
|
|
510
|
+
const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
|
|
511
|
+
const pools = servers.filter((x) => x.state === 'open' && x.door === 'open').map((x) => x.id).join(',');
|
|
512
|
+
let best = null;
|
|
513
|
+
const wver = versionOf(meta?.netplay?.version);
|
|
514
|
+
try { best = await (await lobby().fetch(`https://lobby/busiest?pools=${encodeURIComponent(pools)}${not ? `¬=${encodeURIComponent(not)}` : ''}${wver ? `&ver=${encodeURIComponent(wver)}` : ''}`)).json(); } catch { best = null; }
|
|
515
|
+
return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
|
|
516
|
+
}
|
|
517
|
+
if (sub === 'api/lobby') {
|
|
518
|
+
// A standalone copy's page reads this answer from another origin: the Worker's own `fetch` adds its header to
|
|
519
|
+
// every answer of this address (worker/standalone.mjs), whichever line here made it.
|
|
520
|
+
const app = isAppOrigin(request);
|
|
521
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
522
|
+
// ?server=<id>: a room of that server's pool (its door first); none: public. Strangers never meet across servers.
|
|
523
|
+
const sid = url.searchParams.get('server') ?? 'public';
|
|
524
|
+
// An app plays on the public server only: it has no account and no invite to show a server's door.
|
|
525
|
+
if (app && sid !== 'public') return json({ ok: false, error: 'app-public', message: 'A standalone copy plays on the public server.' }, 403);
|
|
526
|
+
const srv = SERVER_ID.test(sid) || sid === 'public' ? serverById(sid) : null;
|
|
527
|
+
if (!srv) return json({ ok: false, error: 'no-server', message: `${meta.name} has no server called ${String(sid).slice(0, 24)}.` }, 404);
|
|
528
|
+
if (srv.id === 'public' && publicHidden() && !(await pageDoor(srv)).owner) return json({ ok: false, error: 'closed', message: 'Pick a server to play on.' }, 403);
|
|
529
|
+
const d = await pageDoor(srv);
|
|
530
|
+
if (!d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
|
|
531
|
+
// A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
|
|
532
|
+
const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
|
|
533
|
+
const pol = policyFor(srv);
|
|
534
|
+
// Strangers on different builds of the game never meet (section 23): the Lobby matches within the live build.
|
|
535
|
+
// A standalone copy is the build it was made from, whatever is live now: it says its own (`gv`, or none for a
|
|
536
|
+
// game that named none then) and is matched with copies of that build. What it says is its own word, so the
|
|
537
|
+
// Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
|
|
538
|
+
const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
|
|
539
|
+
if (app && meta?.room?.host === 'server' && ver !== meta.room.build) return json({ ok: false, error: 'stale', message: 'Update the app to play online.' }, 409);
|
|
540
|
+
if (meta.room?.lifetime === 'persistent') {
|
|
541
|
+
const room = srv.id === 'public' ? 'pub-1' : `s-${srv.id}-1`;
|
|
542
|
+
if (not.split(',').includes(room)) return json({ ok: false, error: 'world-unavailable', message: 'This world is unavailable. Choose another named world.' }, 409);
|
|
543
|
+
return json({ room, players: 0, max: humanSeats(pol), server: srv.id });
|
|
544
|
+
}
|
|
545
|
+
return lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `¬=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
|
|
546
|
+
}
|
|
547
|
+
if (sub === 'api/agent') {
|
|
548
|
+
// An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
|
|
549
|
+
return sitRoute(request, env, url, {
|
|
550
|
+
meta, launch,
|
|
551
|
+
serverOf: (id) => serverById(id),
|
|
552
|
+
policyOf: (srv) => policyFor(srv),
|
|
553
|
+
ticketFor: (holder) => ticketFor(env, game, holder),
|
|
554
|
+
join: async (srv, pol) => {
|
|
555
|
+
try {
|
|
556
|
+
const reserve = pol.aiSeats + pol.guides;
|
|
557
|
+
const r = await (await lobby().fetch(`https://lobby/join?host=${meta?.room?.host === 'server' ? 'server' : 'browser'}&max=${max}&server=${srv.id}&agent=1&ai=${reserve}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json();
|
|
558
|
+
return r.room ?? null;
|
|
559
|
+
} catch { return null; }
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (sub === 'api/chat/report') {
|
|
564
|
+
// A player reports one chat line (section 19): the room's own copy of it is what is kept, never the reporter's words.
|
|
565
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'a report comes from this site\'s own pages' }, 403);
|
|
566
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
567
|
+
let body = {};
|
|
568
|
+
try { body = JSON.parse((await request.text()).slice(0, 2048) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
|
|
569
|
+
const room = String(body.room ?? '');
|
|
570
|
+
const id = String(body.id ?? '');
|
|
571
|
+
if (!ROOM_ID.test(room) || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return json({ ok: false, error: 'bad-request', message: 'room and id name the message' }, 400);
|
|
572
|
+
const reason = REPORT_REASONS.includes(body.reason) ? body.reason : 'other';
|
|
573
|
+
const key = `${BROWSER_KEY.test(String(body.b ?? '')) ? body.b : ''}|${request.headers.get('cf-connecting-ip') ?? ''}`;
|
|
574
|
+
if (!reportAllowed(key)) return json({ ok: false, error: 'rate', message: 'That is a lot of reports: the studio has them. Try again in a few minutes.' }, 429);
|
|
575
|
+
let line = null;
|
|
576
|
+
try { line = (await (await env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__chat?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}&op=line&id=${encodeURIComponent(id)}`)).json()).line ?? null; } catch { line = null; }
|
|
577
|
+
if (!line) return json({ ok: false, error: 'gone', message: 'That message is no longer in the room.' }, 404);
|
|
578
|
+
if (line.by === 'studio') return json({ ok: false, error: 'studio', message: 'That is the studio\'s own line.' }, 400);
|
|
579
|
+
try { await fileReport(env, { game, room, line, reason }); } catch (error) {
|
|
580
|
+
return json({ ok: false, error: 'not-migrated', message: `Reports need migration 0007_studio_chat.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
|
|
581
|
+
}
|
|
582
|
+
return json({ ok: true, message: 'Thanks for telling the studio. They will look at it.' });
|
|
583
|
+
}
|
|
584
|
+
if (sub === '__net' || sub === '__watch') {
|
|
585
|
+
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
586
|
+
const room = url.searchParams.get('room') || 'main';
|
|
587
|
+
if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
|
|
588
|
+
// Who the ticket names: the door of a game that is not public, and in any game a signed-in player's account.
|
|
589
|
+
const who = url.searchParams.get('t') ? await ticketSub(env, game, url.searchParams.get('t')) : null;
|
|
590
|
+
if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('this game is not open to you', { status: 403 });
|
|
591
|
+
const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : '';
|
|
592
|
+
// A watcher's socket (section 16): what the game lets watchers see goes with every socket, so a helper that only
|
|
593
|
+
// says `watch` in its hello is held to it too; a game that cannot be watched refuses a watch door's socket here.
|
|
594
|
+
const policy = watchOf(meta);
|
|
595
|
+
const w = sub === '__net' && url.searchParams.get('w') === '1';
|
|
596
|
+
if (w && policy === 'off') return new Response('this game cannot be watched', { status: 403 });
|
|
597
|
+
// The room's server (section 17): its door for a person, and the policy the room applies. An AI's ticket
|
|
598
|
+
// (a-<pass>) is checked against its pass and the server: a humans-only server refuses it here and again at the relay.
|
|
599
|
+
const srv = serverById(roomServer(room));
|
|
600
|
+
if (!srv) return new Response('this server does not exist', { status: 404 });
|
|
601
|
+
const parts = holders(who);
|
|
602
|
+
const agentPart = parts.find((x) => x.startsWith('a-'));
|
|
603
|
+
const pol = policyFor(srv, !pooledRoom(room));
|
|
604
|
+
let ag = null;
|
|
605
|
+
if (agentPart && sub === '__net') {
|
|
606
|
+
const pass = await passById(env, agentPart.slice(2));
|
|
607
|
+
const why = passRefusal(pass, { game, server: srv.id, policy: pol, launch });
|
|
608
|
+
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' } });
|
|
609
|
+
ag = { ...agentFacts(pass), name: aiName(pass.label) };
|
|
610
|
+
} else if (srv.state !== 'open' || srv.door !== 'open' || (srv.policy === 'beginner' && !w && sub === '__net')) {
|
|
611
|
+
const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
|
|
612
|
+
if (!access.ok) return new Response('this server is not open to you', { status: 403 });
|
|
613
|
+
}
|
|
614
|
+
// Room chat (section 19): the room's rules, and who this socket is to them: an account with a passkey (not a
|
|
615
|
+
// guest), a member of this server, or another site's page watching from elsewhere (homie.rocks's room page).
|
|
616
|
+
const chat = chatOf(meta, srv, await chatRowsOf(env));
|
|
617
|
+
const { acct, member } = await chatWho(env, game, srv, who);
|
|
618
|
+
const origin = request.headers.get('origin');
|
|
619
|
+
const hub = sub === '__watch' && Boolean(origin) && origin !== 'null' && origin !== url.origin;
|
|
620
|
+
const lean = { ...chat, emoji: chat.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
|
|
621
|
+
// A supporter's badge (the shop, 0.24.0): what the player's account owns, looked up here, so a hello can never claim
|
|
622
|
+
// one; none on a kids server, none for an AI or a watcher.
|
|
623
|
+
const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
|
|
624
|
+
const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
|
|
625
|
+
const stub = sub === '__net' ? gateFor(env, game, room, max, b) : env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
626
|
+
// Revision 9 (NETPLAY.md sections 22 and 23): the build that is live now (`cur`, always said, empty when the game
|
|
627
|
+
// names none), the build this socket's page was served with (`gv`, from the socket's own address), and the game's
|
|
628
|
+
// own stall time.
|
|
629
|
+
const cur = versionOf(meta?.netplay?.version) ?? '';
|
|
630
|
+
const gv = versionOf(url.searchParams.get('gv')) ?? '';
|
|
631
|
+
const stall = Number(meta?.netplay?.stallMs) || 0;
|
|
632
|
+
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}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
|
|
633
|
+
// The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
|
|
634
|
+
// tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
|
|
635
|
+
// uncaught error with nothing on it.
|
|
636
|
+
try { return await stub.fetch(new Request(target, request)); } catch (error) {
|
|
637
|
+
const gone = departure(error);
|
|
638
|
+
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 */ }
|
|
639
|
+
return new Response(gone ? 'gone' : 'the room did not answer; try again', { status: gone ? 499 : 503 });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
643
|
+
if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
|
|
644
|
+
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
|
|
645
|
+
if (url.searchParams.get('embed') === '1' && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('Embedding is off for this game.', cat);
|
|
646
|
+
const who = await ticketSub(env, game, url.searchParams.get('t'));
|
|
647
|
+
// A play page left open across a deploy (0.46.5). One that names what it was made for (`sh`) and is behind is
|
|
648
|
+
// told so, and reloads itself whole, once, to the same room and seat. One from before that handshake is sent
|
|
649
|
+
// to a fresh page only when this game cannot work inside it: the game keeps verified results for a player, and
|
|
650
|
+
// the old page has no player ticket to give its frame (a result would have nobody to be written for).
|
|
651
|
+
if (url.searchParams.get('pf') === '1' && url.searchParams.get('embed') !== '1' && url.searchParams.get('want') !== 'screen' && !url.searchParams.has('watch')) {
|
|
652
|
+
const said = url.searchParams.get('sh'), now = shellKey(meta);
|
|
653
|
+
const ticketless = Boolean(meta.room?.records && env.DB) && !holders(who).some((x) => x.startsWith('p-'));
|
|
654
|
+
if (said !== null ? said !== now : ticketless) {
|
|
655
|
+
return new Response(shellStandIn(url.searchParams.get('room') ?? '', said !== null ? now : null), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
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' } });
|
|
659
|
+
// An agent's frame (hands `self`): the game plays as that AI.
|
|
660
|
+
const agentPart = holders(who).find((x) => x.startsWith('a-'));
|
|
661
|
+
const pass = agentPart ? await passById(env, agentPart.slice(2)) : null;
|
|
662
|
+
return gameDocument(request, env, url, game, meta, cat, { agent: pass?.live ? { name: aiName(pass.label), hands: pass.hands, role: pass.role } : null });
|
|
663
|
+
}
|
|
664
|
+
if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
|
|
665
|
+
}
|
|
666
|
+
return fallback(request, env, url, getCat);
|
|
667
|
+
}
|