@homie-rocks/studio 0.36.0 → 0.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +4 -2
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +19 -4
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +6 -3
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +13 -4
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +26 -9
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +8 -8
- package/lib/domain.mjs +43 -0
- package/lib/handoff.mjs +1 -1
- package/lib/jobs.mjs +17 -7
- package/lib/lab-check.mjs +1 -1
- package/lib/lab.mjs +8 -8
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +71 -21
- package/lib/mcp.mjs +6 -11
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/prepare.mjs +47 -0
- package/lib/scaffold.mjs +31 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +7 -2
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +82 -0
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/package.json +14 -3
- package/parts/PARTS.md +10 -5
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/embed.mjs +4 -2
- package/worker/index.mjs +33 -9
- package/worker/pages.mjs +33 -27
- package/worker/parts.mjs +11 -9
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
package/lib/jobs.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import { delimiter, dirname, join } from 'node:path';
|
|
19
19
|
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { nodeHome } from './prepare.mjs';
|
|
20
21
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
21
22
|
|
|
22
23
|
const WIN = process.platform === 'win32';
|
|
@@ -38,7 +39,7 @@ export function toolPath(env = process.env, platform = process.platform, home =
|
|
|
38
39
|
: ['/opt/homebrew/bin', '/usr/local/bin', join(home, '.volta', 'bin'), join(home, '.asdf', 'shims'), join(home, '.local', 'bin'), join(home, '.fnm', 'aliases', 'default', 'bin'), join(home, '.bun', 'bin'), ...nvm, '/usr/bin', '/bin'];
|
|
39
40
|
const seen = new Set();
|
|
40
41
|
const out = [];
|
|
41
|
-
for (const p of [...String(env.PATH ?? env.Path ?? '').split(delimiter), ...extra]) if (p && !seen.has(p)) { seen.add(p); out.push(p); }
|
|
42
|
+
for (const p of [...String(env.PATH ?? env.Path ?? '').split(delimiter), join(nodeHome(home), platform === 'win32' ? '' : 'bin'), ...extra]) if (p && !seen.has(p)) { seen.add(p); out.push(p); }
|
|
42
43
|
return out.join(delimiter);
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -52,6 +53,7 @@ export function findNode({ fresh = false } = {}) {
|
|
|
52
53
|
if (!process.versions.electron) candidates.push(process.execPath);
|
|
53
54
|
for (const dir of toolPath().split(delimiter)) candidates.push(join(dir, exe));
|
|
54
55
|
const tried = new Set();
|
|
56
|
+
let withoutNpm = null;
|
|
55
57
|
for (const bin of candidates) {
|
|
56
58
|
if (tried.has(bin) || !existsSync(bin)) continue;
|
|
57
59
|
tried.add(bin);
|
|
@@ -61,11 +63,12 @@ export function findNode({ fresh = false } = {}) {
|
|
|
61
63
|
const version = String(r.stdout ?? '').trim().replace(/^v/, '');
|
|
62
64
|
if (r.status === 0 && Number(version.split('.')[0]) >= 22) {
|
|
63
65
|
const npmNext = join(dirname(bin), WIN ? 'npm.cmd' : 'npm');
|
|
64
|
-
|
|
65
|
-
return nodeCache;
|
|
66
|
+
const found = { bin, version, npm: existsSync(npmNext) ? npmNext : which(WIN ? 'npm.cmd' : 'npm') };
|
|
67
|
+
if (found.npm) { nodeCache = found; return nodeCache; }
|
|
68
|
+
withoutNpm ??= found;
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
|
-
return
|
|
71
|
+
return withoutNpm;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
export function which(name) {
|
|
@@ -161,16 +164,23 @@ export function jobView(job, { lines = 12 } = {}) {
|
|
|
161
164
|
/** Run the studio's pinned CLI as a job: `homie-studio <args> --json`. */
|
|
162
165
|
export function cliJob(root, label, args, { cli = null, ...opts } = {}) {
|
|
163
166
|
const node = findNode();
|
|
164
|
-
if (!node) throw new Error('
|
|
167
|
+
if (!node) throw new Error('Node.js is missing. AI: call setup_prepare, then retry this tool.');
|
|
165
168
|
// `cli`: another homie-studio than the studio's pinned one (this package's, for an upgrade to it).
|
|
166
169
|
return startJob({ root, label, cmd: node.bin, args: [cli ?? cliOf(root), ...args, '--json'], json: true, ...opts });
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
/** Windows command shims need a shell; run npm's JavaScript with Node instead. */
|
|
173
|
+
export function npmInvocation(node, platform = process.platform) {
|
|
174
|
+
return platform === 'win32'
|
|
175
|
+
? { cmd: node.bin, args: [join(dirname(node.npm), 'node_modules', 'npm', 'bin', 'npm-cli.js'), 'install', '--no-audit', '--no-fund'] }
|
|
176
|
+
: { cmd: node.npm, args: ['install', '--no-audit', '--no-fund'] };
|
|
177
|
+
}
|
|
178
|
+
|
|
169
179
|
/** `npm install` in a studio (its pinned toolkit and Wrangler). */
|
|
170
180
|
export function installJob(root, opts = {}) {
|
|
171
181
|
const node = findNode();
|
|
172
|
-
if (!node?.npm) throw new Error('npm
|
|
173
|
-
return startJob({ root, label: 'npm install (the studio\'s pinned toolkit and Wrangler)',
|
|
182
|
+
if (!node?.npm) throw new Error('npm is missing. AI: call setup_prepare, then retry studio_install.');
|
|
183
|
+
return startJob({ root, label: 'npm install (the studio\'s pinned toolkit and Wrangler)', ...npmInvocation(node), ...opts });
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
/** When the server ends (the app quit), nothing it started keeps running, except what asked to (none today). */
|
package/lib/lab-check.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
25
25
|
import { join, relative } from 'node:path';
|
|
26
26
|
import { chromeArgs, findChrome, noChrome, SOFTWARE_GL } from './chrome.mjs';
|
|
27
27
|
import { labDir, startLabServer } from './lab.mjs';
|
|
28
|
-
import { isRulesGame, listGames } from './studio.mjs';
|
|
28
|
+
import { isRulesGame, listExperiences as listGames } from './studio.mjs';
|
|
29
29
|
|
|
30
30
|
const round = (v, d = 2) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
|
|
31
31
|
const pct = (xs, p) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] : null; };
|
package/lib/lab.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { createServer } from 'node:http';
|
|
|
28
28
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from 'node:fs';
|
|
29
29
|
import { extname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
30
30
|
import { buildGameFiles, studioEsbuild } from './build.mjs';
|
|
31
|
-
import { GAME_ID, PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
31
|
+
import { experienceDir, experienceFile, GAME_ID, PACKAGE_ROOT, listExperiences as listGames, readStudio } from './studio.mjs';
|
|
32
32
|
import { STUDIO_VERSION } from './version.mjs';
|
|
33
33
|
import { formatLabFile, formatTunables, readLabFile, checkTake, setTunables, tunableValue, TAKE_NAME } from '../lab/take.js';
|
|
34
34
|
|
|
@@ -123,16 +123,16 @@ export function commitOf(root, ref = 'HEAD') {
|
|
|
123
123
|
|
|
124
124
|
/** Whether games/<id>/ differs from HEAD (staged, changed or new files). */
|
|
125
125
|
export function dirtyOf(root, id) {
|
|
126
|
-
const r = git(root, ['status', '--porcelain', '--',
|
|
126
|
+
const r = git(root, ['status', '--porcelain', '--', relative(root, experienceDir(root, id))]);
|
|
127
127
|
return r.status === 0 ? r.stdout.trim().split('\n').filter(Boolean).length : null;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/** The paths of `sha` that New's bundle read from outside games/<id>/ (a shared folder), plus the game's own. */
|
|
131
131
|
function pathsFor(root, id, sha, metafile) {
|
|
132
|
-
const want = new Set([
|
|
132
|
+
const want = new Set([relative(root, experienceDir(root, id))]);
|
|
133
133
|
for (const input of Object.keys(metafile?.inputs ?? {})) {
|
|
134
134
|
const rel = input.split(sep).join('/');
|
|
135
|
-
if (rel.startsWith('node_modules/') || rel.startsWith('../') || rel.startsWith('/') || rel.startsWith(
|
|
135
|
+
if (rel.startsWith('node_modules/') || rel.startsWith('../') || rel.startsWith('/') || rel.startsWith(relative(root, experienceDir(root, id)) + '/') || rel.includes('/node_modules/')) continue;
|
|
136
136
|
want.add(rel);
|
|
137
137
|
}
|
|
138
138
|
for (const f of ['package.json', 'studio.json']) want.add(f);
|
|
@@ -146,7 +146,7 @@ function checkout(root, id, sha, paths) {
|
|
|
146
146
|
const dir = join(labDir(root, id), 'checkout');
|
|
147
147
|
const stamp = join(labDir(root, id), 'checkout.json');
|
|
148
148
|
const had = readJson(stamp);
|
|
149
|
-
if (had?.sha === sha && JSON.stringify(had.paths) === JSON.stringify(paths) && existsSync(
|
|
149
|
+
if (had?.sha === sha && JSON.stringify(had.paths) === JSON.stringify(paths) && existsSync(experienceDir(dir, id))) return dir;
|
|
150
150
|
removeCheckout(root, dir);
|
|
151
151
|
mkdirSync(labDir(root, id), { recursive: true });
|
|
152
152
|
const add = git(root, ['worktree', 'add', '--no-checkout', '--detach', dir, sha]);
|
|
@@ -154,8 +154,8 @@ function checkout(root, id, sha, paths) {
|
|
|
154
154
|
const co = git(dir, ['checkout', sha, '--', ...paths]);
|
|
155
155
|
if (co.status !== 0) throw new Error(`git could not check out games/${id} at ${sha.slice(0, 7)}: ${(co.stderr || co.stdout).trim().split('\n').pop()}`);
|
|
156
156
|
// A game with its own build (game.json build.mode "command") builds with its own node_modules: the working tree's.
|
|
157
|
-
const nm = join(root,
|
|
158
|
-
if (existsSync(nm) && existsSync(
|
|
157
|
+
const nm = join(experienceDir(root, id), 'node_modules');
|
|
158
|
+
if (existsSync(nm) && existsSync(experienceDir(dir, id)) && !existsSync(join(experienceDir(dir, id), 'node_modules'))) { try { symlinkSync(nm, join(experienceDir(dir, id), 'node_modules'), 'dir'); } catch { /* builds without */ } }
|
|
159
159
|
writeFileSync(stamp, `${JSON.stringify({ sha, paths, at: new Date().toISOString() })}\n`);
|
|
160
160
|
return dir;
|
|
161
161
|
}
|
|
@@ -243,7 +243,7 @@ class Builds {
|
|
|
243
243
|
// A commit changes Today: look every 2 s (one cheap `git rev-parse`).
|
|
244
244
|
this.refTimer = setInterval(() => { void this.followRef(); }, 2000);
|
|
245
245
|
this.refTimer.unref?.();
|
|
246
|
-
const dir =
|
|
246
|
+
const dir = experienceDir(this.root, this.id);
|
|
247
247
|
try {
|
|
248
248
|
this.watcher = watch(dir, { recursive: true }, (_e, file) => {
|
|
249
249
|
const f = String(file ?? '');
|
package/lib/library.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { homedir } from 'node:os';
|
|
|
18
18
|
import { dirname, join, resolve } from 'node:path';
|
|
19
19
|
import { LIMITS, checkGlb } from '../assets/safety.mjs';
|
|
20
20
|
import { recordAsset } from './asset-manifest.mjs';
|
|
21
|
-
import { GAME_ID } from './studio.mjs';
|
|
21
|
+
import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
|
|
22
22
|
|
|
23
23
|
export const LIBRARY_URL = 'https://homie.rocks/api/library/v0';
|
|
24
24
|
const CACHE = join(homedir(), '.cache', 'homie-studio', 'library');
|
|
@@ -117,7 +117,7 @@ const KIND_DIR = { texture: 'tex', sky: 'sky' };
|
|
|
117
117
|
*/
|
|
118
118
|
export async function addFromLibrary(root, game, itemId, { as = null, height = null, card = null, lib = libraryBase(), index = null } = {}) {
|
|
119
119
|
if (!GAME_ID.test(String(game ?? ''))) throw new Error(`"${game}" is not a game id`);
|
|
120
|
-
const gdir = join(root,
|
|
120
|
+
const gdir = join(experienceDir(root, game));
|
|
121
121
|
if (!existsSync(gdir)) throw new Error(`no game "${game}"`);
|
|
122
122
|
const idx = index ?? (await loadIndex({ lib })).index;
|
|
123
123
|
const item = idx.items.find((x) => x.id === itemId) ?? idx.items.find((x) => x.item === itemId);
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* AI in a game's guide seat, marked " · AI". This process holds the socket between turns; the game's own bot code
|
|
17
17
|
* moves the body while the AI thinks; the AI chooses only the game's own goals and lines (agents.json).
|
|
18
18
|
*/
|
|
19
|
+
import { prepareNode } from './prepare.mjs';
|
|
20
|
+
import { studioDomain } from './domain.mjs';
|
|
19
21
|
import { spawnSync } from 'node:child_process';
|
|
20
22
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
21
23
|
import { homedir } from 'node:os';
|
|
@@ -28,7 +30,7 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
|
|
|
28
30
|
import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
|
|
29
31
|
import { newStudio, slugify } from './scaffold.mjs';
|
|
30
32
|
import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
31
|
-
import { GAME_ID, PACKAGE_ROOT, findStudio, isRulesGame, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
|
|
33
|
+
import { GAME_ID, PACKAGE_ROOT, findStudio, isRulesGame, listExperiences as listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
|
|
32
34
|
import { runningDev } from './dev.mjs';
|
|
33
35
|
import { deployWords } from './deploy-state.mjs';
|
|
34
36
|
import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
|
|
@@ -192,7 +194,7 @@ function needsInstall(ctx, root) {
|
|
|
192
194
|
if (installed(root) || !ctx.install) return null;
|
|
193
195
|
const running = runningJobs(root).find((j) => j.label.startsWith('npm install'));
|
|
194
196
|
const job = running ?? installJob(root);
|
|
195
|
-
return ok(`The studio's toolkit is still being installed (npm install, job ${job.id}; usually 20 to 60 s).
|
|
197
|
+
return ok(`The studio's toolkit is still being installed (npm install, job ${job.id}; usually 20 to 60 s). AI: follow it with studio_job { "job": "${job.id}" }.`, { kind: 'job', ...jobView(job) });
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
/* ------------------------------------------------------------------ the setup card */
|
|
@@ -217,7 +219,7 @@ async function statusOf(ctx, root, { fresh = false } = {}) {
|
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
function checklist(ctx, root) {
|
|
220
|
-
const steps = ['Setup status', 'The studio', '
|
|
222
|
+
const steps = ['Setup status', 'The studio', 'Plan from your request', 'Build and check it', 'Put it online'];
|
|
221
223
|
const done = [true, Boolean(root)];
|
|
222
224
|
if (root) {
|
|
223
225
|
const games = listGames(root);
|
|
@@ -228,7 +230,7 @@ function checklist(ctx, root) {
|
|
|
228
230
|
const checked = note.checked || (() => {
|
|
229
231
|
try { return readdirSync(join(root, '.studio', 'progress')).some((f) => f.endsWith('.json') && readFeed(root, f.slice(0, -5))?.stages?.some((x) => x.id === 'checks' && x.state === 'done')); } catch { return false; }
|
|
230
232
|
})();
|
|
231
|
-
done.push(Boolean(
|
|
233
|
+
done.push(Boolean(codex || games.length || checked || local.deployedAt), Boolean(checked || local.deployedAt), Boolean(local.deployedAt || local.connectedAt));
|
|
232
234
|
}
|
|
233
235
|
// In order: a step counts as done only when every step before it is (an early file edit is not "one small change").
|
|
234
236
|
for (let i = 1; i < done.length; i++) done[i] = done[i] && done[i - 1];
|
|
@@ -618,9 +620,18 @@ async function openFromGitHub(ctx, a) {
|
|
|
618
620
|
}
|
|
619
621
|
|
|
620
622
|
/** github_login: GitHub's device sign-in through the GitHub CLI; the person types the one-time code in the browser. */
|
|
621
|
-
async function githubLogin(ctx) {
|
|
622
|
-
const { gh, signedIn } =
|
|
623
|
-
if (!gh)
|
|
623
|
+
async function githubLogin(ctx, { state = ghState, find = which, start = startJob, activeJobs = runningJobs, view = jobView } = {}) {
|
|
624
|
+
const { gh, signedIn } = state();
|
|
625
|
+
if (!gh) {
|
|
626
|
+
const brew = find('brew');
|
|
627
|
+
if (brew) {
|
|
628
|
+
const root = ctx.current ?? ctx.studiosDir ?? homedir();
|
|
629
|
+
const active = activeJobs(root).find((j) => j.label === 'Install GitHub CLI');
|
|
630
|
+
const job = active ?? start({ root, label: 'Install GitHub CLI', cmd: brew, args: ['install', 'gh'] });
|
|
631
|
+
return ok(`AI: follow studio_job { "job": "${job.id}" }, then retry github_login.`, { kind: 'job', ...view(job) });
|
|
632
|
+
}
|
|
633
|
+
return fail('AI: install the GitHub CLI from https://cli.github.com, then retry github_login. The person only approves an OS installer prompt; never ask them for a command or token.');
|
|
634
|
+
}
|
|
624
635
|
if (signedIn) return ok('This computer is signed in to GitHub already.');
|
|
625
636
|
const job = startJob({ root: ctx.current ?? ctx.studiosDir ?? homedir(), label: 'GitHub sign-in (gh auth login)', cmd: gh, args: ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web'], env: toolEnv() });
|
|
626
637
|
let code = null;
|
|
@@ -758,6 +769,26 @@ const RW = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
|
|
758
769
|
|
|
759
770
|
export function toolDefs(ctx, avail = {}) {
|
|
760
771
|
const tools = [
|
|
772
|
+
{
|
|
773
|
+
name: 'setup_prepare', title: 'Prepare this computer',
|
|
774
|
+
description: 'AI setup: install a private Node.js and npm when missing, without a terminal or administrator rights. Then resume studio_scaffold; install Chrome with studio_run ["chrome","install"] when checks need it. No provider account is needed.',
|
|
775
|
+
inputSchema: { type: 'object', properties: {} },
|
|
776
|
+
annotations: { title: 'Prepare this computer', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
777
|
+
run: async () => {
|
|
778
|
+
if (findNode({ fresh: true })?.npm) return ok('Node and npm are ready. AI: continue studio_scaffold.');
|
|
779
|
+
const r = await prepareNode();
|
|
780
|
+
ctx.status = null;
|
|
781
|
+
findNode({ fresh: true });
|
|
782
|
+
return r.ok ? ok('Node and npm are ready. AI: continue studio_scaffold.', r) : fail(r.next, r);
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
name: 'studio_domain', title: 'Set the studio domain',
|
|
787
|
+
description: 'Prepare a requested custom hostname for deployment. Cloudflare creates DNS and TLS using its existing browser sign-in. The AI handles configuration; never ask for an account id, zone id or DNS value. Does not deploy or change the domain registrar.',
|
|
788
|
+
inputSchema: { type: 'object', properties: { hostname: str('Requested hostname, e.g. play.example.com'), ...STUDIO_ARG }, required: ['hostname'] },
|
|
789
|
+
annotations: { title: 'Set the studio domain', ...RW },
|
|
790
|
+
run: async (a) => { const r = studioDomain(ctx.root(a.studio), a.hostname); return r.ok ? ok(r.next, r) : fail(r.next, r); },
|
|
791
|
+
},
|
|
761
792
|
{
|
|
762
793
|
name: 'setup_status', title: 'Setup status',
|
|
763
794
|
description: 'Step 0 of a new studio, and any time the person asks what they need or whether they are set up: what this computer and their accounts have (Node, Cloudflare signed in and email verified, Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and fal), what each unlocks, and the exact fix; the studios in the studios folder; and the new-studio checklist with where they are on it. For a studio that is not made yet, pass its name as studio: the checklist is then the new studio\'s, never another studio\'s that happens to be in the folder. Read-only, a few seconds. Optional rows never block anything.',
|
|
@@ -772,12 +803,11 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
772
803
|
},
|
|
773
804
|
{
|
|
774
805
|
name: 'studio_scaffold', title: 'Make a studio',
|
|
775
|
-
description: 'Step 1: make a new game studio ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (
|
|
776
|
-
inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), folder: str('Optional: the folder name inside the studios folder (default: from the name)') }
|
|
806
|
+
description: 'Step 1: make a new game studio ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (their account). It has NO game: its home page says "First game coming soon". Then installs its pinned toolkit (npm install, in the background). Never put a game in it the person did not ask for.',
|
|
807
|
+
inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), folder: str('Optional: the folder name inside the studios folder (default: from the name)') } },
|
|
777
808
|
annotations: { title: 'Make a studio', ...RW }, _meta: ui(UI.setup),
|
|
778
809
|
run: async (a) => {
|
|
779
|
-
const name = String(a.name ?? '').trim().slice(0, 60);
|
|
780
|
-
if (!name) return fail('name the studio, e.g. "Night Owls"');
|
|
810
|
+
const name = String(a.name ?? '').trim().slice(0, 60) || 'My Studio';
|
|
781
811
|
if (!ctx.studiosDir) return fail('no studios folder is set: in Claude, Settings > Extensions > Homie, choose the folder your studios live in');
|
|
782
812
|
mkdirSync(ctx.studiosDir, { recursive: true });
|
|
783
813
|
const folder = a.folder ? String(a.folder).trim() : slugify(name);
|
|
@@ -797,6 +827,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
797
827
|
ctx.note(made.dir).earlier = earlier.map((e) => e.path);
|
|
798
828
|
let install = null;
|
|
799
829
|
if (ctx.install) {
|
|
830
|
+
if (!findNode()?.npm) { const ready = await prepareNode(); if (!ready.ok) return fail(ready.next, ready); findNode({ fresh: true }); }
|
|
800
831
|
try { install = installJob(made.dir); } catch (error) { return fail(`${name} is made at ${made.dir}, but ${error.message}`); }
|
|
801
832
|
await waitJob(install, Math.min(ctx.waitMs, 35_000));
|
|
802
833
|
}
|
|
@@ -863,6 +894,25 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
863
894
|
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 });
|
|
864
895
|
},
|
|
865
896
|
},
|
|
897
|
+
{
|
|
898
|
+
name: 'app_make', title: 'Make an app',
|
|
899
|
+
description: 'Make one morphing screen with customer, staff and wall roles, lasting records and a shared 3D scene. Uses the same engines, netplay, parts and standalone builds as games. No rounds required.',
|
|
900
|
+
inputSchema: { type: 'object', properties: { id: str('App id: lowercase, digits, hyphens'), name: str('Display name'), ...STUDIO_ARG }, required: ['id', 'name'] },
|
|
901
|
+
annotations: { title: 'Make an app', ...RW },
|
|
902
|
+
run: async (a) => {
|
|
903
|
+
if (!GAME_ID.test(String(a.id ?? ''))) return fail('invalid app id');
|
|
904
|
+
const root = ctx.root(a.studio);
|
|
905
|
+
const r = await cli(ctx, root, `app new ${a.id}`, ['app', 'new', a.id, '--name', String(a.name ?? a.id).slice(0, 60)]);
|
|
906
|
+
if (!r.ended) return stillRunning(r.job, 'Making the app');
|
|
907
|
+
if (r.job.code !== 0) return fail(`Not made: ${whyOf(r.job)}`);
|
|
908
|
+
let install = '';
|
|
909
|
+
if (r.result?.installNeeded) {
|
|
910
|
+
if (ctx.install) { const job = runningJobs(root).find((j) => j.label.startsWith('npm install')) ?? installJob(root); install = ` Dependencies are installing (job ${job.id}); wait for studio_job.`; }
|
|
911
|
+
else install = ' Run studio_install for its engine dependencies.';
|
|
912
|
+
}
|
|
913
|
+
return ok(`apps/${a.id} is ready.${install} Build, dev --lan, then check. Follow the app skill: one screen, roles and parts.`, { kind: 'app', ...r.result });
|
|
914
|
+
},
|
|
915
|
+
},
|
|
866
916
|
{
|
|
867
917
|
name: 'game_make', title: 'Make a multiplayer game',
|
|
868
918
|
description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
|
|
@@ -918,8 +968,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
918
968
|
},
|
|
919
969
|
{
|
|
920
970
|
name: 'game_plan', title: 'Plan a game (its Game Codex)',
|
|
921
|
-
description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and
|
|
922
|
-
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), ...STUDIO_ARG }, required: ['id'] },
|
|
971
|
+
description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and record sensible defaults from the request for genre, style, devices, rooms, art, sound and scope. Never route engineering or design questions to the person. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their request and your defaults in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it. While planning, run `parts find` (the parts_find tool) for each system the game needs, so the plan starts from pieces of existing games and the @homie-rocks/* packages rather than from nothing; the codex\'s Built from section records what was found.',
|
|
972
|
+
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), interview: { type: 'boolean', description: 'Only when the person explicitly requests a planning conversation' }, ...STUDIO_ARG }, required: ['id'] },
|
|
923
973
|
annotations: { title: 'Plan a game', ...RW },
|
|
924
974
|
run: async (a) => {
|
|
925
975
|
const root = ctx.root(a.studio);
|
|
@@ -929,12 +979,12 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
929
979
|
made = newCodex(root, String(a.id), { name: a.name ?? null });
|
|
930
980
|
if (!made.ok) return fail(made.why);
|
|
931
981
|
}
|
|
932
|
-
const guide = ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
|
|
982
|
+
const guide = a.interview === true && ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
|
|
933
983
|
return ok([
|
|
934
984
|
made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
|
|
935
|
-
'
|
|
985
|
+
'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
|
|
936
986
|
'', PARTS_FIRST,
|
|
937
|
-
...(guide ? ['', guide] : []),
|
|
987
|
+
...(guide ? ['', 'Optional reference for a person who explicitly requests an interview:', guide] : []),
|
|
938
988
|
].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
|
|
939
989
|
},
|
|
940
990
|
},
|
|
@@ -1007,7 +1057,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1007
1057
|
},
|
|
1008
1058
|
{
|
|
1009
1059
|
name: 'check', title: 'Two-browser check',
|
|
1010
|
-
description: 'Prove a
|
|
1060
|
+
description: 'Prove an app works across a wall and two phones: an action propagates and survives reconnect, with no rounds. For a game: two fresh browsers (a computer and a phone) press Play, land in the same public room and finish a round (about 70 s). Starts the site here first when it is not running. Runs in the background; the build card follows each step going green. Run it before saying a game works.',
|
|
1011
1061
|
inputSchema: { type: 'object', properties: { game: str('The game\'s id (default: the only game)'), url: str('Optional: the site to check (default: this computer\'s preview); the live site after a deploy'), ...STUDIO_ARG } },
|
|
1012
1062
|
annotations: { title: 'Two-browser check', ...RW }, _meta: ui(UI.build),
|
|
1013
1063
|
run: async (a) => {
|
|
@@ -1017,7 +1067,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1017
1067
|
const game = a.game ?? (games.length === 1 ? games[0].id : null);
|
|
1018
1068
|
if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to check yet');
|
|
1019
1069
|
const url = a.url ? String(a.url) : await devUrl(ctx, root);
|
|
1020
|
-
return startRun(ctx, root, { kind: 'check', game, title: `${games.find((g) => g.id === game)?.name ?? game}: two-browser check`, steps: [
|
|
1070
|
+
return startRun(ctx, root, { kind: 'check', game, title: `${games.find((g) => g.id === game)?.name ?? game}: ${games.find((g) => g.id === game)?.kind === 'app' ? 'multi-screen' : 'two-browser'} check`, steps: [
|
|
1021
1071
|
...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
|
|
1022
1072
|
{ label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
|
|
1023
1073
|
] });
|
|
@@ -1086,7 +1136,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1086
1136
|
},
|
|
1087
1137
|
{
|
|
1088
1138
|
name: 'studio_deploy', title: 'Put the studio online',
|
|
1089
|
-
description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects,
|
|
1139
|
+
description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects, with its own resources. Call it with plan: true first and tell the person in two or three lines what it creates and costs. If Cloudflare is not signed in, cloudflare_login opens it in their browser to approve once. Runs in the background with the build card.',
|
|
1090
1140
|
inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ownRoute: { type: 'boolean', description: 'Only after the plan or a deploy warned that another site\'s route on the studio\'s custom domain covers its hostname, and the person agreed: add the studio\'s own exact-host route (the one line the warning gives) and deploy. It never edits or removes any other route' }, ...STUDIO_ARG } },
|
|
1091
1141
|
annotations: { title: 'Put the studio online', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(UI.build),
|
|
1092
1142
|
run: async (a) => {
|
|
@@ -1120,7 +1170,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1120
1170
|
},
|
|
1121
1171
|
{
|
|
1122
1172
|
name: 'cloudflare_login', title: 'Sign in to Cloudflare',
|
|
1123
|
-
description: 'Open Cloudflare in the person\'s browser to approve this computer once (Wrangler\'s own sign-in;
|
|
1173
|
+
description: 'Open Cloudflare in the person\'s browser to approve this computer once (Wrangler\'s own sign-in; no key pasted anywhere). Say in one line that Cloudflare opened and they should approve it.',
|
|
1124
1174
|
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
1125
1175
|
annotations: { title: 'Sign in to Cloudflare', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1126
1176
|
run: async (a) => {
|
|
@@ -1445,4 +1495,4 @@ export function slimCodex(html, { max = 140_000, pictures = true } = {}) {
|
|
|
1445
1495
|
return h.length <= max ? h : null;
|
|
1446
1496
|
}
|
|
1447
1497
|
|
|
1448
|
-
export const _test = { checklist, feedFor, slimCodex, expandHome, feedbackFacts };
|
|
1498
|
+
export const _test = { githubLogin, checklist, feedFor, slimCodex, expandHome, feedbackFacts };
|
package/lib/mcp.mjs
CHANGED
|
@@ -33,22 +33,17 @@ export const MCP_APP_MIME = 'text/html;profile=mcp-app';
|
|
|
33
33
|
|
|
34
34
|
export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios and their multiplayer web games, music and videos, run them here, and put them online on the studio's own Cloudflare. (It is not the Homie house app: that app's own server, with its room and TV tools, is a different one.) These tools do the work in this chat; the person never types a command and never needs a terminal. A studio is a folder in the studios folder; the file tools (file_list, file_read, file_edit, file_write, file_search) work inside it.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex). Look for pieces first: the @homie-rocks/* packages for general mechanisms, and parts_find for pieces other studios shared (part_add brings one in).
|
|
42
|
-
5. Build it: build_open, then game_make under the planned id (the codex stays), file edits (few and large: one file_edit carries every change to a file in its edits list, never a call per change), build, preview_run, check. The card follows every build.
|
|
43
|
-
For a shop: stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through Stripe CLI browser approval. Follow studio_job. Default is keyless: no API key in the Worker, no human-handled key. Rerun after shop.json changes. Never expose CLI credentials or webhook secrets. Follow the single next step, verify purchase/webhook/grant and a refund in Stripe or through the connected AI. An existing Worker key selects fuller carts, caps, expiry, automatic recovery and office refunds; explain that only when requested. Never choose manual automatically. Lost webhook: resend from Stripe, never grant from the return URL. Live only when requested.
|
|
44
|
-
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.
|
|
45
|
-
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.
|
|
36
|
+
Carry the person's request through to a working result without a lesson or an interview. Use their name or business name, otherwise "My Studio". Setup, dependencies, a plan, building and checking are AI work, not approval gates. Choose engineering and design defaults from the request; record them in CODEX.md so the person can change them later by asking. Phone and computer controls, a small first version, local sound and CC0 art are the defaults. No paid media unless requested with a budget. A business or charity may need an app or a site, not a game: do not make them play a demo or choose a genre.
|
|
37
|
+
Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
|
|
38
|
+
Show progress briefly and give the preview link as soon as it works. For going live, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
|
|
39
|
+
For a shop, stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through one Stripe browser approval. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
|
|
40
|
+
For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
|
|
46
41
|
|
|
47
42
|
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.
|
|
48
43
|
|
|
49
44
|
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.
|
|
50
45
|
|
|
51
|
-
|
|
46
|
+
Give preview and finished-site links in your reply (the cards have their own buttons). Open provider authorization flows when needed; the person signs in and approves, while you handle configuration.
|
|
52
47
|
|
|
53
48
|
To make one mechanic of a game feel better (a jump, a hit, a dash), game_lab opens the Game Lab: New beside Today, one take, its phases and sliders (studio_guide { "topic": "lab" }).
|
|
54
49
|
|
package/lib/parts-build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceDir, experienceFile } from './studio.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* GAME PARTS IN THE BUILD (parts/PARTS.md). Three small things lib/build.mjs and the deploy plan call, so the build
|
|
3
4
|
* itself stays one line longer in each place:
|
|
@@ -63,7 +64,7 @@ export function partsPlugin(root, game = null) {
|
|
|
63
64
|
});
|
|
64
65
|
// A brought-in part a game imports is that game's to credit, even when it was added without naming the game.
|
|
65
66
|
build.onEnd(() => {
|
|
66
|
-
if (!game || !used.size || !existsSync(join(root,
|
|
67
|
+
if (!game || !used.size || !existsSync(join(experienceDir(root, game)))) return;
|
|
67
68
|
try {
|
|
68
69
|
const now = readOrigins(root);
|
|
69
70
|
let changed = false;
|
|
@@ -112,7 +113,7 @@ export function buildParts(root, dist, { studio = {}, log = () => {} } = {}) {
|
|
|
112
113
|
writeFileSync(join(dest, 'part.json'), `${JSON.stringify(publicPart({ ...packed, share: true }))}\n`);
|
|
113
114
|
}
|
|
114
115
|
// The game it came out of, by the name the studio gives it.
|
|
115
|
-
const g = part.from?.game;
|
|
116
|
+
const g = part.from?.game ?? part.from?.app;
|
|
116
117
|
if (g && !names.has(g)) names.set(g, gameFacts(root, g).name);
|
|
117
118
|
index.push({ ...part, ...(g ? { from: { ...part.from, name: names.get(g) } } : {}), url: `/parts/${id}/${versions.at(-1)}/`, page: `/parts/${id}/`, versions });
|
|
118
119
|
log(`shared part ${id} (${versions.join(', ')})`);
|
|
@@ -142,7 +143,7 @@ export function partsPublishReport(root) {
|
|
|
142
143
|
for (const [ref, e] of Object.entries(lock.parts)) for (const g of e.games ?? []) { if (!byGame.has(g)) byGame.set(g, []); byGame.get(g).push([ref, e]); }
|
|
143
144
|
const games = [];
|
|
144
145
|
for (const [game, list] of [...byGame].sort(([a], [b]) => a.localeCompare(b))) {
|
|
145
|
-
if (!existsSync(
|
|
146
|
+
if (!existsSync(experienceFile(root, game))) continue;
|
|
146
147
|
const issues = licenceIssues(partItems(root, { game }), { game: gameFacts(root, game) });
|
|
147
148
|
games.push({
|
|
148
149
|
game,
|
package/lib/parts-cli.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export async function partsCommand(root, sub, positional, flags, { log = () => {
|
|
|
28
28
|
const str = (k) => (typeof flags.get(k) === 'string' ? flags.get(k) : null);
|
|
29
29
|
switch (sub ?? 'find') {
|
|
30
30
|
case 'find': return findParts(root, positional.slice(2).join(' '), { kind: str('kind'), tag: str('tag'), license: str('license'), builds: str('builds'), fetch, hub: str('hub') });
|
|
31
|
-
case 'new': return newPart(root, arg, { kind: str('kind') ?? 'mechanic', name: str('name'), from: str('from'), paths: positional.slice(3) });
|
|
31
|
+
case 'new': return newPart(root, arg, { kind: str('kind') ?? 'mechanic', name: str('name'), from: str('from'), paths: positional.slice(3), uses: str('uses')?.split(',') });
|
|
32
32
|
case 'check': return checkParts(root, arg ?? null, { write: true });
|
|
33
33
|
case 'share': return arg ? sharePart(root, arg, true) : { ok: false, command: 'parts share', why: 'name the part to share' };
|
|
34
34
|
case 'unshare': return arg ? sharePart(root, arg, false) : { ok: false, command: 'parts unshare', why: 'name the part to stop sharing' };
|
|
@@ -49,7 +49,7 @@ function addLines(r) {
|
|
|
49
49
|
if (r.was && r.same) L.push(`${r.ref} ${r.version} is already here and unchanged.`);
|
|
50
50
|
else if (r.was) L.push(`${r.ref}: ${r.was} → ${r.version}. ${[r.changes.changed.length ? `changed: ${r.changes.changed.join(', ')}` : '', r.changes.added.length ? `new: ${r.changes.added.join(', ')}` : '', r.changes.removed.length ? `gone: ${r.changes.removed.join(', ')}` : ''].filter(Boolean).join('; ') || 'the same files'}. Your tuning.json was kept${r.replaced?.length ? `; REPLACED your edited ${r.replaced.join(', ')} as asked` : ''}.`);
|
|
51
51
|
else L.push(`${r.name} ${r.version} (${r.kind}) from ${r.host} is in ${r.dir}: ${r.files} files, every one checked against its SHA-256. It is this studio's to tune now.`);
|
|
52
|
-
if (r.from?.name || r.from?.game) L.push(` It came out of ${r.from.name ?? r.from.game}${r.from.studio ? ` by ${r.from.studio}` : ''}.`);
|
|
52
|
+
if (r.from?.name || (r.from?.game ?? r.from?.app)) L.push(` It came out of ${r.from.name ?? (r.from.game ?? r.from.app)}${r.from.studio ? ` by ${r.from.studio}` : ''}.`);
|
|
53
53
|
L.push(` Licence: ${r.license ?? 'NONE NAMED'} (asks ${r.asks ?? 'unknown'})${r.attribution ? `; credit: ${r.attribution}` : ''}`);
|
|
54
54
|
if (r.credits?.length) L.push(` Credited in ${r.credits.join(', ')}.`);
|
|
55
55
|
else if (!r.game) L.push(' It is credited in a game\'s credits when that game imports it (the build does it).');
|
|
@@ -69,7 +69,7 @@ export function partsLines(r) {
|
|
|
69
69
|
for (const p of r.results) {
|
|
70
70
|
L.push(`${p.name} (${p.kind}) ${p.version ?? ''} · ${p.license ?? 'no licence named'} · ${p.where === 'hub' ? p.ref : p.where === 'here' ? `${p.ref} (already here)` : `${p.id} (this studio's own${p.share ? ', shared' : ', private'})`}`);
|
|
71
71
|
if (p.summary) L.push(` ${p.summary}`);
|
|
72
|
-
if (p.from?.name || p.from?.game) L.push(` from ${p.from.name ?? p.from.game}${p.from.studio ? ` by ${p.from.studio}` : ''}`);
|
|
72
|
+
if (p.from?.name || (p.from?.game ?? p.from?.app)) L.push(` from ${p.from.name ?? (p.from.game ?? p.from.app)}${p.from.studio ? ` by ${p.from.studio}` : ''}`);
|
|
73
73
|
const bits = [costWords(p.cost) && `costs ${costWords(p.cost)}`, buildsWords(p.requires) && `builds on ${buildsWords(p.requires)}`, p.rig && `rig ${p.rig}`, p.netplay && `in a room: ${p.netplay}`].filter(Boolean);
|
|
74
74
|
if (bits.length) L.push(` ${bits.join(' · ')}`);
|
|
75
75
|
L.push(` ${p.say}`);
|
package/lib/parts-store.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
packedVersions, parseRef, partDir, readOrigins, readPart, requiredParts, sha256, shareProblems, syncPartCredits, vendorDir, verifyFiles, writeHashes,
|
|
24
24
|
writeOrigins, writePart,
|
|
25
25
|
} from './parts.mjs';
|
|
26
|
-
import { listGames, readStudio } from './studio.mjs';
|
|
26
|
+
import { experienceDir, experienceFile, listExperiences as listGames, readStudio } from './studio.mjs';
|
|
27
27
|
|
|
28
28
|
export const HUB = 'https://homie.rocks';
|
|
29
29
|
const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -139,8 +139,8 @@ export function ensurePackages(root, wants, { npm = runNpm, install = false, say
|
|
|
139
139
|
/** What a part's licence can care about in the game using it: whether the studio sells, and who the game credits. */
|
|
140
140
|
export function gameFacts(root, id) {
|
|
141
141
|
let g = {}; let credits = {};
|
|
142
|
-
try { g = JSON.parse(readFileSync(
|
|
143
|
-
try { credits = JSON.parse(readFileSync(join(root,
|
|
142
|
+
try { g = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { g = {}; }
|
|
143
|
+
try { credits = JSON.parse(readFileSync(join(experienceDir(root, id), 'credits.json'), 'utf8')); } catch { credits = {}; }
|
|
144
144
|
return { id, name: g.name ?? id, sells: existsSync(join(root, 'shop.json')), credits: Array.isArray(credits.parts) ? credits.parts : [] };
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -177,7 +177,7 @@ const diffFiles = (was, now) => {
|
|
|
177
177
|
export async function addPart(root, refText, { game = null, fetch, npm = runNpm, install = true, overwrite = false, say = () => {} } = {}) {
|
|
178
178
|
const command = 'parts add';
|
|
179
179
|
const fail = (why, extra = {}) => ({ ok: false, command, why, ...extra });
|
|
180
|
-
if (game && !existsSync(
|
|
180
|
+
if (game && !existsSync(experienceFile(root, game))) return fail(`there is no game "${game}" in this studio (${listGames(root).map((g) => g.id).join(', ') || 'it has none yet'})`);
|
|
181
181
|
let got;
|
|
182
182
|
try { got = await fetchPart(refText, { fetch }); } catch (error) { return fail(error.message); }
|
|
183
183
|
const { ref, part, files, version, base, entry } = got;
|
|
@@ -281,14 +281,15 @@ export function sharePart(root, id, on = true) {
|
|
|
281
281
|
const packed = packPart(root, id);
|
|
282
282
|
if (!packed.ok) { part.share = false; writePart(dir, part); return { ...packed, command }; }
|
|
283
283
|
const lic = licenseOfPart(part.license);
|
|
284
|
-
return { ok: true, command, id, share: true, version: part.version, license: lic.id, asks: lic.asks, files: part.files.length, bytes: part.cost?.bytes ?? 0, ...(part.from
|
|
284
|
+
return { ok: true, command, id, share: true, version: part.version, license: lic.id, asks: lic.asks, files: part.files.length, bytes: part.cost?.bytes ?? 0, ...(part.from ? { from: part.from } : {}), effect: 'It is live after the studio\'s next deploy, not before: nothing was uploaded now. Sharing a part does not list the studio in the directory.' };
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
/* ------------------------------------------------------------------ find */
|
|
288
288
|
|
|
289
289
|
const row = (p, extra) => ({
|
|
290
290
|
id: p.id, name: p.name, kind: p.kind, version: p.version, summary: line(p.summary), license: p.license || null, tags: Array.isArray(p.tags) ? p.tags.slice(0, 12) : [],
|
|
291
|
-
|
|
291
|
+
...(Array.isArray(p.uses) ? { uses: p.uses } : {}),
|
|
292
|
+
from: isObj(p.from) ? { ...(p.from.app ? { app: p.from.app, open: p.from.open ?? null } : {}), ...(p.from.music ? { music: p.from.music } : {}), ...(p.from.video ? { video: p.from.video } : {}), game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
|
|
292
293
|
cost: isObj(p.cost) ? p.cost : {}, requires: isObj(p.requires) ? p.requires : {}, rig: p.skeleton?.rig ?? null, netplay: p.contract?.netplay ?? null, ...extra,
|
|
293
294
|
});
|
|
294
295
|
function matches(p, { words, kind, tag, license, builds }) {
|
|
@@ -297,7 +298,7 @@ function matches(p, { words, kind, tag, license, builds }) {
|
|
|
297
298
|
if (license && String(p.license ?? '').toLowerCase() !== String(license).toLowerCase()) return false;
|
|
298
299
|
// What a part builds on: a package it names, or the skeleton it is made for.
|
|
299
300
|
if (builds && !(Object.keys(p.requires?.packages && typeof p.requires.packages === 'object' ? p.requires.packages : {}).includes(builds) || p.rig === builds)) return false;
|
|
300
|
-
const hay = [p.id, p.name, p.kind, p.summary, ...(p.tags ?? []), p.from?.name, p.from?.game, p.from?.studio].filter(Boolean).join(' ').toLowerCase();
|
|
301
|
+
const hay = [p.id, p.name, p.kind, p.summary, ...(p.tags ?? []), p.from?.name, p.from?.game, p.from?.app, p.from?.music, p.from?.video, ...(p.uses ?? []), p.from?.studio].filter(Boolean).join(' ').toLowerCase();
|
|
301
302
|
return words.every((w) => hay.includes(w));
|
|
302
303
|
}
|
|
303
304
|
|
package/lib/parts-tools.mjs
CHANGED
|
@@ -51,22 +51,22 @@ export function partsToolDefs(ctx, h) {
|
|
|
51
51
|
},
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
|
-
name: 'part_new', title: 'Make a
|
|
55
|
-
description: 'Make a part of this studio: a piece of
|
|
56
|
-
inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'What kind of part (default mechanic)' }, name: str('Its name'), from: str('The game to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
|
|
57
|
-
annotations: { title: 'Make a
|
|
54
|
+
name: 'part_new', title: 'Make a shared part',
|
|
55
|
+
description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game\'s id) and files (paths inside that game: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a reusable piece, never an entire experience, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
|
|
56
|
+
inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'What kind of part (default mechanic)' }, name: str('Its name'), uses: { type: 'array', items: { type: 'string', enum: ['game', 'app', 'venue', 'cause', 'music', 'video'] } }, from: str('The game to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
|
|
57
|
+
annotations: { title: 'Make a shared part', ...RW },
|
|
58
58
|
run: async (a) => {
|
|
59
59
|
const root = ctx.root(a.studio);
|
|
60
|
-
const r = newPart(root, String(a.id ?? ''), { kind: a.kind ?? 'mechanic', name: a.name ?? null, from: a.from ? String(a.from) : null, paths: Array.isArray(a.files) ? a.files.map(String) : [] });
|
|
60
|
+
const r = newPart(root, String(a.id ?? ''), { kind: a.kind ?? 'mechanic', uses: a.uses, name: a.name ?? null, from: a.from ? String(a.from) : null, paths: Array.isArray(a.files) ? a.files.map(String) : [] });
|
|
61
61
|
if (!r.ok) return fail(`No part was made: ${r.why}`, { kind: 'part', ...r });
|
|
62
62
|
return ok([text(r), r.lifted ? `Build ${r.from} now and check it still plays the same.` : '', 'Fill in parts/' + r.id + '/part.json: a one-sentence summary, what it takes from a game ("contract"), and its costs if measured. It stays private until the person asks to share it (part_share).'].filter(Boolean).join('\n'), { kind: 'part', ...r });
|
|
63
63
|
},
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
|
-
name: 'part_share', title: 'Share a
|
|
66
|
+
name: 'part_share', title: 'Share a part',
|
|
67
67
|
description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
|
|
68
68
|
inputSchema: { type: 'object', properties: { id: str('The part\'s id'), share: { type: 'boolean', description: 'true: share it; false: stop sharing it; left out: only check what stands in the way' }, license: str('The licence the person picked, an SPDX identifier (set before sharing)'), attribution: str('Who a game using it should credit'), ...STUDIO_ARG }, required: ['id'] },
|
|
69
|
-
annotations: { title: 'Share a
|
|
69
|
+
annotations: { title: 'Share a part', ...RW },
|
|
70
70
|
run: async (a) => {
|
|
71
71
|
const root = ctx.root(a.studio);
|
|
72
72
|
const id = String(a.id ?? '');
|