@homie-rocks/studio 0.14.3 → 0.14.4

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/README.md CHANGED
@@ -83,8 +83,10 @@ featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every
83
83
  joinable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
84
84
  page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
85
85
  pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
86
- live rooms, credits (the original and its licence for a port), and "Make a game like this". Every page ends with
87
- "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
86
+ live rooms, credits (the original and its licence for a port; "Remix of <game> by <studio>", linked, for a
87
+ remix), and "Make a game like this". A game's shared source (`/games/<id>/source.json`) says who made it and the
88
+ licence its owner picked in game.json `"license"`: `"remix-with-credit"` (the default), `"remix-freely"` or
89
+ `"no-remix"`, which `game remix` refuses. Every page ends with "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
88
90
  files. `site/SITE.md` says all of it.
89
91
 
90
92
  The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
@@ -100,8 +102,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
100
102
  A studio pins one version, so nothing changes until it asks. To take a newer one:
101
103
 
102
104
  ```sh
103
- npx -y @homie-rocks/studio@0.14.3 upgrade # the plan; changes nothing
104
- npx -y @homie-rocks/studio@0.14.3 upgrade --apply # after the person agrees
105
+ npx -y @homie-rocks/studio@0.14.4 upgrade # the plan; changes nothing
106
+ npx -y @homie-rocks/studio@0.14.4 upgrade --apply # after the person agrees
105
107
  npm install && npm run build
106
108
  ```
107
109
 
@@ -232,7 +234,7 @@ device; the owner's own passkey account (`homie-studio players owner`) counts as
232
234
  ## Setup status
233
235
 
234
236
  ```sh
235
- npx -y @homie-rocks/studio@0.14.3 setup status --connector yes # before a studio exists
237
+ npx -y @homie-rocks/studio@0.14.4 setup status --connector yes # before a studio exists
236
238
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
237
239
  ```
238
240
 
