@homie-rocks/studio 0.30.2 → 0.31.1
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 +108 -1
- package/README.md +349 -23
- 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 +204 -124
- 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 +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- 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 +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -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 +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -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 +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- 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 +50 -11
- 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 +50 -11
- 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 +156 -29
- package/worker/parts.mjs +275 -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/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 });
|
package/worker/license.mjs
CHANGED
|
@@ -1,46 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A GAME'S
|
|
3
|
-
*
|
|
2
|
+
* A GAME'S OWN LICENCE AND THE CREDIT IT OWES, shared by the site Worker (worker/site.mjs, worker/schema.mjs,
|
|
3
|
+
* worker/discover.mjs) and the CLI (lib/build.mjs).
|
|
4
4
|
*
|
|
5
|
-
* The licence is the owner
|
|
6
|
-
* (
|
|
5
|
+
* The licence is a statement of rights the owner may make about the game: game.json `"license"` names an SPDX
|
|
6
|
+
* identifier ("MIT", "CC-BY-4.0"), as the word or as { "spdx": "<id>" }. The game's page, its structured data and
|
|
7
|
+
* /llms.txt say it. A game that names none says nothing: no licence is granted, and no page pretends otherwise.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* REMIX WAS RETIRED. A game used to be handed over whole (its source at /games/<id>/source.json, brought in with
|
|
10
|
+
* `game remix`), and `"license"` also took three words about that: "remix-with-credit", "remix-freely" and
|
|
11
|
+
* "no-remix". Games now build on each other through parts, pieces a studio chooses to share (parts/PARTS.md), each
|
|
12
|
+
* under its own SPDX licence. Nothing here grants or refuses a remix any more:
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* and
|
|
14
|
+
* - the three words, and `"kind"` in the object form, are read as no licence named (an SPDX id beside them stays);
|
|
15
|
+
* - game.json `"remix"`, `"share"` and `landing.make` are ignored; retiredKeys() names the ones a game still
|
|
16
|
+
* carries, so the build can say so once;
|
|
17
|
+
* - `"remixOf"`, which `game remix` wrote into a game it made, is still read: it is a credit that game owes its
|
|
18
|
+
* original, and its landing and credits keep showing it ("Based on <game> by <studio>", basedOnRow()).
|
|
17
19
|
*/
|
|
18
|
-
export const LICENSE_KINDS = Object.freeze({
|
|
19
|
-
'remix-with-credit': 'Remix with credit',
|
|
20
|
-
'remix-freely': 'Remix freely',
|
|
21
|
-
'no-remix': 'Not for remixing',
|
|
22
|
-
});
|
|
23
|
-
export const DEFAULT_LICENSE = 'remix-with-credit';
|
|
24
20
|
const SPDX = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/;
|
|
21
|
+
/** The three words `"license"` took while a game could be handed over whole: none of them is a licence. */
|
|
22
|
+
const RETIRED_KINDS = new Set(['remix-with-credit', 'remix-freely', 'no-remix']);
|
|
25
23
|
|
|
26
|
-
/** {
|
|
24
|
+
/** { spdx } from what game.json (or a built catalogue row) says, or null when it names no licence. */
|
|
27
25
|
export function licenseOf(value) {
|
|
28
|
-
const v = typeof value === 'string'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const kind = Object.hasOwn(LICENSE_KINDS, v.kind) ? v.kind : DEFAULT_LICENSE;
|
|
32
|
-
const spdx = typeof v.spdx === 'string' && SPDX.test(v.spdx.trim()) ? v.spdx.trim() : null;
|
|
33
|
-
return { kind, spdx };
|
|
26
|
+
const v = typeof value === 'string' ? value : value && typeof value === 'object' ? value.spdx : null;
|
|
27
|
+
const spdx = typeof v === 'string' && SPDX.test(v.trim()) && !RETIRED_KINDS.has(v.trim()) ? v.trim() : null;
|
|
28
|
+
return spdx ? { spdx } : null;
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
/** "
|
|
37
|
-
export
|
|
38
|
-
const l = licenseOf(value);
|
|
39
|
-
return `${LICENSE_KINDS[l.kind]}${l.spdx ? ` (${l.spdx})` : ''}`;
|
|
40
|
-
}
|
|
31
|
+
/** "MIT", or null for a game that names no licence. */
|
|
32
|
+
export const licenseLabel = (value) => licenseOf(value)?.spdx ?? null;
|
|
41
33
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The retired settings a game.json still carries, as the words to print: `"remix"`, `"share"`, `landing.make`, and
|
|
36
|
+
* a `"license"` that is one of the three old words (or has a `"kind"`). `"remixOf"` is not one: it is still read.
|
|
37
|
+
*/
|
|
38
|
+
export function retiredKeys(g) {
|
|
39
|
+
const out = [];
|
|
40
|
+
if (g?.remix !== undefined) out.push('"remix"');
|
|
41
|
+
if (g?.share !== undefined) out.push('"share"');
|
|
42
|
+
const lic = g?.license;
|
|
43
|
+
if ((typeof lic === 'string' && RETIRED_KINDS.has(lic.trim())) || (lic && typeof lic === 'object' && lic.kind !== undefined)) out.push(`"license": ${JSON.stringify(typeof lic === 'string' ? lic.trim() : String(lic.kind)).slice(0, 40)}`);
|
|
44
|
+
if (g?.landing && typeof g.landing === 'object' && g.landing.make !== undefined) out.push('landing.make');
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
44
47
|
|
|
45
48
|
const line = (v, max) => (typeof v === 'string' && v.trim() ? v.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, max) : null);
|
|
46
49
|
/** An https address, else null. */
|
|
@@ -48,8 +51,8 @@ export function httpsPage(v) {
|
|
|
48
51
|
try { const u = new URL(String(v ?? '')); return u.protocol === 'https:' ? u.href : null; } catch { return null; }
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
/** A game's page from its source
|
|
52
|
-
|
|
54
|
+
/** A game's page from the address its source once had: https://<site>/games/<id>/source.json is https://<site>/<id>/. */
|
|
55
|
+
function pageOfSource(source) {
|
|
53
56
|
try {
|
|
54
57
|
const u = new URL(String(source ?? ''));
|
|
55
58
|
const m = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(u.pathname);
|
|
@@ -58,17 +61,18 @@ export function pageOfSource(source) {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
64
|
+
* The credit a game owes the game it was made from, as it may be shown: the original's name, studio and page (an
|
|
65
|
+
* https link), plain text only. Read from game.json `"remixOf"` (what `game remix` wrote; one made before 0.14.4
|
|
66
|
+
* has only the original's source address, which names its page too) or from a built catalogue row's `basedOn`.
|
|
63
67
|
*/
|
|
64
|
-
export function
|
|
68
|
+
export function basedOnRow(r) {
|
|
65
69
|
if (!r || typeof r !== 'object') return null;
|
|
66
70
|
const name = line(r.name, 80);
|
|
67
71
|
return name ? { name, studio: line(r.studio, 80), page: httpsPage(r.page) ?? pageOfSource(r.source) } : null;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
/** "
|
|
71
|
-
export function
|
|
72
|
-
const row =
|
|
73
|
-
return row ? `
|
|
74
|
+
/** "Based on Gem Rush by Night Owl Games" (the studio left out when the record named none). */
|
|
75
|
+
export function basedOnCredit(r) {
|
|
76
|
+
const row = basedOnRow(r);
|
|
77
|
+
return row ? `Based on ${row.name}${row.studio ? ` by ${row.studio}` : ''}` : null;
|
|
74
78
|
}
|
package/worker/office-page.mjs
CHANGED
|
@@ -226,16 +226,12 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
226
226
|
b.setAttribute('data-v', p[0]); b.setAttribute('aria-pressed', g.launch === p[0] ? 'true' : 'false');
|
|
227
227
|
st.appendChild(b);
|
|
228
228
|
});
|
|
229
|
-
var remix = el('input', 'switch'); remix.type = 'checkbox'; remix.checked = g.remix; remix.disabled = !g.remixBuilt;
|
|
230
|
-
remix.setAttribute('aria-label', 'Remixable');
|
|
231
|
-
remix.onchange = function () { act('/_studio/api/game', { game: g.id, remix: remix.checked }, remix.checked ? g.name + '\'s source is open for remixing.' : g.name + '\'s source is withdrawn.'); };
|
|
232
229
|
var max = el('input'); max.type = 'number'; max.min = '1'; max.max = String(g.seats); max.value = String(g.maxPlayers); max.setAttribute('aria-label', 'Players per room');
|
|
233
230
|
var setMax = btn('Set', 'ghost small', function () {
|
|
234
231
|
var n = Math.floor(Number(max.value));
|
|
235
232
|
act('/_studio/api/game', { game: g.id, maxPlayers: n >= g.seats ? null : n }, 'Rooms of ' + g.name + ' now hold ' + Math.min(n, g.seats) + '.');
|
|
236
233
|
});
|
|
237
234
|
add(box, st,
|
|
238
|
-
add(el('label'), remix, el('span', '', !g.remixBuilt ? 'Remixable (not in the build)' : g.license === 'no-remix' ? 'Source shared (its licence: not for remixing)' : 'Remixable')),
|
|
239
235
|
add(el('label'), el('span', 'dim', 'Players per room'), max, el('span', 'faint', 'of ' + g.seats), setMax),
|
|
240
236
|
add(el('span', 'faint'), document.createTextNode(g.launchFrom === 'game.json' ? 'state from game.json' : '')));
|
|
241
237
|
return box;
|