@homie-rocks/studio 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -1
- package/README.md +361 -22
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +142 -123
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +15 -5
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +15 -5
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +141 -23
- package/worker/parts.mjs +234 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/worker/seats.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* `maxPlayers` or `players.max`); every room of that game has that many, up to SEAT_MAX (32).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { stallOf, versionOf } from './room.mjs';
|
|
9
|
+
|
|
8
10
|
/** The most seats one room holds (NETPLAY.md §3). */
|
|
9
11
|
export const SEAT_MAX = 32;
|
|
10
12
|
|
|
@@ -18,3 +20,72 @@ export const seatsOf = (meta) => Math.max(1, Math.min(SEAT_MAX, Math.floor(Numbe
|
|
|
18
20
|
* game keeps the old 12.
|
|
19
21
|
*/
|
|
20
22
|
export const perAddress = (seats) => Math.max(12, seats + 4);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `net.prefs` (NETPLAY.md section 24): what the play page keeps for one game on one browser, because the game's frame
|
|
26
|
+
* has no storage of its own. The helper (netplay/netplay.ts PREFS_LIMITS) has the same numbers; a test keeps them equal.
|
|
27
|
+
*/
|
|
28
|
+
export const PREFS_LIMITS = Object.freeze({ bytes: 16_384, keys: 32, key: 64 });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* THE ADDRESS'S SWITCHES (NETPLAY.md section 24). The game runs in a frame that cannot read the play page's address, so
|
|
32
|
+
* the page passes a few of its query parameters in: these always (`?debug`, `?q=low`, and the three the port kit
|
|
33
|
+
* reads), and the names the game declares (game.json `netplay.params`). Never a name the page uses itself (the room,
|
|
34
|
+
* the seat's token, the ticket, the browser key…): a game can never ask to be handed those.
|
|
35
|
+
*/
|
|
36
|
+
export const PLAY_PARAMS = Object.freeze(['debug', 'q', 'touchdebug', 'cam', 'view']);
|
|
37
|
+
const RESERVED_PARAMS = new Set(['room', 'device', 'want', 'k', 'b', 't', 'name', 'hush', 'chat', 'bub', 'watch', 'follow', 'hand', 'screen', 'arrive', 'not', 'via', 'invite', 'w', 'gv', 'pf', 'server', 'token', 'key', 'code']);
|
|
38
|
+
const PARAM_NAME = /^[a-z][a-z0-9_-]{0,23}$/;
|
|
39
|
+
/** A switch's value as it is passed in: up to 48 of A-Z a-z 0-9 _ . ~ - (empty for a bare `?debug`). */
|
|
40
|
+
export const PARAM_VALUE = '^[A-Za-z0-9_.~-]{0,48}$';
|
|
41
|
+
|
|
42
|
+
/** The names a game may be handed: the defaults, then up to 12 of its own (lowercase, never one the page uses). */
|
|
43
|
+
export function playParams(meta) {
|
|
44
|
+
const own = Array.isArray(meta?.netplay?.params) ? meta.netplay.params : [];
|
|
45
|
+
const out = [...PLAY_PARAMS];
|
|
46
|
+
for (const raw of own) {
|
|
47
|
+
const k = String(raw ?? '');
|
|
48
|
+
if (PARAM_NAME.test(k) && !RESERVED_PARAMS.has(k) && !out.includes(k)) out.push(k);
|
|
49
|
+
if (out.length >= PLAY_PARAMS.length + 12) break;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The allowed switches of an address, by name (what `HOMIE_NET.params` and `net.params` carry). */
|
|
55
|
+
export function paramsFrom(searchParams, meta) {
|
|
56
|
+
const ok = new RegExp(PARAM_VALUE);
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const k of playParams(meta)) {
|
|
59
|
+
const v = searchParams.get(k);
|
|
60
|
+
if (v !== null && ok.test(v)) out[k] = v;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What a game's netplay manifest tells its rooms beyond their size (the catalogue row's `netplay`, written by
|
|
67
|
+
* `homie-studio build` from game.json `netplay`): `version` (the game's revision: a room runs one at a time, section
|
|
68
|
+
* 23), `stallMs` (how long a host may send no snapshot before it is replaced, 1500 to 10000, section 22) and `params`
|
|
69
|
+
* (the address's switches the game wants passed in, section 24). Null when it names none of them. `problems`: what was
|
|
70
|
+
* named and could not be used, for the build to say.
|
|
71
|
+
*/
|
|
72
|
+
export function netplayRow(net) {
|
|
73
|
+
const out = {};
|
|
74
|
+
const problems = [];
|
|
75
|
+
if (net?.version !== undefined && net.version !== null) {
|
|
76
|
+
const v = versionOf(net.version);
|
|
77
|
+
if (v) out.version = v; else problems.push('"netplay.version" is 1 to 32 letters, digits, ".", "_" or "-" (a number works too); left out');
|
|
78
|
+
}
|
|
79
|
+
if (net?.stallMs !== undefined && net.stallMs !== null) {
|
|
80
|
+
const n = stallOf(net.stallMs);
|
|
81
|
+
if (n === null) problems.push('"netplay.stallMs" is a number of milliseconds (1500 to 10000); left out');
|
|
82
|
+
else { out.stallMs = n; if (n !== Math.floor(Number(net.stallMs))) problems.push(`"netplay.stallMs" is held to 1500 to 10000 ms; using ${n}`); }
|
|
83
|
+
}
|
|
84
|
+
if (net?.params !== undefined && net.params !== null) {
|
|
85
|
+
const own = playParams({ netplay: { params: net.params } }).slice(PLAY_PARAMS.length);
|
|
86
|
+
if (own.length) out.params = own;
|
|
87
|
+
const asked = Array.isArray(net.params) ? net.params.filter((k) => !PLAY_PARAMS.includes(k)) : [];
|
|
88
|
+
if (!Array.isArray(net.params) || own.length < asked.length) problems.push('"netplay.params" is a list of up to 12 names (a-z, 0-9, - and _, starting with a letter), none of them one the play page uses itself (room, name, k, t, b…); the others were left out');
|
|
89
|
+
}
|
|
90
|
+
return { row: Object.keys(out).length ? out : null, problems };
|
|
91
|
+
}
|
package/worker/site.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The studio site's pages, in the studio's own look (site/SITE.md): the same sections as homie.rocks
|
|
3
3
|
* (Home, Games, Music, Videos, Rooms, Posts, each shown only when the studio has something in it), an epic
|
|
4
4
|
* landing for every game (a full-bleed hero from the game's own footage or art, the pitch, a big Play button
|
|
5
|
-
* into a public room, phone / computer / TV, live rooms, how to play
|
|
5
|
+
* into a public room, phone / computer / TV, live rooms, how to play and credits),
|
|
6
6
|
* posts with Atom and JSON feeds, and a "Made with Homie" footer. Plain HTML from the Worker; every word a
|
|
7
7
|
* studio or a player wrote is escaped; the one script is /_homie/site.js.
|
|
8
8
|
*
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
13
13
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { basedOnRow, licenseLabel } from './license.mjs';
|
|
15
15
|
import { POLICY_WORDS, policyWords } from './servers.mjs';
|
|
16
16
|
import { blogNode, breadcrumbs, gameNode, itemList, ldScript, musicIndexNode, postNode, songNode, studioNode, videoNode, websiteNode } from './schema.mjs';
|
|
17
17
|
|
|
@@ -128,8 +128,8 @@ img,video{display:block;max-width:100%}
|
|
|
128
128
|
text-shadow:0 0 38px color-mix(in srgb,var(--hot) 50%,transparent),0 2px 0 color-mix(in srgb,var(--bg) 60%,transparent)}
|
|
129
129
|
.title.t-long{font-size:clamp(44px,12vw,64px)}
|
|
130
130
|
.title.t-xlong{font-size:clamp(38px,10vw,54px)}
|
|
131
|
-
.
|
|
132
|
-
.
|
|
131
|
+
.based-on{margin:12px 0 0;font:600 14px/1.4 var(--text);color:var(--soft)}
|
|
132
|
+
.based-on a{color:inherit;text-decoration:underline;text-underline-offset:3px}
|
|
133
133
|
.line{margin:14px 0 22px;max-width:36ch;font:500 17px/1.45 var(--text);color:var(--soft);text-wrap:pretty}
|
|
134
134
|
.play{display:flex;width:100%;align-items:center;justify-content:center;gap:12px;min-height:62px;padding:0 30px;border-radius:18px;
|
|
135
135
|
background:var(--hot);color:var(--hot-ink);text-decoration:none;font:800 20px/1 var(--display);letter-spacing:-.01em;
|
|
@@ -295,11 +295,7 @@ img,video{display:block;max-width:100%}
|
|
|
295
295
|
.shots a{display:block;border-radius:var(--r);overflow:hidden;border:1px solid var(--line);background:var(--panel)}
|
|
296
296
|
.shots img{width:100%;aspect-ratio:16/9;object-fit:cover}
|
|
297
297
|
|
|
298
|
-
/*
|
|
299
|
-
.make{position:relative;overflow:hidden;border-radius:calc(var(--r) + 8px);padding:clamp(26px,4vw,48px);border:1px solid color-mix(in srgb,var(--hot) 55%,transparent);
|
|
300
|
-
background:radial-gradient(70% 90% at 100% 0%,color-mix(in srgb,var(--hot) 20%,transparent),transparent 60%),radial-gradient(60% 80% at 0% 100%,color-mix(in srgb,var(--glow) 14%,transparent),transparent 60%),var(--panel);
|
|
301
|
-
box-shadow:0 20px 70px color-mix(in srgb,var(--hot) 16%,transparent)}
|
|
302
|
-
.make h2{max-width:16ch}
|
|
298
|
+
/* a line to say to an AI, with its Copy button (the parts band, worker/parts.mjs) */
|
|
303
299
|
.say{display:grid;gap:10px;margin:24px 0 0;padding:0;list-style:none;counter-reset:say}
|
|
304
300
|
.say li{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;padding:10px 10px 10px 16px;border-radius:12px;background:color-mix(in srgb,var(--bg) 70%,transparent);border:1px solid var(--line)}
|
|
305
301
|
.say code{font:500 14px/1.45 var(--mono);color:var(--fg);overflow-wrap:anywhere}
|
|
@@ -640,14 +636,20 @@ ${footer(cat)}
|
|
|
640
636
|
}
|
|
641
637
|
|
|
642
638
|
/**
|
|
643
|
-
* A whole page from site/pages, served as it is, in the site's frame rules (never framed, no-transform).
|
|
639
|
+
* A whole page from site/pages, served as it is, in the site's frame rules (never framed, no-transform). Six
|
|
644
640
|
* markers let it borrow the generated parts: <!-- homie:style --> (the tokens and the stylesheet),
|
|
645
|
-
* <!-- homie:header -->, <!-- homie:footer --> ("Made with Homie"), <!-- homie:script
|
|
646
|
-
* <!-- homie:schema --> (the structured data the generated page at that address would carry: worker/schema.mjs)
|
|
641
|
+
* <!-- homie:header -->, <!-- homie:footer --> ("Made with Homie"), <!-- homie:script -->, in its <head>
|
|
642
|
+
* <!-- homie:schema --> (the structured data the generated page at that address would carry: worker/schema.mjs),
|
|
643
|
+
* and <!-- homie:home-hero --> or <!-- homie:home-hero <id> --> (Home's hero for the featured game, or for the
|
|
644
|
+
* public game it names: homeHero, below; a game that is not public, or not this studio's, leaves nothing there).
|
|
647
645
|
*/
|
|
648
646
|
export function customPage(cat, html, { active = null, schema = '' } = {}) {
|
|
649
647
|
const out = String(html)
|
|
650
648
|
.replace(/<!--\s*homie:schema\s*-->/g, () => schema)
|
|
649
|
+
.replace(/<!--\s*homie:home-hero(?:\s+([a-z0-9][a-z0-9-]{0,39}))?\s*-->/g, (m, id) => {
|
|
650
|
+
const g = id ? (cat.games ?? []).find((x) => x.id === id) : featuredOf(cat);
|
|
651
|
+
return g ? homeHero(cat, g) : '';
|
|
652
|
+
})
|
|
651
653
|
.replace(/<!--\s*homie:style\s*-->/g, () => `<style>${tokensCss(cat.studio?.theme ?? {})}${BASE_CSS}${cat.site?.css ?? ''}</style>`)
|
|
652
654
|
.replace(/<!--\s*homie:header\s*-->/g, () => header(cat, active))
|
|
653
655
|
.replace(/<!--\s*homie:footer\s*-->/g, () => footer(cat))
|
|
@@ -789,19 +791,18 @@ export const landingLd = (cat, g, origin, opts = {}) => [breadcrumbs(origin, [['
|
|
|
789
791
|
/** The public games as a list of their landings (Games, Rooms). */
|
|
790
792
|
const gamesList = (cat, origin, id, name) => itemList(origin, id, name, (cat.games ?? []).map((g) => ({ name: g.name, path: `/${g.id}/` })));
|
|
791
793
|
|
|
792
|
-
|
|
794
|
+
/**
|
|
795
|
+
* Home's hero for one game: its footage or art full-bleed, the studio's name, the game's title and pitch, Play, the
|
|
796
|
+
* live line and the links on. The generated Home leads with it (for the featured game), and a Home of the studio's
|
|
797
|
+
* own (site/pages/index.html) takes the same markup with <!-- homie:home-hero --> (the featured game) or
|
|
798
|
+
* <!-- homie:home-hero <id> --> (the game it names), so replacing Home never means writing a game's hero by hand
|
|
799
|
+
* from the addresses of its pictures.
|
|
800
|
+
*/
|
|
801
|
+
export function homeHero(cat, f, { playing = 0 } = {}) {
|
|
793
802
|
const name = studioName(cat);
|
|
794
803
|
const games = cat.games ?? [];
|
|
795
|
-
const posts = (cat.posts ?? []).slice(0, 3);
|
|
796
|
-
const songs = cat.songs ?? [];
|
|
797
|
-
const videos = cat.videos ?? [];
|
|
798
|
-
const f = featuredOf(cat);
|
|
799
804
|
const tagline = cat.studio?.tagline ?? '';
|
|
800
|
-
|
|
801
|
-
let soon = '';
|
|
802
|
-
if (f) {
|
|
803
|
-
const playing = live[f.id] ?? 0;
|
|
804
|
-
hero = `<section class="hero home" aria-labelledby="hero-title">
|
|
805
|
+
return `<section class="hero home" aria-labelledby="hero-title">
|
|
805
806
|
${heroMedia(f.landing?.hero, { alt: f.landing?.hero?.alt })}
|
|
806
807
|
<div class="hero-copy">
|
|
807
808
|
<p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || 'featured game')}</span></p>
|
|
@@ -811,6 +812,20 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
811
812
|
<a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/games/">${icon('games')}<span>All ${games.length} games</span></a>` : ''}
|
|
812
813
|
</div>
|
|
813
814
|
</section>`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
818
|
+
const name = studioName(cat);
|
|
819
|
+
const games = cat.games ?? [];
|
|
820
|
+
const posts = (cat.posts ?? []).slice(0, 3);
|
|
821
|
+
const songs = cat.songs ?? [];
|
|
822
|
+
const videos = cat.videos ?? [];
|
|
823
|
+
const f = featuredOf(cat);
|
|
824
|
+
const tagline = cat.studio?.tagline ?? '';
|
|
825
|
+
let hero;
|
|
826
|
+
let soon = '';
|
|
827
|
+
if (f) {
|
|
828
|
+
hero = homeHero(cat, f, { playing: live[f.id] ?? 0 });
|
|
814
829
|
} else if (!videos.length && !songs.length) {
|
|
815
830
|
// A new studio with nothing published yet: its own Home, its name, "First game coming soon", and what is on the
|
|
816
831
|
// way (a studio is never given a starter game it did not ask for). Its posts show below as soon as it has any.
|
|
@@ -1115,18 +1130,18 @@ export function jsonFeed(cat, posts, origin) {
|
|
|
1115
1130
|
|
|
1116
1131
|
/**
|
|
1117
1132
|
* The landing every game gets: a full-bleed hero from its own footage or art, the pitch, a big Play button into a
|
|
1118
|
-
* public room, phone / computer / TV (with the join code), live rooms, how to play
|
|
1119
|
-
* this". A studio's site/pages/<id>/index.html replaces it; site/partials/game.html and game-<id>.html add a band.
|
|
1133
|
+
* public room, phone / computer / TV (with the join code), live rooms, how to play and credits. A studio's site/pages/<id>/index.html replaces it; site/partials/game.html and game-<id>.html add a band.
|
|
1120
1134
|
*/
|
|
1121
|
-
export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null,
|
|
1135
|
+
export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null, servers = null, listed = true, shop = null } = {}) {
|
|
1122
1136
|
const name = studioName(cat);
|
|
1123
1137
|
const L = g.landing ?? {};
|
|
1124
|
-
// A
|
|
1125
|
-
|
|
1138
|
+
// A game made from another studio's game, while a game could be handed over whole (remix, retired), still owes
|
|
1139
|
+
// that game its credit: its name, linked, under this one's and in its credits. A credit and nothing more: no
|
|
1140
|
+
// page offers this game, or that one, to be taken. (`basedOn` is the built row; lib/build.mjs reads `remixOf`.)
|
|
1141
|
+
const lineage = basedOnRow(g.basedOn ?? g.remixOf);
|
|
1126
1142
|
const ofWhat = lineage ? `${lineage.page ? `<a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.name)}</a>` : `<strong>${esc(lineage.name)}</strong>`}${lineage.studio ? ` by ${esc(lineage.studio)}` : ''}` : '';
|
|
1127
|
-
//
|
|
1128
|
-
const license =
|
|
1129
|
-
const remixable = Boolean(L.source) && remix !== false && license.kind !== 'no-remix';
|
|
1143
|
+
// The licence the game names for itself (an SPDX id), said in its credits; a game that names none says nothing.
|
|
1144
|
+
const license = licenseLabel(g.license);
|
|
1130
1145
|
const h = L.hero ?? {};
|
|
1131
1146
|
const host = origin.replace(/^https?:\/\//, '');
|
|
1132
1147
|
const playUrl = `${origin}/${g.id}/play`;
|
|
@@ -1147,7 +1162,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1147
1162
|
<div class="hero-copy">
|
|
1148
1163
|
<p class="kicker">${kickerHtml}</p>
|
|
1149
1164
|
<h1 class="title ${titleClass(g.name)}" id="game-title">${esc(g.name)}</h1>
|
|
1150
|
-
${lineage ? `<p class="
|
|
1165
|
+
${lineage ? `<p class="based-on">Based on ${ofWhat}</p>` : ''}
|
|
1151
1166
|
<p class="line">${esc(L.pitch ?? g.blurb)}</p>
|
|
1152
1167
|
<div class="row">
|
|
1153
1168
|
<a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
|
|
@@ -1204,30 +1219,13 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1204
1219
|
const o = C.original;
|
|
1205
1220
|
const creditCards = [
|
|
1206
1221
|
`<div class="credit"><h3>Made by</h3><p><strong><a href="/">${esc(name)}</a></strong>${cat.studio?.tagline ? ` · ${esc(cat.studio.tagline)}` : ''}</p>${(C.people ?? []).length ? `<ul>${C.people.map((c) => `<li>${c.role ? `${esc(c.role)}: ` : ''}${c.url ? `<a href="${esc(c.url)}" rel="noopener">${esc(c.name)}</a>` : `<strong>${esc(c.name)}</strong>`}</li>`).join('')}</ul>` : ''}</div>`,
|
|
1207
|
-
lineage ? `<div class="credit"><h3>
|
|
1222
|
+
lineage ? `<div class="credit"><h3>Based on</h3><p>${ofWhat}.</p>${lineage.page ? `<p><a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.page.replace(/^https:\/\//, ''))}</a></p>` : ''}</div>` : '',
|
|
1208
1223
|
o && !o.house ? `<div class="credit"><h3>Based on</h3><p><strong>${esc(o.title)}</strong>${o.author ? ` by <strong>${esc(o.author)}</strong>` : ''}${o.year ? ` (${esc(o.year)})` : ''}${o.licence ? `, ${esc(o.licence)} licence` : ''}.</p>${o.url ? `<p><a href="${esc(o.url)}" rel="noopener">${esc(o.url.replace(/^https:\/\//, ''))}</a></p>` : ''}<p>Made multiplayer with Homie: public rooms, bots, join-in-progress, a new host when one leaves, touch controls and the big screen.</p></div>` : '',
|
|
1209
1224
|
(C.parts ?? []).length ? `<div class="credit"><h3>Also inside</h3><ul>${C.parts.map((p) => `<li>${esc(p.what)}${p.author ? `: ${esc(p.author)}` : ''}${p.url ? ` (<a href="${esc(p.url)}" rel="noopener">source</a>)` : ''}${p.licence ? `, ${p.licenceUrl ? `<a href="${esc(p.licenceUrl)}" rel="noopener">${esc(p.licence)}</a>` : esc(p.licence)}` : ''}</li>`).join('')}</ul></div>` : '',
|
|
1210
|
-
`<div class="credit"><h3>Built with</h3><p>Homie’s open engine: <a href="https://github.com/homie-rocks/homie" rel="noopener">@homie-rocks/studio</a> (rooms, netplay, the big screen), Apache-2.0.</p>${
|
|
1225
|
+
`<div class="credit"><h3>Built with</h3><p>Homie’s open engine: <a href="https://github.com/homie-rocks/homie" rel="noopener">@homie-rocks/studio</a> (rooms, netplay, the big screen), Apache-2.0.</p>${license ? `<p>Licence: ${esc(license)}.</p>` : ''}${C.texts ? `<p><a href="/${esc(g.id)}/credits">Licences and full credits</a></p>` : ''}</div>`,
|
|
1211
1226
|
].filter(Boolean).join('');
|
|
1212
1227
|
const creditsBand = `<section class="band tight" aria-labelledby="credits-title"><div class="band-in"><p class="kicker reveal">Credits</p><h2 class="small-h reveal" id="credits-title">Who made ${esc(g.name)}</h2><div class="credits reveal">${creditCards}</div></div></section>`;
|
|
1213
1228
|
|
|
1214
|
-
const source = `${origin}/games/${g.id}/source.json`;
|
|
1215
|
-
const say = remixable
|
|
1216
|
-
? `Remix ${g.name} from ${source} into a game of my own in my Homie studio`
|
|
1217
|
-
: `Make a multiplayer game like ${g.name} in my Homie studio`;
|
|
1218
|
-
const makeHref = remixable ? `https://homie.rocks/studio/?remix=${encodeURIComponent(source)}` : 'https://homie.rocks/studio/';
|
|
1219
|
-
const makeBand = `<section class="band" aria-labelledby="make-title"><div class="band-in"><div class="make reveal">
|
|
1220
|
-
<p class="kicker">${remixable ? 'Open to remix' : 'Your turn'}</p>
|
|
1221
|
-
<h2 id="make-title">Make a game like this</h2>
|
|
1222
|
-
<p class="lead">${remixable ? `${esc(g.name)}’s source is shared. Homie is a plugin for Claude Code and Codex: your AI copies this game into a studio of your own, makes it yours, and puts it online with public rooms like these, on your own free Cloudflare account.` : `Homie is a plugin for Claude Code and Codex: your AI sets up a studio of your own and makes a multiplayer game like ${esc(g.name)}, with public rooms like these, on your own free Cloudflare account.`}</p>
|
|
1223
|
-
<ol class="say">
|
|
1224
|
-
<li><div><span class="label">1 · In Claude Code</span><code>/plugin marketplace add homie-rocks/homie</code></div><button class="copy" type="button" data-copy="/plugin marketplace add homie-rocks/homie">${icon('copy')}<span data-copy-word>Copy</span></button></li>
|
|
1225
|
-
<li><div><span class="label">2</span><code>/plugin install homie@homie</code></div><button class="copy" type="button" data-copy="/plugin install homie@homie">${icon('copy')}<span data-copy-word>Copy</span></button></li>
|
|
1226
|
-
<li><div><span class="label">3 · Then say</span><code>${esc(say)}</code></div><button class="copy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></li>
|
|
1227
|
-
</ol>
|
|
1228
|
-
<div class="keys"><a class="btn" href="${esc(makeHref)}">${icon('spark')}<span>Make a game like this</span></a>${remixable ? `<a class="ghost" href="/games/${esc(g.id)}/source.json">See the source</a>` : ''}</div>
|
|
1229
|
-
</div></div></section>`;
|
|
1230
|
-
|
|
1231
1229
|
// A partial that is its own <section> stands as it is; anything else sits in one of the page's bands.
|
|
1232
1230
|
const band = (html) => (!html ? '' : /^\s*<section\b/i.test(html) ? html : `<section class="band tight"><div class="band-in">${html}</div></section>`);
|
|
1233
1231
|
const vars = { 'game.name': g.name, 'game.id': g.id, 'game.play': `/${g.id}/play` };
|
|
@@ -1238,15 +1236,15 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1238
1236
|
head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}`,
|
|
1239
1237
|
// A game that is not public yet (private, or an invite-only beta) is never indexed, and offers nothing.
|
|
1240
1238
|
ld: landingLd(cat, g, origin, { listed, shop }), ...(listed ? {} : { extraHeaders: { 'x-robots-tag': 'noindex' } }),
|
|
1241
|
-
main: `${hero}${ways}${liveBand}${servers ? serversBand(g, servers) : ''}${howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}
|
|
1239
|
+
main: `${hero}${ways}${liveBand}${servers ? serversBand(g, servers) : ''}${howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}`,
|
|
1242
1240
|
});
|
|
1243
1241
|
}
|
|
1244
1242
|
|
|
1245
1243
|
/** /<game>/credits: the licence texts the game ships with (CREDITS.md and every licence file credits.json names). */
|
|
1246
1244
|
export function creditsPage(cat, g, texts, { origin = '' } = {}) {
|
|
1247
1245
|
const name = studioName(cat);
|
|
1248
|
-
const lineage =
|
|
1249
|
-
const of = lineage ? `<p class="sec">
|
|
1246
|
+
const lineage = basedOnRow(g.basedOn ?? g.remixOf);
|
|
1247
|
+
const of = lineage ? `<p class="sec">Based on</p><p>${lineage.page ? `<a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.name)}</a>` : esc(lineage.name)}${lineage.studio ? ` by ${esc(lineage.studio)}` : ''}</p>` : '';
|
|
1250
1248
|
return layout(cat, {
|
|
1251
1249
|
title: `Credits · ${g.name} · ${name}`, description: `Credits and licences for ${g.name}.`, origin, path: `/${g.id}/credits`, page: 'credits', active: 'games',
|
|
1252
1250
|
ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Credits', `/${g.id}/credits`]])],
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.31.0';
|
package/lib/remix-assets.mjs
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A REMIX ARRIVES WITH ITS MODELS. The remix source (source.json) carries text only: the game's code, its
|
|
3
|
-
* assets/manifest.json and RIGHTS.md. The original site also serves /games/<id>/assets.json (lib/asset-manifest.mjs
|
|
4
|
-
* servedAssets): each asset's licence, what a remix gets, and for redistributable files their address and SHA-256.
|
|
5
|
-
*
|
|
6
|
-
* include fetched from the original studio's site, same origin as the source, checked by SHA-256 (and, for a
|
|
7
|
-
* model, by the safety rules), written where the original had it
|
|
8
|
-
* reference not carried: a grey placeholder of the recorded size, and the origin named in RIGHTS.md
|
|
9
|
-
* none a grey placeholder of the recorded size and "needs its own licence"
|
|
10
|
-
*
|
|
11
|
-
* At most 50 MB in all. Each entry of the remix's manifest records where it came from (`remixOf`), so RIGHTS.md and the
|
|
12
|
-
* credits carry over; nothing is fetched from anywhere but the original site.
|
|
13
|
-
*/
|
|
14
|
-
import { createHash } from 'node:crypto';
|
|
15
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
-
import { dirname, join } from 'node:path';
|
|
17
|
-
import { LIMITS, checkGlb } from '../assets/safety.mjs';
|
|
18
|
-
import { readManifest, syncCredits, writeManifest, writeRights } from './asset-manifest.mjs';
|
|
19
|
-
|
|
20
|
-
export const REMIX_ASSET_CAP = 50 * 1024 * 1024;
|
|
21
|
-
const SAFE_PATH = /^public\/[A-Za-z0-9._/-]{1,200}$/;
|
|
22
|
-
|
|
23
|
-
/** `game remix` after the text files are written: the original's assets, or placeholders. */
|
|
24
|
-
export async function fetchRemixAssets(root, id, source, { credit = {}, cap = REMIX_ASSET_CAP, log = () => {} } = {}) {
|
|
25
|
-
const base = new URL(String(source));
|
|
26
|
-
const at = new URL(base.pathname.replace(/source\.json$/, 'assets.json'), base);
|
|
27
|
-
let body = null;
|
|
28
|
-
try {
|
|
29
|
-
const res = await fetch(at, { signal: AbortSignal.timeout(20_000) });
|
|
30
|
-
if (res.ok) body = await res.json();
|
|
31
|
-
} catch { body = null; }
|
|
32
|
-
if (body?.kind !== 'homie-game-assets' || !Array.isArray(body.assets)) return { carried: false, why: 'the original serves no assets.json (a game made before 0.22.0, or one with no models)', fetched: [], placeholders: [], bytes: 0 };
|
|
33
|
-
const gdir = join(root, 'games', id);
|
|
34
|
-
const manifest = readManifest(root, id);
|
|
35
|
-
const fetched = []; const placeholders = []; const failed = [];
|
|
36
|
-
let total = 0;
|
|
37
|
-
const { placeholderGlb, sha256 } = await import('./optimise.mjs').catch(() => ({}));
|
|
38
|
-
for (const a of body.assets) {
|
|
39
|
-
const entry = manifest.assets.find((x) => x.id === a.id);
|
|
40
|
-
if (!entry) continue;
|
|
41
|
-
entry.from = { ...(entry.from ?? {}), remixOf: { studio: credit.studio ?? null, game: credit.game ?? null, source: String(base), asset: a.id } };
|
|
42
|
-
// The original studio owns what it made or generated; this remix does not.
|
|
43
|
-
if (entry.license?.owner === 'studio') entry.license = { ...entry.license, owner: credit.studio ?? 'the original studio' };
|
|
44
|
-
const files = (a.files ?? []).filter((f) => SAFE_PATH.test(String(f.path ?? '')) && !String(f.path).split('/').includes('..'));
|
|
45
|
-
if (a.license?.remix === 'include' && files.length && files.every((f) => f.url && f.sha256)) {
|
|
46
|
-
let ok = true;
|
|
47
|
-
for (const f of files) {
|
|
48
|
-
const url = new URL(String(f.url), at);
|
|
49
|
-
if (url.origin !== at.origin) { failed.push({ asset: a.id, why: `${f.url} is on another site` }); ok = false; break; }
|
|
50
|
-
try {
|
|
51
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
52
|
-
if (!res.ok) throw new Error(`answered ${res.status}`);
|
|
53
|
-
const bytes = Buffer.from(await res.arrayBuffer());
|
|
54
|
-
total += bytes.byteLength;
|
|
55
|
-
if (total > cap) throw new Error(`the remix's assets passed ${Math.round(cap / 1024 / 1024)} MB`);
|
|
56
|
-
const sum = createHash('sha256').update(bytes).digest('hex');
|
|
57
|
-
if (sum !== f.sha256) throw new Error('its SHA-256 does not match the original\'s record');
|
|
58
|
-
if (/\.glb$/i.test(f.path)) { const s = checkGlb(bytes, LIMITS.game); if (!s.ok) throw new Error(s.problems[0]); }
|
|
59
|
-
mkdirSync(dirname(join(gdir, f.path)), { recursive: true });
|
|
60
|
-
writeFileSync(join(gdir, f.path), bytes);
|
|
61
|
-
} catch (error) { failed.push({ asset: a.id, file: f.path, why: error.message }); ok = false; break; }
|
|
62
|
-
}
|
|
63
|
-
if (ok) { fetched.push({ asset: a.id, files: files.map((f) => f.path) }); continue; }
|
|
64
|
-
}
|
|
65
|
-
// Not carried: a grey box of the size it had, so the game still runs, and a plain note.
|
|
66
|
-
const model = (entry.files ?? []).find((f) => f.role === 'model' && SAFE_PATH.test(String(f.path ?? '')));
|
|
67
|
-
if (model && placeholderGlb) {
|
|
68
|
-
const size = a.size ?? entry.measured?.box?.size ?? [0.5, entry.measured?.heightM ?? 0.5, 0.5];
|
|
69
|
-
const glb = Buffer.from(await placeholderGlb(size, { name: `${a.id}-placeholder` }));
|
|
70
|
-
mkdirSync(dirname(join(gdir, model.path)), { recursive: true });
|
|
71
|
-
if (!existsSync(join(gdir, model.path))) writeFileSync(join(gdir, model.path), glb);
|
|
72
|
-
model.sha256 = sha256(glb);
|
|
73
|
-
model.bytes = glb.byteLength;
|
|
74
|
-
}
|
|
75
|
-
entry.placeholder = true;
|
|
76
|
-
entry.license = { ...(entry.license ?? {}), remix: 'none', notes: `${a.license?.remix === 'reference' ? `Fetch it from its origin${a.from?.origin ? ` (${a.from.origin})` : ''} under its own licence` : 'Needs its own licence'}: this remix has a grey placeholder of the same size in its place.` };
|
|
77
|
-
placeholders.push({ asset: a.id, why: a.license?.remix === 'include' ? (failed.find((x) => x.asset === a.id)?.why ?? 'not served') : `its licence (${a.license?.kind ?? 'none'}) does not let a remix carry it` });
|
|
78
|
-
}
|
|
79
|
-
writeManifest(root, id, manifest);
|
|
80
|
-
writeRights(root, id, manifest);
|
|
81
|
-
syncCredits(root, id, manifest);
|
|
82
|
-
log(`remix assets: ${fetched.length} carried, ${placeholders.length} placeholder(s)`);
|
|
83
|
-
return { carried: true, fetched, placeholders, failed, bytes: total };
|
|
84
|
-
}
|