@@ -164,7 +164,7 @@ function print(result) {
164
164
  lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
165
165
  break;
166
166
  case 'game remix':
167
- lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
167
+ lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Its game.json credits the original: "${result.credit}"${result.page ? ` (${result.page})` : ''}, on its landing and in its credits. Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
168
168
  break;
169
169
  case 'game new':
170
170
  lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
package/lib/build.mjs CHANGED
@@ -33,6 +33,7 @@ import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
33
33
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
34
34
  import { SEAT_MAX } from '../worker/seats.mjs';
35
35
  import { STUDIO_VERSION } from './version.mjs';
36
+ import { licenseOf, remixRow } from '../worker/license.mjs';
36
37
 
37
38
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
38
39
  /** Never copied into a static game's served folder. */
@@ -64,8 +65,12 @@ function dirBytes(dir) {
64
65
  return n;
65
66
  }
66
67
  const TEXT = /\.(ts|tsx|js|mjs|jsx|json|html|css|md|txt|svg|glsl|wgsl|frag|vert)$/i;
67
- /** Text files of a game folder, capped (2 MB total, 512 KB each): what `homie-studio game remix` takes. */
68
- export function sourceOf(dir, id) {
68
+ /**
69
+ * Text files of a game folder, capped (2 MB total, 512 KB each): what `homie-studio game remix` takes; with who made
70
+ * it (`credit`: the studio and the game; the live site adds the page, worker/index.mjs) and its licence (game.json
71
+ * "license", worker/license.mjs).
72
+ */
73
+ export function sourceOf(dir, id, { studio = null, game = null, license } = {}) {
69
74
  const files = {};
70
75
  let total = 0;
71
76
  const walk = (rel) => {
@@ -82,7 +87,7 @@ export function sourceOf(dir, id) {
82
87
  }
83
88
  };
84
89
  walk('');
85
- return { v: 1, kind: 'homie-game-source', id, files };
90
+ return { v: 1, kind: 'homie-game-source', id, credit: { studio, game: game ?? id }, license: licenseOf(license), files };
86
91
  }
87
92
 
88
93
  /*
@@ -193,7 +198,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
193
198
  if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
194
199
  if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
195
200
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
196
- if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
201
+ if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
197
202
  const main = join(out, 'assets', 'main.js');
198
203
  const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
199
204
  const seats = seatsFor(g, netplayOf(g, out));
@@ -225,6 +230,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
225
230
  ...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
226
231
  // game.json `"launch": "private" | "invite"`: the game's launch state until its owner sets one live (the office).
227
232
  ...(g.launch === 'private' || g.launch === 'invite' ? { launch: g.launch } : {}),
233
+ // Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
234
+ license: licenseOf(g.license),
235
+ ...(remixRow(g.remixOf) ? { remixOf: remixRow(g.remixOf) } : {}),
228
236
  landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
229
237
  };
230
238
  });
package/lib/mcp-tools.mjs CHANGED
@@ -684,7 +684,7 @@ export function toolDefs(ctx, avail = {}) {
684
684
  },
685
685
  {
686
686
  name: 'game_remix', title: 'Remix a shared game',
687
- description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own (the original stays credited in game.json).',
687
+ description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own. The original stays credited: game.json remixOf says "Remix of <game> by <studio>" with a link back, shown on the new game\'s landing and in its credits. A game whose owner\'s licence says no remix is refused.',
688
688
  inputSchema: { type: 'object', properties: { game: str('The directory game: "<studio site>/<game id>" or its play URL'), id: str('The new game id in this studio'), name: str('Optional: its new name'), ...STUDIO_ARG }, required: ['game', 'id'] },
689
689
  annotations: { title: 'Remix a game', ...RW, openWorldHint: true },
690
690
  run: async (a) => {
@@ -696,7 +696,8 @@ export function toolDefs(ctx, avail = {}) {
696
696
  const r = await cli(ctx, root, `game remix ${a.id}`, ['game', 'remix', source, '--id', a.id, ...(a.name ? ['--name', String(a.name).slice(0, 60)] : [])]);
697
697
  if (!r.ended) return stillRunning(r.job, 'The remix');
698
698
  if (r.job.code !== 0) return fail(`Not remixed: ${whyOf(r.job)}`);
699
- return ok(`games/${a.id} is a remix of ${site}/${gameId} (${(r.result?.files ?? []).length} files).`, { kind: 'game', ...r.result });
699
+ const credit = r.result?.credit ? ` Its game.json credits the original: "${r.result.credit}"${r.result.page ? ` (${r.result.page})` : ''}, on its landing and in its credits; keep it.` : '';
700
+ return ok(`games/${a.id} is a remix of ${site}/${gameId} (${(r.result?.files ?? []).length} files).${credit}`, { kind: 'game', ...r.result });
700
701
  },
701
702
  },
702
703
  {
package/lib/scaffold.mjs CHANGED
@@ -202,6 +202,12 @@ studio's pinned copy, never a registry lookup of the bare name.
202
202
  a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
203
203
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
204
204
  - A game's id is its URL (\`/<id>/\`); keep it once published.
205
+ - **Remixing.** A public game shares its source at \`/games/<id>/source.json\`, with who made it (the studio, the
206
+ game, its page) and the licence its owner picks in game.json \`"license"\`: \`"remix-with-credit"\` (the
207
+ default), \`"remix-freely"\` or \`"no-remix"\`, and \`{ "kind": "…", "spdx": "MIT" }\` to name a licence too.
208
+ \`npx --no-install homie-studio game remix <source.json> --id <new id>\` brings another studio's game in as a new
209
+ game here: its game.json \`remixOf\` says "Remix of <game> by <studio>" with a link back, and its landing and
210
+ credits show it; keep that credit. A game whose licence says no remix is refused.
205
211
  - **Progress that lasts** (a character, unlocks, a collection, days of play) goes in **saves**, never in the room:
206
212
  a room forgets everything 60 s after its last player leaves. game.json \`"saves": true\` and
207
213
  \`createSaves\` from \`@homie-rocks/studio/saves\` (\`node_modules/@homie-rocks/studio/saves/SAVES.md\`): per
package/lib/studio.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
8
9
 
9
10
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
11
  export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -125,7 +126,11 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
125
126
  return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String) };
126
127
  }
127
128
 
128
- /** `homie-studio game remix <source.json url> --id <new id>`: a shared game brought in as this studio's own. */
129
+ /**
130
+ * `homie-studio game remix <source.json url> --id <new id>`: a shared game brought in as this studio's own. Its
131
+ * game.json `remixOf` credits the original ("Remix of <game> by <studio>", with a link back to its page), and its
132
+ * landing and credits show it. A game whose owner's licence says no remix is refused (worker/license.mjs).
133
+ */
129
134
  export async function remixGame(root, source, id, { name } = {}) {
130
135
  if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('give the new game an id: --id <lowercase-id>');
131
136
  const dest = join(root, 'games', id);
@@ -137,6 +142,14 @@ export async function remixGame(root, source, id, { name } = {}) {
137
142
  if (!res.ok) throw new Error(`${url} answered ${res.status}`);
138
143
  const body = await res.json();
139
144
  if (body?.kind !== 'homie-game-source' || !body.files || typeof body.files !== 'object') throw new Error('that address is not a shared Homie game source');
145
+ // Who made it and what they allow: the source's credit and licence (a studio before 0.14.4 sends neither, so the
146
+ // default licence, and the page from the address).
147
+ const credit = body.credit && typeof body.credit === 'object' ? body.credit : {};
148
+ const license = licenseOf(body.license);
149
+ if (!remixAllowed(license)) {
150
+ const who = remixRow({ name: credit.game ?? body.id, studio: credit.studio });
151
+ throw new Error(`${who?.name ?? 'That game'}${who?.studio ? ` by ${who.studio}` : ''} is not open to remixing: its owner's licence says no remix. Make a game of your own like it instead.`);
152
+ }
140
153
  const wrote = [];
141
154
  for (const [rel, text] of Object.entries(body.files)) {
142
155
  if (typeof text !== 'string' || rel.includes('..') || rel.startsWith('/') || !/^[A-Za-z0-9._/-]+$/.test(rel)) continue;
@@ -148,11 +161,18 @@ export async function remixGame(root, source, id, { name } = {}) {
148
161
  if (!existsSync(join(dest, 'game.json'))) throw new Error('the shared source has no game.json');
149
162
  const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
150
163
  const from = meta.id;
151
- meta.remixOf = { source: String(url), id: from, name: meta.name };
164
+ // The credit, in the remix's own game.json: the original's name, studio, page and licence, the line its landing and
165
+ // credits show, and, when the original was itself a remix, what that was a remix of.
166
+ const row = remixRow({ name: credit.game ?? meta.name ?? from, studio: credit.studio, page: httpsPage(credit.page) ?? pageOfSource(url) });
167
+ const before = remixRow(meta.remixOf);
168
+ meta.remixOf = {
169
+ credit: remixCredit(row), name: row?.name ?? null, studio: row?.studio ?? null, page: row?.page ?? null,
170
+ source: String(url), id: from, license, ...(before ? { of: before } : {}),
171
+ };
152
172
  meta.id = id;
153
173
  if (name) meta.name = String(name).slice(0, 60);
154
174
  writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
155
175
  const main = join(dest, meta.entry ?? 'src/main.ts');
156
176
  if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
157
- return { ok: true, command: 'game remix', id, from: String(url), files: wrote };
177
+ return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote };
158
178
  }
@@ -380,6 +380,33 @@
380
380
  "## Beta": "9d02638207a26e73"
381
381
  }
382
382
  }
383
+ },
384
+ "0.14.4": {
385
+ "files": {
386
+ "CLAUDE.md": "ddf2b2a0376202f6",
387
+ "README.md": "684b3cca8001673c",
388
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
389
+ "games/README.md": "5ad32fe80c61f117",
390
+ "music/README.md": "b8a828de9311bd28",
391
+ "videos/README.md": "70614388cbb203c7",
392
+ "posts/README.md": "37841aae476bd540",
393
+ "site/README.md": "ca131cd0472464fb",
394
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
395
+ },
396
+ "sections": {
397
+ "AGENTS.md": {
398
+ "(intro)": "214aa46eb3a895d5",
399
+ "## Layout": "f14d641159480e6c",
400
+ "## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
401
+ "## The site": "7c1618431bae6324",
402
+ "## Making games": "ce87c4dde1894c26",
403
+ "## The Game Codex and progress": "0738ef719cef3a4c",
404
+ "## Running live games (the back office)": "f0566f07cff04113",
405
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
406
+ "## Rules": "969318425c99f5a5",
407
+ "## Beta": "9d02638207a26e73"
408
+ }
409
+ }
383
410
  }
384
411
  }
