@homie-rocks/studio 0.14.2 → 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 +7 -5
- package/bin/homie-studio.mjs +1 -1
- package/lib/build.mjs +12 -4
- package/lib/files.mjs +7 -4
- package/lib/mcp-tools.mjs +99 -9
- package/lib/mcp.mjs +52 -1
- package/lib/pictures.mjs +67 -0
- package/lib/progress.mjs +14 -2
- package/lib/scaffold.mjs +6 -0
- package/lib/studio.mjs +23 -3
- package/lib/template-history.json +54 -0
- package/package.json +1 -1
- package/site/SITE.md +13 -5
- package/worker/index.mjs +23 -4
- package/worker/license.mjs +74 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/site.mjs +21 -8
- package/worker/version.mjs +1 -1
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
|
|
87
|
-
|
|
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.
|
|
104
|
-
npx -y @homie-rocks/studio@0.14.
|
|
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.
|
|
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
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
68
|
-
|
|
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/files.mjs
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
import { PICTURE_MAX, PICTURE_TYPES, pictureFor } from './pictures.mjs';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
const
|
|
12
|
+
// A picture is at most PICTURE_MAX raw in an answer (lib/pictures.mjs): a bigger one goes as a smaller JPEG copy.
|
|
13
|
+
export const FILE_LIMITS = Object.freeze({ write: 2 * 1024 * 1024, read: 400 * 1024, image: PICTURE_MAX, lines: 2000, list: 600, matches: 200 });
|
|
14
|
+
const IMAGES = PICTURE_TYPES;
|
|
13
15
|
const SKIP_DIRS = new Set(['node_modules', '.git', '.wrangler', '.studio', 'dist']);
|
|
14
16
|
|
|
15
17
|
const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
@@ -79,8 +81,9 @@ export function readStudioFile(root, path, { offset = 1, limit = FILE_LIMITS.lin
|
|
|
79
81
|
if (st.isDirectory()) throw new Error(`${rel} is a folder (file_list lists it)`);
|
|
80
82
|
const mime = IMAGES[extname(abs).toLowerCase()];
|
|
81
83
|
if (mime) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
// Small enough for one answer (the Claude desktop app refuses one over 1 MB): a big picture goes as a smaller copy.
|
|
85
|
+
const pic = pictureFor(abs, { label: rel });
|
|
86
|
+
return { rel, image: { mimeType: pic.mimeType, data: pic.data }, bytes: pic.bytes, shrunk: pic.shrunk, from: pic.from ?? st.size };
|
|
84
87
|
}
|
|
85
88
|
if (st.size > FILE_LIMITS.read * 8) throw new Error(`${rel} is ${Math.round(st.size / 1024)} KB: too big to read here`);
|
|
86
89
|
const buf = readFileSync(abs);
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* file tools never leave it (lib/files.mjs).
|
|
14
14
|
*/
|
|
15
15
|
import { spawnSync } from 'node:child_process';
|
|
16
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
16
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
19
19
|
import { renderCodex, newCodex } from './codex.mjs';
|
|
@@ -399,6 +399,80 @@ async function startDev(ctx, root, port) {
|
|
|
399
399
|
return { url: `http://127.0.0.1:${at}`, job, slow: true };
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/* ------------------------------------------------------------------ an earlier attempt, folded in */
|
|
403
|
+
|
|
404
|
+
/** Where a person keeps projects, looked at two levels deep for an earlier folder of a studio's name. */
|
|
405
|
+
const PROJECT_ROOTS = ['dev', 'Developer', 'code', 'Code', 'src', 'projects', 'Projects', 'repos', 'git', 'work', 'Documents', 'Desktop'];
|
|
406
|
+
const TEXT_NOTES = new Set(['.md', '.markdown', '.txt', '.json', '.yaml', '.yml', '.toml', '.csv', '.html', '.css', '.js', '.mjs', '.ts', '.tsx']);
|
|
407
|
+
const SKIP = new Set(['node_modules', '.git', 'Library', 'dist', '.studio', '.wrangler']);
|
|
408
|
+
|
|
409
|
+
/** Folders named like the studio that are not studios: [{ path, files }] (at most 3). */
|
|
410
|
+
export function earlierFolders(ctx, names, own, home = homedir()) {
|
|
411
|
+
const want = new Set(names.filter(Boolean).map((n) => String(n).toLowerCase()));
|
|
412
|
+
const roots = [ctx.studiosDir, home, ...PROJECT_ROOTS.map((r) => join(home, r))].filter((r) => r && existsSync(r));
|
|
413
|
+
const found = new Map();
|
|
414
|
+
const look = (dir, depth) => {
|
|
415
|
+
let entries = [];
|
|
416
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
417
|
+
for (const e of entries) {
|
|
418
|
+
if (found.size >= 3) return;
|
|
419
|
+
if (!e.isDirectory() || e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
420
|
+
const p = join(dir, e.name);
|
|
421
|
+
if (want.has(e.name.toLowerCase()) && realOr(p) !== realOr(own) && !existsSync(join(p, 'studio.json'))) {
|
|
422
|
+
let files = [];
|
|
423
|
+
try { files = readdirSync(p).filter((f) => !f.startsWith('.')).slice(0, 12); } catch { files = []; }
|
|
424
|
+
found.set(realOr(p), { path: realOr(p), files });
|
|
425
|
+
} else if (depth > 1 && entries.length < 400) look(p, depth - 1);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
for (const r of [...new Set(roots.map(realOr))]) look(r, r === realOr(home) ? 1 : 2);
|
|
429
|
+
return [...found.values()];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** studio_fold: the earlier folder's notes into notes/earlier/, or (remove) the folder into the Trash. */
|
|
433
|
+
async function foldEarlier(ctx, root, a) {
|
|
434
|
+
const from = realOr(expandHome(a.from) ?? '');
|
|
435
|
+
const known = ctx.note(root).earlier ?? earlierFolders(ctx, [readStudio(root).name, readStudio(root).slug, basename(root)], root).map((e) => e.path);
|
|
436
|
+
if (!known.includes(from)) return fail(`${a.from} is not an earlier folder found for this studio (${known.join(', ') || 'none was found'}); nothing was touched`);
|
|
437
|
+
if (!existsSync(from)) return fail(`${from} is not there any more`);
|
|
438
|
+
if (a.remove === true) {
|
|
439
|
+
const trash = join(homedir(), '.Trash');
|
|
440
|
+
if (process.platform !== 'darwin' || !existsSync(trash)) return fail(`Moving a folder to the Trash works on a Mac only: the person can remove ${from} themselves.`);
|
|
441
|
+
const to = join(trash, `${basename(from)} ${new Date().toISOString().slice(0, 19).replace(/:/g, '.')}`);
|
|
442
|
+
try { renameSync(from, to); } catch (error) { return fail(`${from} could not be moved to the Trash (${error.code ?? error.message}): the person can remove it themselves`); }
|
|
443
|
+
ctx.note(root).earlier = known.filter((p) => p !== from);
|
|
444
|
+
return ok(`Moved ${from} to the Trash (as "${basename(to)}"); the person can put it back from there.`);
|
|
445
|
+
}
|
|
446
|
+
const dest = join(root, 'notes', 'earlier', basename(from));
|
|
447
|
+
const copied = [];
|
|
448
|
+
let total = 0;
|
|
449
|
+
const walk = (dir, rel) => {
|
|
450
|
+
let entries = [];
|
|
451
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
452
|
+
for (const e of entries) {
|
|
453
|
+
if (copied.length >= 60 || total > 2 * 1024 * 1024) return;
|
|
454
|
+
if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
455
|
+
const p = join(dir, e.name);
|
|
456
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
457
|
+
if (e.isDirectory()) { walk(p, r); continue; }
|
|
458
|
+
if (!e.isFile() || !TEXT_NOTES.has(extname(e.name).toLowerCase())) continue;
|
|
459
|
+
const size = statSync(p).size;
|
|
460
|
+
if (size > 200 * 1024) continue;
|
|
461
|
+
mkdirSync(dirname(join(dest, r)), { recursive: true });
|
|
462
|
+
copyFileSync(p, join(dest, r));
|
|
463
|
+
copied.push(r); total += size;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
walk(from, '');
|
|
467
|
+
let shown = '';
|
|
468
|
+
for (const r of copied.filter((f) => /\.(md|markdown|txt)$/i.test(f))) {
|
|
469
|
+
if (shown.length > 30_000) break;
|
|
470
|
+
shown += `\n--- ${r}\n${readFileSync(join(dest, r), 'utf8').split('\n').slice(0, 80).join('\n')}\n`;
|
|
471
|
+
}
|
|
472
|
+
ctx.note(root).changed = true;
|
|
473
|
+
return ok(`Copied ${copied.length} note file${copied.length === 1 ? '' : 's'} from ${from} into notes/earlier/${basename(from)}/ (the old folder is unchanged). Fold its premise into the plan with the person (the game's CODEX.md), then ask whether to remove the old folder (studio_fold with remove: true moves it to the Trash).${shown ? `\n${shown}` : ''}`, { kind: 'fold', from, to: `notes/earlier/${basename(from)}`, files: copied });
|
|
474
|
+
}
|
|
475
|
+
|
|
402
476
|
/* ------------------------------------------------------------------ a studio from GitHub */
|
|
403
477
|
|
|
404
478
|
/** owner/name of a checkout's origin remote, lowercased, or null. */
|
|
@@ -517,19 +591,34 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
517
591
|
// Compared on real paths (a temporary or linked folder may have two names).
|
|
518
592
|
const dir = join(realOr(dirname(asked)), basename(asked));
|
|
519
593
|
if (!inside(base, dir) || dir === base) return fail(`a studio goes in its own folder inside ${ctx.studiosDir}`);
|
|
594
|
+
// An earlier attempt for this name (a folder that is not a studio: notes, a plan, a charter), here or in the
|
|
595
|
+
// usual project folders: the person is offered to fold its premise in (studio_fold), never surprised by it.
|
|
596
|
+
const earlier = earlierFolders(ctx, [name, slugify(name), basename(dir)], dir);
|
|
597
|
+
let at = dir;
|
|
598
|
+
if (existsSync(at) && readdirSync(at).some((f) => !['.DS_Store', '.git'].includes(f)) && !existsSync(join(at, 'studio.json'))) at = `${dir}-studio`;
|
|
520
599
|
let made;
|
|
521
|
-
try { made = newStudio(
|
|
600
|
+
try { made = newStudio(at, { name, install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
|
|
522
601
|
ctx.current = made.dir;
|
|
602
|
+
ctx.note(made.dir).earlier = earlier.map((e) => e.path);
|
|
523
603
|
let install = null;
|
|
524
604
|
if (ctx.install) {
|
|
525
605
|
try { install = installJob(made.dir); } catch (error) { return fail(`${name} is made at ${made.dir}, but ${error.message}`); }
|
|
526
606
|
await waitJob(install, Math.min(ctx.waitMs, 35_000));
|
|
527
607
|
}
|
|
528
608
|
const r = await setupCard(ctx, made.dir, { install, made: { dir: made.dir, wrote: made.wrote } });
|
|
529
|
-
|
|
609
|
+
const fold = earlier.length ? `\n\nAn earlier folder for this name is on this computer, and it is not a studio: ${earlier.map((e) => `${e.path} (${e.files.join(', ') || 'empty'})`).join('; ')}. ASK the person: fold its premise into ${name}? With their yes, studio_fold { "from": "<that folder>" } copies its notes into the studio (notes/earlier/) and shows them to you, so the plan can use them. Then, only with another yes, studio_fold { "from": "<that folder>", "remove": true } moves the old folder to the Trash.` : '';
|
|
610
|
+
r.content[0].text = `Made ${name} at ${made.dir} (no game yet: its home page says "First game coming soon").\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}${fold}\n\n${r.content[0].text}`;
|
|
611
|
+
if (r.structuredContent) r.structuredContent.earlier = earlier;
|
|
530
612
|
return r;
|
|
531
613
|
},
|
|
532
614
|
},
|
|
615
|
+
{
|
|
616
|
+
name: 'studio_fold', title: 'Fold an earlier folder into the studio',
|
|
617
|
+
description: 'An earlier folder for this studio\'s name that is not a studio (one studio_scaffold found and named): copy its notes into the studio (notes/earlier/<folder>/, text files only) and read them, so its premise goes into the plan; or, with remove: true, move the old folder to the Trash. Only with the person\'s yes for each; it never touches any other folder.',
|
|
618
|
+
inputSchema: { type: 'object', properties: { from: str('The earlier folder studio_scaffold named'), remove: { type: 'boolean', description: 'Move the earlier folder to the Trash (only with the person\'s yes)' }, ...STUDIO_ARG }, required: ['from'] },
|
|
619
|
+
annotations: { title: 'Fold an earlier folder in', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
620
|
+
run: async (a) => foldEarlier(ctx, ctx.root(a.studio), a),
|
|
621
|
+
},
|
|
533
622
|
{
|
|
534
623
|
name: 'studio_install', title: 'Install the studio\'s dependencies',
|
|
535
624
|
description: 'npm install in the studio: its pinned toolkit and Wrangler, and anything its package.json gained (after an upgrade, a new pin, or a library a game needs). Runs in the background; studio_job follows it.',
|
|
@@ -569,14 +658,14 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
569
658
|
},
|
|
570
659
|
{
|
|
571
660
|
name: 'game_demo', title: 'See a working game',
|
|
572
|
-
description: 'Step 2: a live multiplayer game on Homie Arcade to play right now, made with this same toolkit, with NOTHING copied into the studio. Give the person the Play link:
|
|
661
|
+
description: 'Step 2: a live multiplayer game on Homie Arcade to play right now, made with this same toolkit, with NOTHING copied into the studio. Give the person the Play link: THEY open it (the link in your reply, or Play on a card); never open a browser or run a command to open it yourself. Two browser tabs (or a phone and a computer) are two players in the same room; bots fill the empty seats. Copy a starter into the studio (game_make) only if they ask.',
|
|
573
662
|
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
574
663
|
annotations: { title: 'See a working game', ...RO, openWorldHint: true },
|
|
575
664
|
run: async (a) => {
|
|
576
665
|
const root = ctx.root(a.studio, { need: false });
|
|
577
666
|
const r = await demoGames();
|
|
578
667
|
if (root) ctx.note(root).demo = true;
|
|
579
|
-
return ok(formatDemo(r), { kind: 'demo', ...r });
|
|
668
|
+
return ok(`${formatDemo(r)}\n\nFor you, the AI: give the person this Play link in your reply; they open it themselves. Do not open a browser or run any command to open it.`, { kind: 'demo', ...r });
|
|
580
669
|
},
|
|
581
670
|
},
|
|
582
671
|
{
|
|
@@ -595,7 +684,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
595
684
|
},
|
|
596
685
|
{
|
|
597
686
|
name: 'game_remix', title: 'Remix a shared game',
|
|
598
|
-
description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own
|
|
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.',
|
|
599
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'] },
|
|
600
689
|
annotations: { title: 'Remix a game', ...RW, openWorldHint: true },
|
|
601
690
|
run: async (a) => {
|
|
@@ -607,7 +696,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
607
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)] : [])]);
|
|
608
697
|
if (!r.ended) return stillRunning(r.job, 'The remix');
|
|
609
698
|
if (r.job.code !== 0) return fail(`Not remixed: ${whyOf(r.job)}`);
|
|
610
|
-
|
|
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 });
|
|
611
701
|
},
|
|
612
702
|
},
|
|
613
703
|
{
|
|
@@ -705,7 +795,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
705
795
|
return ok([
|
|
706
796
|
`${slow ? 'Starting' : 'Running'} here: ${url}/${games.length ? '' : ' (the home page: "First game coming soon")'}`,
|
|
707
797
|
...games.map((x) => ` ${x.name ?? x.id}: ${url}/${x.id}/play`),
|
|
708
|
-
g ? `
|
|
798
|
+
g ? `The person opens ${url}/${g.id}/play in two browser tabs: two players, one room. Give them the address; do not open a browser for them.` : 'Give the person the address; do not open a browser for them.',
|
|
709
799
|
].filter(Boolean).join('\n'), { kind: 'preview', url, games: games.map((x) => ({ id: x.id, play: `${url}/${x.id}/play` })) });
|
|
710
800
|
},
|
|
711
801
|
},
|
|
@@ -901,7 +991,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
901
991
|
annotations: { title: 'Read a file', ...RO },
|
|
902
992
|
run: async (a) => {
|
|
903
993
|
const r = readStudioFile(ctx.root(a.studio), a.path, { offset: a.offset, limit: a.limit });
|
|
904
|
-
if (r.image) return { content: [{ type: 'image', data: r.image.data, mimeType: r.image.mimeType }, { type: 'text', text: `${r.rel} (${Math.round(r.bytes / 1024)} KB)` }] };
|
|
994
|
+
if (r.image) return { content: [{ type: 'image', data: r.image.data, mimeType: r.image.mimeType }, { type: 'text', text: `${r.rel} (${r.shrunk ? `a smaller JPEG copy, ${Math.round(r.bytes / 1024)} KB, of a ${Math.round(r.from / 1024)} KB picture; the file is unchanged` : `${Math.round(r.bytes / 1024)} KB`})` }] };
|
|
905
995
|
return ok(`${r.rel} (lines ${r.from}-${r.to} of ${r.lines}${r.more ? '; more with offset' : ''})\n${r.text}`);
|
|
906
996
|
},
|
|
907
997
|
},
|
package/lib/mcp.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { join } from 'node:path';
|
|
|
22
22
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
23
23
|
import { StudioContext, UI, availability, toolDefs } from './mcp-tools.mjs';
|
|
24
24
|
import { stopAllJobs, toolPath } from './jobs.mjs';
|
|
25
|
+
import { shrinkPictureData } from './pictures.mjs';
|
|
25
26
|
import { STUDIO_VERSION } from './version.mjs';
|
|
26
27
|
|
|
27
28
|
export const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
@@ -39,8 +40,12 @@ A new studio follows one checklist, in order and never ahead. Show it in your fi
|
|
|
39
40
|
6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
|
|
40
41
|
If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.
|
|
41
42
|
|
|
43
|
+
If studio_scaffold finds an earlier folder of the studio's name that is not a studio, ask the person whether to fold its premise in (studio_fold), and remove it only with a second yes.
|
|
44
|
+
|
|
42
45
|
A studio made elsewhere (on a phone, with Deploy to Cloudflare, so it lives on GitHub): studio_open with its repo clones it into the studios folder with this computer's own GitHub sign-in; github_login signs the computer in with GitHub's one-time code. Never ask for a token.
|
|
43
46
|
|
|
47
|
+
Links are the person's to open: give them in your reply (the cards have their own buttons). Never open a browser or run a command such as open to open one yourself.
|
|
48
|
+
|
|
44
49
|
Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
|
|
45
50
|
|
|
46
51
|
const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js' };
|
|
@@ -64,6 +69,52 @@ export function cardCsp(uri) {
|
|
|
64
69
|
return { connectDomains: [], resourceDomains: [] };
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/*
|
|
73
|
+
* ONE ANSWER, AT MOST RESULT_MAX. The Claude desktop app refuses a tool result over 1 MB. Every tools/call answer goes
|
|
74
|
+
* through fitResult on its way out: pictures first (a smaller copy, else left out), then the card's pictures, then the
|
|
75
|
+
* text (cut, and saying so), and last the card's data itself. What was left out is said in the answer's text.
|
|
76
|
+
*/
|
|
77
|
+
export const RESULT_MAX = 900_000;
|
|
78
|
+
const sizeOf = (r) => JSON.stringify(r).length;
|
|
79
|
+
|
|
80
|
+
/** Every data: URL in a card's data (a feed's preview, a codex's art, a studio's covers) taken out. */
|
|
81
|
+
function withoutDataUrls(v) {
|
|
82
|
+
if (typeof v === 'string') return v.startsWith('data:') && v.length > 1024 ? null : v;
|
|
83
|
+
if (Array.isArray(v)) return v.map(withoutDataUrls);
|
|
84
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, withoutDataUrls(x)]));
|
|
85
|
+
return v;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function fitResult(result, { max = RESULT_MAX, shrink = shrinkPictureData } = {}) {
|
|
89
|
+
if (!result || typeof result !== 'object' || sizeOf(result) <= max) return result;
|
|
90
|
+
const r = JSON.parse(JSON.stringify(result));
|
|
91
|
+
const said = [];
|
|
92
|
+
const content = Array.isArray(r.content) ? r.content : (r.content = []);
|
|
93
|
+
for (let i = 0; i < content.length && sizeOf(r) > max; i++) {
|
|
94
|
+
const c = content[i];
|
|
95
|
+
if (c?.type !== 'image') continue;
|
|
96
|
+
const small = shrink(c.data, c.mimeType);
|
|
97
|
+
if (small && small.data.length < c.data.length) { content[i] = { ...c, ...small }; said.push('a picture went as a smaller copy'); }
|
|
98
|
+
else { content[i] = { type: 'text', text: '(A picture was left out: too big for one answer.)' }; said.push('a picture was left out'); }
|
|
99
|
+
}
|
|
100
|
+
if (sizeOf(r) > max && r.structuredContent) { r.structuredContent = withoutDataUrls(r.structuredContent); said.push('the card shows no pictures this time'); }
|
|
101
|
+
if (sizeOf(r) > max) {
|
|
102
|
+
// The longest text goes down to what fits, cut at a line's end.
|
|
103
|
+
const texts = content.filter((c) => c?.type === 'text' && typeof c.text === 'string').sort((a, b) => b.text.length - a.text.length);
|
|
104
|
+
for (const t of texts) {
|
|
105
|
+
const over = sizeOf(r) - max;
|
|
106
|
+
if (over <= 0) break;
|
|
107
|
+
const keep = Math.max(0, t.text.length - over - 400);
|
|
108
|
+
const cut = t.text.slice(0, keep);
|
|
109
|
+
t.text = `${cut.slice(0, Math.max(cut.lastIndexOf('\n'), Math.floor(keep * 0.9)))}\n…`;
|
|
110
|
+
said.push('the text was cut');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (sizeOf(r) > max && r.structuredContent) { delete r.structuredContent; said.push('the card had no room for its data'); }
|
|
114
|
+
if (said.length) content.push({ type: 'text', text: `(This answer was over ${Math.round(max / 1000)} KB, the most one answer may be here: ${[...new Set(said)].join('; ')}.)` });
|
|
115
|
+
return r;
|
|
116
|
+
}
|
|
117
|
+
|
|
67
118
|
export const PROMPTS = [
|
|
68
119
|
{ name: 'new-studio', title: 'Set up a game studio', description: 'Make a game studio on this computer and go through Homie\'s new-studio checklist.', arguments: [{ name: 'name', description: 'The studio\'s name', required: false }] },
|
|
69
120
|
{ name: 'make-multiplayer', title: 'Make my game multiplayer', description: 'Port an existing single-player web game on this computer into a studio.', arguments: [{ name: 'folder', description: 'The game\'s folder', required: true }] },
|
|
@@ -122,7 +173,7 @@ export async function serveMcp({ studios = null, skills = null, cwd = process.cw
|
|
|
122
173
|
let result;
|
|
123
174
|
try { result = await tool.run(params?.arguments ?? {}); } catch (e) { result = { content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }], isError: true }; }
|
|
124
175
|
log(`${name} ${result?.isError ? 'failed' : 'ok'} in ${Date.now() - t0} ms`);
|
|
125
|
-
reply(result);
|
|
176
|
+
reply(fitResult(result));
|
|
126
177
|
// A media provider set up meanwhile adds its tool.
|
|
127
178
|
const before = names;
|
|
128
179
|
avail = availability();
|
package/lib/pictures.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PICTURES SMALL ENOUGH FOR ONE ANSWER. The Claude desktop app refuses a tool result over 1 MB, and a picture travels
|
|
3
|
+
* as base64 (a third bigger). So a picture a tool hands back is at most PICTURE_MAX raw: a bigger one becomes a
|
|
4
|
+
* smaller JPEG copy (ffmpeg, else macOS's own sips), stepping down in width until it fits; with neither, it is
|
|
5
|
+
* refused, naming the file, so the person can open it themselves. The original file is never changed.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { extname, join } from 'node:path';
|
|
11
|
+
import { which } from './jobs.mjs';
|
|
12
|
+
|
|
13
|
+
export const PICTURE_MAX = 600 * 1024;
|
|
14
|
+
const SOURCE_MAX = 40 * 1024 * 1024;
|
|
15
|
+
export const PICTURE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
16
|
+
const WIDTHS = [1600, 1200, 900, 640, 480];
|
|
17
|
+
|
|
18
|
+
/** Which shrinker this computer has: { kind, bin } or null. */
|
|
19
|
+
export function shrinker() {
|
|
20
|
+
const ff = which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
|
21
|
+
if (ff) return { kind: 'ffmpeg', bin: ff };
|
|
22
|
+
if (process.platform === 'darwin' && existsSync('/usr/bin/sips')) return { kind: 'sips', bin: '/usr/bin/sips' };
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shrinkTo(tool, src, out, width) {
|
|
27
|
+
const args = tool.kind === 'ffmpeg'
|
|
28
|
+
? ['-v', 'error', '-y', '-i', src, '-frames:v', '1', '-vf', `scale='min(${width},iw)':-2`, '-q:v', '6', out]
|
|
29
|
+
: ['-s', 'format', 'jpeg', '-s', 'formatOptions', '70', '-Z', String(width), src, '--out', out];
|
|
30
|
+
const r = spawnSync(tool.bin, args, { encoding: 'utf8', timeout: 30_000 });
|
|
31
|
+
return r.status === 0 && existsSync(out);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A picture file as MCP image content: { mimeType, data (base64), bytes, shrunk, width? }. Throws, naming `label`,
|
|
36
|
+
* when it cannot be made small enough here.
|
|
37
|
+
*/
|
|
38
|
+
export function pictureFor(path, { label = path, max = PICTURE_MAX, tool = shrinker() } = {}) {
|
|
39
|
+
const size = statSync(path).size;
|
|
40
|
+
const mime = PICTURE_TYPES[extname(path).toLowerCase()] ?? 'image/png';
|
|
41
|
+
if (size <= max) return { mimeType: mime, data: readFileSync(path).toString('base64'), bytes: size, shrunk: false };
|
|
42
|
+
if (size > SOURCE_MAX) throw new Error(`${label} is ${Math.round(size / 1024 / 1024)} MB: too big to look at here; the person can open it themselves`);
|
|
43
|
+
if (!tool) throw new Error(`${label} is ${Math.round(size / 1024)} KB, more than the ${Math.round(max / 1024)} KB a picture may be in one answer, and this computer has no ffmpeg (or sips) to make a smaller copy. The person can open it themselves: ${label}`);
|
|
44
|
+
const tmp = mkdtempSync(join(tmpdir(), 'homie-picture-'));
|
|
45
|
+
try {
|
|
46
|
+
for (const width of WIDTHS) {
|
|
47
|
+
const out = join(tmp, `w${width}.jpg`);
|
|
48
|
+
if (!shrinkTo(tool, path, out, width)) break;
|
|
49
|
+
const bytes = statSync(out).size;
|
|
50
|
+
if (bytes <= max) return { mimeType: 'image/jpeg', data: readFileSync(out).toString('base64'), bytes, shrunk: true, width, from: size };
|
|
51
|
+
}
|
|
52
|
+
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
53
|
+
throw new Error(`${label} could not be made smaller than ${Math.round(max / 1024)} KB here; the person can open it themselves: ${label}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A picture already in an answer (base64): a smaller copy that fits, or null when there is none to be had. */
|
|
57
|
+
export function shrinkPictureData(data, mimeType, { max = PICTURE_MAX, tool = shrinker() } = {}) {
|
|
58
|
+
if (!tool || typeof data !== 'string') return null;
|
|
59
|
+
const ext = Object.entries(PICTURE_TYPES).find(([, m]) => m === mimeType)?.[0] ?? '.png';
|
|
60
|
+
const tmp = mkdtempSync(join(tmpdir(), 'homie-picture-'));
|
|
61
|
+
try {
|
|
62
|
+
const src = join(tmp, `in${ext}`);
|
|
63
|
+
writeFileSync(src, Buffer.from(data, 'base64'));
|
|
64
|
+
const pic = pictureFor(src, { max, tool });
|
|
65
|
+
return { mimeType: pic.mimeType, data: pic.data };
|
|
66
|
+
} catch { return null; } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
67
|
+
}
|
package/lib/progress.mjs
CHANGED
|
@@ -83,6 +83,17 @@ const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image
|
|
|
83
83
|
|
|
84
84
|
const now = () => new Date().toISOString();
|
|
85
85
|
/** Plain text of at most `max` characters: no control characters, trimmed. */
|
|
86
|
+
/**
|
|
87
|
+
* Plain text of at most `max` characters, cut at the end of a word with "…" when it is longer: never mid-word, and
|
|
88
|
+
* never mid-number (a log line said "bots, up to)" once, its "6" cut off).
|
|
89
|
+
*/
|
|
90
|
+
export function clip(value, max) {
|
|
91
|
+
const t = plain(value, Number.MAX_SAFE_INTEGER);
|
|
92
|
+
if (t.length <= max) return t;
|
|
93
|
+
const cut = t.slice(0, max - 1);
|
|
94
|
+
const space = cut.lastIndexOf(' ');
|
|
95
|
+
return `${(space > max * 0.5 ? cut.slice(0, space) : cut).replace(/[\s,;:(\u2013\u2014-]+$/u, '')}…`;
|
|
96
|
+
}
|
|
86
97
|
export const plain = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
87
98
|
const dir = (root) => join(root, PROGRESS_DIR);
|
|
88
99
|
const docPath = (root, id) => join(dir(root), `${id}.json`);
|
|
@@ -219,7 +230,7 @@ export async function flushProgress(ms = 6000) {
|
|
|
219
230
|
/* ------------------------------------------------------------------ the document */
|
|
220
231
|
|
|
221
232
|
function pushLog(d, text) {
|
|
222
|
-
d.log = [...(d.log ?? []), { at: now(), text:
|
|
233
|
+
d.log = [...(d.log ?? []), { at: now(), text: clip(text, LIMITS.logLine) }].slice(-LIMITS.logLines);
|
|
223
234
|
}
|
|
224
235
|
|
|
225
236
|
function mutateDoc(root, id, fn) {
|
|
@@ -268,7 +279,8 @@ export class Feed {
|
|
|
268
279
|
if (note !== undefined) s.note = plain(note, LIMITS.note);
|
|
269
280
|
if (state === 'failed' && note) doc.error = plain(note, LIMITS.note);
|
|
270
281
|
if (state === 'running' || state === 'done') doc.error = null;
|
|
271
|
-
|
|
282
|
+
// The note is cut at a word's end, inside its own brackets, so the line keeps its closing ")".
|
|
283
|
+
pushLog(doc, `${s.label}: ${state}${note ? ` (${clip(note, LIMITS.logLine - s.label.length - state.length - 5)})` : ''}`);
|
|
272
284
|
}, { soon: true });
|
|
273
285
|
heartbeat(this.root, this.id, d?.stages?.some((s) => s.state === 'running') && d.state === 'running');
|
|
274
286
|
return d;
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
|
@@ -353,6 +353,60 @@
|
|
|
353
353
|
"## Beta": "9d02638207a26e73"
|
|
354
354
|
}
|
|
355
355
|
}
|
|
356
|
+
},
|
|
357
|
+
"0.14.3": {
|
|
358
|
+
"files": {
|
|
359
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
360
|
+
"README.md": "684b3cca8001673c",
|
|
361
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
362
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
363
|
+
"music/README.md": "b8a828de9311bd28",
|
|
364
|
+
"videos/README.md": "70614388cbb203c7",
|
|
365
|
+
"posts/README.md": "37841aae476bd540",
|
|
366
|
+
"site/README.md": "ca131cd0472464fb",
|
|
367
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
368
|
+
},
|
|
369
|
+
"sections": {
|
|
370
|
+
"AGENTS.md": {
|
|
371
|
+
"(intro)": "214aa46eb3a895d5",
|
|
372
|
+
"## Layout": "f14d641159480e6c",
|
|
373
|
+
"## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
|
|
374
|
+
"## The site": "7c1618431bae6324",
|
|
375
|
+
"## Making games": "63069b31c87a2aa2",
|
|
376
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
377
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
378
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
379
|
+
"## Rules": "969318425c99f5a5",
|
|
380
|
+
"## Beta": "9d02638207a26e73"
|
|
381
|
+
}
|
|
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
|
+
}
|
|
356
410
|
}
|
|
357
411
|
}
|
|
358
412
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.14.
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
56
|
-
`/games/<id>/source.json`, and
|
|
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
|
-
|
|
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
|
+
}
|
package/worker/office-page.mjs
CHANGED
|
@@ -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' : '
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 =
|
|
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 =
|
|
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">${
|
|
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">${
|
|
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>${
|
|
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
|
|
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.14.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.14.4';
|