@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. 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, credits, and "Make a game like this"),
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 { licenseLabel, licenseOf, remixRow } from './license.mjs';
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
- .remix-of{margin:12px 0 0;font:600 14px/1.4 var(--text);color:var(--soft)}
132
- .remix-of a{color:inherit;text-decoration:underline;text-underline-offset:3px}
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
- /* make a game like this */
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). Five
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 --> and, in its <head>,
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
- export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
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
- let hero;
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, credits, and "Make a game like
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, remix = true, servers = null, listed = true, shop = 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 remix says what it is a remix of, linked, under its name and in its credits (game.json `remixOf`).
1125
- const lineage = remixRow(g.remixOf);
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
- // Open to remix: the source is in the build, the owner's switch is on, and the licence they picked allows it.
1128
- const license = licenseOf(g.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="remix-of">Remix of ${ofWhat}</p>` : ''}
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>Remix of</h3><p>${ofWhat}.</p>${lineage.page ? `<p><a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.page.replace(/^https:\/\//, ''))}</a></p>` : ''}</div>` : '',
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>${L.source ? `<p>Its source: ${esc(licenseLabel(license))}.</p>` : ''}${C.texts ? `<p><a href="/${esc(g.id)}/credits">Licences and full credits</a></p>` : ''}</div>`,
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}${makeBand}`,
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 = remixRow(g.remixOf);
1249
- const of = lineage ? `<p class="sec">Remix of</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>` : '';
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`]])],
@@ -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.30.1';
2
+ export const STUDIO_VERSION_TAG = '0.31.0';
@@ -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
- }