385
412
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.14.3",
3
+ "version": "0.14.4",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/site/SITE.md CHANGED
@@ -48,12 +48,20 @@ Made from the game's own files, nothing invented:
48
48
  phone scans to play, and how to put the game on a TV (`/<id>/tv` shows the room with its own join code).
49
49
  - **Live rooms**, each with Join. When studio.json shares its stats (`stats.share`), also "played this week".
50
50
  - **How to play**: `landing.about`, `landing.howToPlay` (a few lines), the controls, the room facts.
51
- - **Credits**: the studio and `landing.credits` (`[{ "role", "name", "url" }]`); for a port, the original, its
52
- author and licence and every part inside it (`credits.json`, as the port skill writes it), with the full
53
- licence texts on `/<id>/credits`; and Homie's open engine.
51
+ - **Credits**: the studio and `landing.credits` (`[{ "role", "name", "url" }]`); for a remix, "Remix of
52
+ <game> by <studio>" linked to the original's page (game.json `remixOf`, which `game remix` writes; it is also
53
+ under the game's name in the hero); for a port, the original, its author and licence and every part inside it
54
+ (`credits.json`, as the port skill writes it), with the full licence texts on `/<id>/credits`; Homie's open
55
+ engine; and the source's licence.
54
56
  - **Make a game like this**: the two commands that install the Homie plugin and the words to say. When the game
55
- shares its source (the default; `"share": { "source": false }` keeps it closed) the words remix it from
56
- `/games/<id>/source.json`, and the button goes to `https://homie.rocks/studio/?remix=<that address>`.
57
+ shares its source (the default; `"share": { "source": false }` keeps it closed, and the owner's Remixable
58
+ switch withdraws it) and its licence allows remixing, the words remix it from `/games/<id>/source.json`, and
59
+ the button goes to `https://homie.rocks/studio/?remix=<that address>`.
60
+
61
+ A game's licence is game.json `"license"`, the owner's pick: `"remix-with-credit"` (the default: a remix says
62
+ "Remix of <game> by <studio>" with a link back), `"remix-freely"` (the credit is welcome, not asked for) or
63
+ `"no-remix"` (the source can be read; `game remix` refuses it), and `{ "kind": "<one of those>", "spdx": "MIT" }`
64
+ names a licence too. The source.json carries it, with `credit`: the studio's name, the game's name and its page.
57
65
 
58
66
  game.json's `landing` block, every key optional:
59
67
 
package/worker/index.mjs CHANGED
@@ -25,6 +25,7 @@
25
25
  * /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
26
26
  * knocks for a Homie box, so it stops knocking (a studio site is not a box)
27
27
  * /.well-known/homie-studio.json what the homie.rocks directory reads
28
+ * /games/<id>/source.json a public game's source for remixing, with its credit and licence (worker/license.mjs)
28
29
  * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
29
30
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
30
31
  * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
@@ -74,6 +75,7 @@ import {
74
75
  // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
75
76
  usePlayers(playerAccounts);
76
77
  import { STUDIO_VERSION_TAG } from './version.mjs';
78
+ import { licenseOf, remixAllowed, remixRow } from './license.mjs';
77
79
 
78
80
  export { SEAT_MAX } from './seats.mjs';
79
81
  /** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
@@ -463,12 +465,17 @@ async function route(request, env, ctx) {
463
465
  games: await Promise.all((cat.games ?? []).map(async (g) => {
464
466
  // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
465
467
  const cover = gameCover(g);
466
- const remix = remixOf(g, await settingsOf(env));
468
+ // Open to remix: the owner's switch is on and the licence the owner picked allows it (worker/license.mjs).
469
+ const license = licenseOf(g.license);
470
+ const remix = remixOf(g, await settingsOf(env)) && remixAllowed(license);
471
+ const lineage = remixRow(g.remixOf);
467
472
  return {
468
473
  id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
469
474
  page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
470
475
  // The owner's remix switch (0.13.0): whether the source is open for other studios to remix, and where.
471
- remix, ...(remix ? { source: `${url.origin}/games/${g.id}/source.json` } : {}),
476
+ remix, ...(remix ? { source: `${url.origin}/games/${g.id}/source.json` } : {}), license,
477
+ // A remix names what it is a remix of (its game.json `remixOf`): "Remix of <name> by <studio>", linked.
478
+ ...(lineage ? { remixOf: lineage } : {}),
472
479
  // Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
473
480
  ...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
474
481
  };
@@ -507,7 +514,9 @@ async function route(request, env, ctx) {
507
514
  return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
508
515
  }
509
516
 
510
- // A game's remix source is served while the game is public and its owner has not withdrawn it (the remix switch).
517
+ // A game's remix source is served while the game is public and its owner has not withdrawn it (the remix switch),
518
+ // with who made it as this site says it (the studio's name, the game's name and page here) and the owner's licence
519
+ // (worker/license.mjs): the remix flow credits the first and refuses a game whose licence says no remix.
511
520
  const src = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(path);
512
521
  if (src) {
513
522
  const all = await getAll();
@@ -515,6 +524,16 @@ async function route(request, env, ctx) {
515
524
  if (meta) {
516
525
  const settings = await settingsOf(env, { fresh: true });
517
526
  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);
527
+ if (request.method === 'GET') {
528
+ const res = await env.ASSETS.fetch(new Request(`${url.origin}${path}`));
529
+ let body = null;
530
+ if (res.ok) { try { body = await res.json(); } catch { body = null; } }
531
+ if (body?.kind === 'homie-game-source' && body.files && typeof body.files === 'object') {
532
+ const credit = { studio: all.studio?.name ?? body.credit?.studio ?? null, game: meta.name ?? body.credit?.game ?? meta.id, page: `${url.origin}/${meta.id}/` };
533
+ const { files, ...head } = body;
534
+ return json({ ...head, credit, license: licenseOf(body.license ?? meta.license), files }, 200, { 'access-control-allow-origin': '*' });
535
+ }
536
+ }
518
537
  }
519
538
  }
520
539
 
@@ -608,7 +627,7 @@ async function route(request, env, ctx) {
608
627
  shareDaily(cat, url, ctx);
609
628
  const { rooms, live } = await roomsOf(env, [meta]);
610
629
  const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
611
- return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week });
630
+ return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings) });
612
631
  }
613
632
  if (sub === 'live') {
614
633
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A GAME'S SOURCE LICENCE AND LINEAGE, shared by the site Worker (worker/index.mjs, worker/site.mjs) and the CLI
3
+ * (lib/build.mjs, lib/studio.mjs).
4
+ *
5
+ * The licence is the owner's pick (game.json `"license"`), carried in the game's source.json beside who made it
6
+ * (`credit`: studio, game, page), so a remix knows what it may do and whom to credit:
7
+ *
8
+ * "remix-with-credit" the default: remix it, and the remix says "Remix of <game> by <studio>" with a link back
9
+ * "remix-freely" remix it; the credit is welcome, not asked for (the remix flow still writes it)
10
+ * "no-remix" the source can be read, not remixed: the remix flow refuses
11
+ *
12
+ * Either the word, or { "kind": "<word>", "spdx": "<SPDX id>" } to name a licence too (e.g. "MIT", "CC-BY-4.0"); a
13
+ * bare SPDX id ("MIT") is the default kind with that id. Anything else is the default.
14
+ *
15
+ * A remix's game.json `remixOf` (written by `homie-studio game remix`) is its lineage: the original's name, studio
16
+ * and page, shown on the remix's landing ("Remix of <game> by <studio>") and in its credits.
17
+ */
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
+ const SPDX = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/;
25
+
26
+ /** { kind, spdx } from what game.json (or a source.json) says. */
27
+ export function licenseOf(value) {
28
+ const v = typeof value === 'string'
29
+ ? (Object.hasOwn(LICENSE_KINDS, value.trim()) ? { kind: value.trim() } : { spdx: value })
30
+ : value && typeof value === 'object' ? value : {};
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 };
34
+ }
35
+
36
+ /** "Remix with credit (MIT)". */
37
+ export function licenseLabel(value) {
38
+ const l = licenseOf(value);
39
+ return `${LICENSE_KINDS[l.kind]}${l.spdx ? ` (${l.spdx})` : ''}`;
40
+ }
41
+
42
+ /** Whether the owner lets the game be remixed. */
43
+ export const remixAllowed = (value) => licenseOf(value).kind !== 'no-remix';
44
+
45
+ const line = (v, max) => (typeof v === 'string' && v.trim() ? v.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, max) : null);
46
+ /** An https address, else null. */
47
+ export function httpsPage(v) {
48
+ try { const u = new URL(String(v ?? '')); return u.protocol === 'https:' ? u.href : null; } catch { return null; }
49
+ }
50
+
51
+ /** A game's page from its source address: https://<site>/games/<id>/source.json is https://<site>/<id>/. */
52
+ export function pageOfSource(source) {
53
+ try {
54
+ const u = new URL(String(source ?? ''));
55
+ const m = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(u.pathname);
56
+ return u.protocol === 'https:' && m ? `${u.origin}/${m[1]}/` : null;
57
+ } catch { return null; }
58
+ }
59
+
60
+ /**
61
+ * What a game.json `remixOf` may show: the original's name, studio and page (an https link; a remix made before
62
+ * 0.14.4 has only its source address, which names the page too), plain text only.
63
+ */
64
+ export function remixRow(r) {
65
+ if (!r || typeof r !== 'object') return null;
66
+ const name = line(r.name, 80);
67
+ return name ? { name, studio: line(r.studio, 80), page: httpsPage(r.page) ?? pageOfSource(r.source) } : null;
68
+ }
69
+
70
+ /** "Remix of Gem Rush by Night Owl Games" (the studio left out when the source named none). */
71
+ export function remixCredit(r) {
72
+ const row = remixRow(r);
73
+ return row ? `Remix of ${row.name}${row.studio ? ` by ${row.studio}` : ''}` : null;
74
+ }
@@ -157,7 +157,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
157
157
  act('/_studio/api/game', { game: g.id, maxPlayers: n >= g.seats ? null : n }, 'Rooms of ' + g.name + ' now hold ' + Math.min(n, g.seats) + '.');
158
158
  });
159
159
  add(box, st,
160
- add(el('label'), remix, el('span', '', g.remixBuilt ? 'Remixable' : 'Remixable (not in the build)')),
160
+ 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')),
161
161
  add(el('label'), el('span', 'dim', 'Players per room'), max, el('span', 'faint', 'of ' + g.seats), setMax),
162
162
  add(el('span', 'faint'), document.createTextNode(g.launchFrom === 'game.json' ? 'state from game.json' : '')));
163
163
  return box;
package/worker/office.mjs CHANGED
@@ -41,6 +41,7 @@ import { SEAT_MAX, seatsOf } from './seats.mjs';
41
41
  import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession } from './stats.mjs';
42
42
  import { esc, layout, notFoundPage } from './site.mjs';
43
43
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
44
+ import { licenseOf } from './license.mjs';
44
45
 
45
46
  export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
46
47
 
@@ -455,7 +456,7 @@ export async function officeView(env, cat, origin) {
455
456
  rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
456
457
  return {
457
458
  id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
458
- remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false,
459
+ remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
459
460
  seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
460
461
  play: `${origin}/${g.id}/play`, page: `${origin}/${g.id}/`,
461
462
  invites: launch === 'invite' || (await hasInvites(env, g.id)) ? await invitesOf(env, g.id, origin) : [],
package/worker/site.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { qrSvg } from './qr.mjs';
13
13
  import { STUDIO_VERSION_TAG } from './version.mjs';
14
+ import { licenseLabel, licenseOf, remixRow } from './license.mjs';
14
15
 
15
16
  export const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
16
17
  const jsonScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
@@ -122,6 +123,8 @@ img,video{display:block;max-width:100%}
122
123
  text-shadow:0 0 38px color-mix(in srgb,var(--hot) 50%,transparent),0 2px 0 color-mix(in srgb,var(--bg) 60%,transparent)}
123
124
  .title.t-long{font-size:clamp(44px,12vw,64px)}
124
125
  .title.t-xlong{font-size:clamp(38px,10vw,54px)}
126
+ .remix-of{margin:12px 0 0;font:600 14px/1.4 var(--text);color:var(--soft)}
127
+ .remix-of a{color:inherit;text-decoration:underline;text-underline-offset:3px}
125
128
  .line{margin:14px 0 22px;max-width:36ch;font:500 17px/1.45 var(--text);color:var(--soft);text-wrap:pretty}
126
129
  .play{display:flex;width:100%;align-items:center;justify-content:center;gap:12px;min-height:62px;padding:0 30px;border-radius:18px;
127
130
  background:var(--hot);color:var(--hot-ink);text-decoration:none;font:800 20px/1 var(--display);letter-spacing:-.01em;
@@ -908,9 +911,15 @@ export function jsonFeed(cat, posts, origin) {
908
911
  * public room, phone / computer / TV (with the join code), live rooms, how to play, credits, and "Make a game like
909
912
  * this". A studio's site/pages/<id>/index.html replaces it; site/partials/game.html and game-<id>.html add a band.
910
913
  */
911
- export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null } = {}) {
914
+ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null, remix = true } = {}) {
912
915
  const name = studioName(cat);
913
916
  const L = g.landing ?? {};
917
+ // A remix says what it is a remix of, linked, under its name and in its credits (game.json `remixOf`).
918
+ const lineage = remixRow(g.remixOf);
919
+ 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)}` : ''}` : '';
920
+ // Open to remix: the source is in the build, the owner's switch is on, and the licence they picked allows it.
921
+ const license = licenseOf(g.license);
922
+ const remixable = Boolean(L.source) && remix !== false && license.kind !== 'no-remix';
914
923
  const h = L.hero ?? {};
915
924
  const host = origin.replace(/^https?:\/\//, '');
916
925
  const playUrl = `${origin}/${g.id}/play`;
@@ -929,6 +938,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
929
938
  <div class="hero-copy">
930
939
  <p class="kicker">${kickerHtml}</p>
931
940
  <h1 class="title ${titleClass(g.name)}" id="game-title">${esc(g.name)}</h1>
941
+ ${lineage ? `<p class="remix-of">Remix of ${ofWhat}</p>` : ''}
932
942
  <p class="line">${esc(L.pitch ?? g.blurb)}</p>
933
943
  <div class="row">
934
944
  <a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
@@ -979,27 +989,28 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
979
989
  const o = C.original;
980
990
  const creditCards = [
981
991
  `<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>`,
992
+ 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>` : '',
982
993
  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>` : '',
983
994
  (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>` : '',
984
- `<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>${C.texts ? `<p><a href="/${esc(g.id)}/credits">Licences and full credits</a></p>` : ''}</div>`,
995
+ `<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>`,
985
996
  ].filter(Boolean).join('');
986
997
  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>`;
987
998
 
988
999
  const source = `${origin}/games/${g.id}/source.json`;
989
- const say = L.source
1000
+ const say = remixable
990
1001
  ? `Remix ${g.name} from ${source} into a game of my own in my Homie studio`
991
1002
  : `Make a multiplayer game like ${g.name} in my Homie studio`;
992
- const makeHref = L.source ? `https://homie.rocks/studio/?remix=${encodeURIComponent(source)}` : 'https://homie.rocks/studio/';
1003
+ const makeHref = remixable ? `https://homie.rocks/studio/?remix=${encodeURIComponent(source)}` : 'https://homie.rocks/studio/';
993
1004
  const makeBand = `<section class="band" aria-labelledby="make-title"><div class="band-in"><div class="make reveal">
994
- <p class="kicker">${L.source ? 'Open to remix' : 'Your turn'}</p>
1005
+ <p class="kicker">${remixable ? 'Open to remix' : 'Your turn'}</p>
995
1006
  <h2 id="make-title">Make a game like this</h2>
996
- <p class="lead">${L.source ? `${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>
1007
+ <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>
997
1008
  <ol class="say">
998
1009
  <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>
999
1010
  <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>
1000
1011
  <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>
1001
1012
  </ol>
1002
- <div class="keys"><a class="btn" href="${esc(makeHref)}">${icon('spark')}<span>Make a game like this</span></a>${L.source ? `<a class="ghost" href="/games/${esc(g.id)}/source.json">See the source</a>` : ''}</div>
1013
+ <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>
1003
1014
  </div></div></section>`;
1004
1015
 
1005
1016
  // A partial that is its own <section> stands as it is; anything else sits in one of the page's bands.
@@ -1018,10 +1029,12 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1018
1029
  /** /<game>/credits: the licence texts the game ships with (CREDITS.md and every licence file credits.json names). */
1019
1030
  export function creditsPage(cat, g, texts, { origin = '' } = {}) {
1020
1031
  const name = studioName(cat);
1032
+ const lineage = remixRow(g.remixOf);
1033
+ 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>` : '';
1021
1034
  return layout(cat, {
1022
1035
  title: `Credits · ${g.name} · ${name}`, description: `Credits and licences for ${g.name}.`, origin, path: `/${g.id}/credits`, page: 'credits', active: 'games',
1023
1036
  main: `${pageHead(g.name, 'Credits and licences', null, `<div class="keys"><a class="ghost" href="/${esc(g.id)}/">${icon('arrow')}<span>Back to ${esc(g.name)}</span></a></div>`)}
1024
- <div class="wrap">${texts.map(({ file, text }) => `<p class="sec">${esc(file)}</p><pre class="licence">${esc(text)}</pre>`).join('')}</div>`,
1037
+ <div class="wrap">${of}${texts.map(({ file, text }) => `<p class="sec">${esc(file)}</p><pre class="licence">${esc(text)}</pre>`).join('')}</div>`,
1025
1038
  });
1026
1039
  }
1027
1040
 
@@ -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.14.3';
2
+ export const STUDIO_VERSION_TAG = '0.14.4';