@homie-rocks/studio 0.35.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- 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 +22 -10
- package/lab/lab.ts +15 -0
- 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 +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- 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/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- 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/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- 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/dev.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { networkInterfaces } from 'node:os';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio dev` and `dev --stop`: the whole site on this computer (pages, rooms in a local Durable Object, D1),
|
|
3
4
|
* and exactly this studio's dev server stopped, never another project's.
|
|
@@ -32,7 +33,7 @@ import { detectLocalAi, localAiVars } from './local-ai.mjs';
|
|
|
32
33
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
33
34
|
import { routesOf } from './routes.mjs';
|
|
34
35
|
import { ensureMigrations, migrationWord } from './scaffold.mjs';
|
|
35
|
-
import { isRulesGame, listGames, workerDir } from './studio.mjs';
|
|
36
|
+
import { isRulesGame, listExperiences as listGames, workerDir } from './studio.mjs';
|
|
36
37
|
|
|
37
38
|
export const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
|
|
38
39
|
|
|
@@ -289,7 +290,7 @@ function passLines(stream, to, opts) {
|
|
|
289
290
|
* saved. `rulesStamp` is what it compares: the newest change among them, and how many there are.
|
|
290
291
|
*/
|
|
291
292
|
export function rulesStamp(g) {
|
|
292
|
-
const files = [join(g.dir, 'game.json'), join(g.dir, 'tunables.json')];
|
|
293
|
+
const files = [join(g.dir, g.kind === 'app' ? 'app.json' : 'game.json'), join(g.dir, 'tunables.json')];
|
|
293
294
|
for (const sub of ['src', 'map']) {
|
|
294
295
|
const dir = join(g.dir, sub);
|
|
295
296
|
if (existsSync(dir)) for (const name of readdirSync(dir, { recursive: true })) files.push(join(dir, String(name)));
|
|
@@ -306,7 +307,7 @@ const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'si
|
|
|
306
307
|
* `homie-studio dev`: build once, then Wrangler's local runtime until it is stopped. Returns the command's result.
|
|
307
308
|
* options: port, remoteAi, localAi (false: --no-local-ai), timestamps (every line of Wrangler's gets a time), log.
|
|
308
309
|
*/
|
|
309
|
-
export async function dev(root, { port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
|
|
310
|
+
export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
|
|
310
311
|
const port = String(askedPort ?? 8787);
|
|
311
312
|
const stop = 'npx --no-install homie-studio dev --stop';
|
|
312
313
|
|
|
@@ -344,6 +345,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
344
345
|
});
|
|
345
346
|
const origin = `http://127.0.0.1:${port}`;
|
|
346
347
|
log(`Local site: ${origin}/ (each game: ${origin}/<id>/play — open it in two browsers)`);
|
|
348
|
+
if (lan) for (const address of lanAddresses()) log(`Same Wi-Fi: http://${address}:${port}/ (open this address on phones and wall screens)`);
|
|
347
349
|
const ai = devConfig(root, remoteAi);
|
|
348
350
|
if (ai.note) log(ai.note);
|
|
349
351
|
// The person's own Clef (0.24.4, lib/local-ai.mjs): with Ollama and clef-flash on this computer, the local Worker's
|
|
@@ -356,7 +358,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
356
358
|
}
|
|
357
359
|
// --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
|
|
358
360
|
// (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
|
|
359
|
-
const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
|
|
361
|
+
const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', lan ? '0.0.0.0' : '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
|
|
360
362
|
log(`Stop it with: ${stop} (this studio's dev server only)`);
|
|
361
363
|
|
|
362
364
|
let child = null;
|
|
@@ -390,7 +392,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
390
392
|
for (const g of listGames(root)) {
|
|
391
393
|
if (built.includes(g.id) || told.has(g.id)) continue;
|
|
392
394
|
told.add(g.id);
|
|
393
|
-
log(
|
|
395
|
+
log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} is new and not built yet, so ${origin}/${g.id}/play is a 404 for now. Build it (npx --no-install homie-studio build): this dev server serves what build wrote and picks the new game up by itself.`);
|
|
394
396
|
}
|
|
395
397
|
for (const id of built) {
|
|
396
398
|
if (startedWith.has(id)) continue;
|
|
@@ -412,22 +414,47 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
412
414
|
};
|
|
413
415
|
// A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
|
|
414
416
|
const stamps = new Map(listGames(root).filter(isRulesGame).map((g) => [g.id, rulesStamp(g)]));
|
|
417
|
+
const timingRetries = new Map();
|
|
415
418
|
let rebuilding = false;
|
|
419
|
+
let published = Promise.resolve();
|
|
416
420
|
const rulesLook = async () => {
|
|
417
421
|
if (rebuilding || ending) return;
|
|
418
422
|
for (const g of listGames(root).filter(isRulesGame)) {
|
|
419
423
|
const now = rulesStamp(g);
|
|
420
|
-
|
|
424
|
+
const retry = timingRetries.get(g.id);
|
|
425
|
+
if (stamps.get(g.id) === now && !(retry?.stamp === now && Date.now() >= retry.at)) continue;
|
|
426
|
+
if (retry?.stamp !== now) timingRetries.delete(g.id);
|
|
421
427
|
stamps.set(g.id, now);
|
|
422
428
|
rebuilding = true;
|
|
423
429
|
try {
|
|
424
|
-
log(
|
|
430
|
+
log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} changed: checking its rules and building it again…`);
|
|
425
431
|
const hash = () => { try { return JSON.parse(readFileSync(join(root, 'site/dist/games.json'), 'utf8')).games.find((x) => x.id === g.id)?.room?.stateHash; } catch { return null; } };
|
|
426
432
|
const before = hash();
|
|
427
|
-
|
|
428
|
-
|
|
433
|
+
let release;
|
|
434
|
+
published = new Promise(resolve => { release = resolve; });
|
|
435
|
+
try {
|
|
436
|
+
await build(root, { only: g.id, log, beforePublish: async () => {
|
|
437
|
+
// Wrangler independently watches assets and code. Publishing both while it runs
|
|
438
|
+
// reloads the old bundle first, then the new one. Stop once at the publish boundary;
|
|
439
|
+
// the loop below starts once with the complete build and the same local storage.
|
|
440
|
+
if (!child || child.exitCode !== null) return;
|
|
441
|
+
restart = g.id;
|
|
442
|
+
const stopped = new Promise(resolve => child.once('close', resolve));
|
|
443
|
+
child.kill('SIGTERM');
|
|
444
|
+
await stopped;
|
|
445
|
+
} });
|
|
446
|
+
} finally { release(); }
|
|
447
|
+
timingRetries.delete(g.id);
|
|
448
|
+
log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} is rebuilt. ${before && before === hash() ? 'Local rooms resume their saved match; pages reconnect.' : 'The stored shape changed: local rooms reset to a fresh match; pages reconnect.'}`);
|
|
429
449
|
} catch (error) {
|
|
430
|
-
log(
|
|
450
|
+
log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} did not build, so the local site still runs what it had:\n${String(error?.message ?? error)}`);
|
|
451
|
+
// A busy machine can fail the wall-clock tick guard once. Keep the guard, resample at most three times,
|
|
452
|
+
// and never retry syntax, contract or deterministic budget failures until the author edits them.
|
|
453
|
+
const attempts = timingRetries.get(g.id)?.attempts ?? 0;
|
|
454
|
+
if (/rules ran for .*seconds with bots and were too slow/.test(String(error?.message ?? error)) && attempts < 3) {
|
|
455
|
+
timingRetries.set(g.id, { stamp: now, attempts: attempts + 1, at: Date.now() + 10000 });
|
|
456
|
+
log(`${g.id}: retrying the timing check in 10 seconds (${attempts + 1}/3); the last good build stays live.`);
|
|
457
|
+
} else timingRetries.delete(g.id);
|
|
431
458
|
} finally { rebuilding = false; }
|
|
432
459
|
return;
|
|
433
460
|
}
|
|
@@ -461,6 +488,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
461
488
|
writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
|
|
462
489
|
settle().catch(() => {});
|
|
463
490
|
await new Promise((done) => { child.on('close', done); child.on('error', done); });
|
|
491
|
+
await published;
|
|
464
492
|
if (!restart || ending) break;
|
|
465
493
|
restart = null;
|
|
466
494
|
}
|
|
@@ -469,3 +497,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
469
497
|
if (failed) return { ok: false, command: 'dev', needs: 'local-rooms', socket: failed.socket, expected: failed.expected, why: failed.why };
|
|
470
498
|
return { ok: true, command: 'dev', stopped: true };
|
|
471
499
|
}
|
|
500
|
+
|
|
501
|
+
export function lanAddresses(interfaces = networkInterfaces()) {
|
|
502
|
+
return [...new Set(Object.values(interfaces).flat().filter((n) => n && !n.internal && n.family === 'IPv4' && !n.address.startsWith('169.254.')).map((n) => n.address))].sort();
|
|
503
|
+
}
|
package/lib/directory.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* nobody can list games under someone else's site.
|
|
6
6
|
*/
|
|
7
7
|
import { request } from './net.mjs';
|
|
8
|
-
import { listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
|
|
8
|
+
import { listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
|
|
9
9
|
import { licenceProblems, readManifest } from './asset-manifest.mjs';
|
|
10
10
|
import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
|
|
11
11
|
|
package/lib/doctor.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { detectLocalAi } from './local-ai.mjs';
|
|
|
50
50
|
import { findChrome } from './chrome.mjs';
|
|
51
51
|
import { whyFailed } from './net.mjs';
|
|
52
52
|
import { isOurs } from './statusline.mjs';
|
|
53
|
-
import { findStudio, listGames, readLocal, readStudio } from './studio.mjs';
|
|
53
|
+
import { findStudio, listExperiences as listGames, readLocal, readStudio } from './studio.mjs';
|
|
54
54
|
import { standaloneRows } from './standalone.mjs';
|
|
55
55
|
import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
|
|
56
56
|
import { STUDIO_VERSION } from './version.mjs';
|
package/lib/handoff.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function originRepo(root) {
|
|
|
26
26
|
return m ? `${m[1]}/${m[2]}` : null;
|
|
27
27
|
}
|
|
28
28
|
import { setupAttach } from './setup.mjs';
|
|
29
|
-
import { listGames, readStudio } from './studio.mjs';
|
|
29
|
+
import { listExperiences as listGames, readStudio } from './studio.mjs';
|
|
30
30
|
import { STUDIO_VERSION } from './version.mjs';
|
|
31
31
|
|
|
32
32
|
const BUILD = /^hb_[a-f0-9]{32}$/;
|
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 { 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; };
|
|
@@ -58,6 +58,11 @@ export function summarise(pane, { fps, units }) {
|
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** A rules take must actually advance its local runtime without joining a room. */
|
|
62
|
+
export function rulesRun(s) {
|
|
63
|
+
return Boolean(s?.hosting && !s.connected && s.tick > 0 && s.status === 'playing');
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
/** Where two runs of one build first differ (frame number), or null when every frame matches. */
|
|
62
67
|
export function firstDifference(a, b) {
|
|
63
68
|
const n = Math.max(a?.length ?? 0, b?.length ?? 0);
|
|
@@ -137,8 +142,18 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
|
|
|
137
142
|
new: first.new.available ? firstDifference(first.new.frames, second.new.frames) : null,
|
|
138
143
|
today: first.today.available ? firstDifference(first.today.frames, second.today.frames) : null,
|
|
139
144
|
};
|
|
145
|
+
const rules = isRulesGame(games.find((g) => g.id === game)) ? await page.evaluate(() => {
|
|
146
|
+
const out = {};
|
|
147
|
+
for (const pane of ['new', 'today']) {
|
|
148
|
+
const frame = [...document.querySelectorAll('iframe')].find((f) => f.src.includes('/' + pane + '/'));
|
|
149
|
+
const n = frame?.contentWindow?.__homieNet;
|
|
150
|
+
out[pane] = n ? { format: typeof n.probe?.hosted === 'function', hosting: n.rulesHosting, connected: n.connected, tick: n.probe?.tick?.(), status: n.probe?.status?.() } : null;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}) : null;
|
|
154
|
+
if (rules && (!rulesRun(rules.new) || (first.today.available && rules.today?.format && !rulesRun(rules.today)))) pageErrors.push('A rules pane did not play on its local host runtime.');
|
|
140
155
|
const summary = {
|
|
141
|
-
ok:
|
|
156
|
+
ok: !rules || pageErrors.length === 0, rules, game, take: first.take, note: first.note, fps: first.fps, frames: first.frames, device: first.device, seed: first.seed, inputs: first.inputs,
|
|
142
157
|
newBuild: first.newBuild, todayBuild: first.todayBuild, overrides: first.overrides, renderer, software: SOFTWARE_GL.test(String(renderer ?? '')),
|
|
143
158
|
deterministic, new: summarise(first.new, first), today: summarise(first.today, first), pageErrors,
|
|
144
159
|
};
|
|
@@ -182,7 +197,7 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
|
|
|
182
197
|
};
|
|
183
198
|
if (!out) writeFileSync(join(labDir(root, game), 'latest.json'), `${JSON.stringify(latest, null, 2)}\n`);
|
|
184
199
|
return {
|
|
185
|
-
ok:
|
|
200
|
+
ok: summary.ok, rules: summary.rules, command: 'lab check', game, take: summary.take, out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath),
|
|
186
201
|
frames: summary.frames, fps: summary.fps, deterministic, renderer, software: summary.software,
|
|
187
202
|
phases: { new: summary.new.phases?.map((p) => `${p.name} ${p.frames}f`) ?? [], today: summary.today.available ? summary.today.phases.map((p) => `${p.name} ${p.frames}f`) : null },
|
|
188
203
|
timeline: { new: (summary.new.phases ?? []).map(({ name, from, to }) => ({ name, from, to })), today: summary.today.available ? summary.today.phases.map(({ name, from, to }) => ({ name, from, to })) : 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
|
|
|
@@ -67,6 +67,20 @@ export function readTunables(dir) {
|
|
|
67
67
|
return json;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Rules keep public movement knobs grouped on disk; sliders name them public.speed, public.jump, and so on. */
|
|
71
|
+
export function flatTunables(spec) {
|
|
72
|
+
if (!spec) return null;
|
|
73
|
+
const { public: pub, ...rest } = spec;
|
|
74
|
+
return { ...rest, ...Object.fromEntries(Object.entries(pub ?? {}).map(([k, v]) => [`public.${k}`, v])) };
|
|
75
|
+
}
|
|
76
|
+
export function setLabTunables(spec, values) {
|
|
77
|
+
const r = setTunables(flatTunables(spec), values);
|
|
78
|
+
if (!spec?.public) return r;
|
|
79
|
+
const out = { ...r.spec, public: { ...spec.public } };
|
|
80
|
+
for (const [k, v] of Object.entries(r.spec)) if (k.startsWith('public.')) { out.public[k.slice(7)] = v; delete out[k]; }
|
|
81
|
+
return { ...r, spec: out };
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
/** `homie-studio lab set <game> name=value ...`: values written into tunables.json, one tunable a line. */
|
|
71
85
|
export function labSet(root, id, pairs) {
|
|
72
86
|
const g = gameOf(root, id);
|
|
@@ -74,12 +88,12 @@ export function labSet(root, id, pairs) {
|
|
|
74
88
|
if (!spec) return { ok: false, command: 'lab set', why: `games/${g.id} has no tunables.json yet: the lab skill says how a game gets one (lab.tunables)` };
|
|
75
89
|
const values = {};
|
|
76
90
|
for (const p of pairs) {
|
|
77
|
-
const m = /^([A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
|
|
91
|
+
const m = /^((?:public\.)?[A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
|
|
78
92
|
if (!m) return { ok: false, command: 'lab set', why: `"${p}" is not name=value` };
|
|
79
93
|
values[m[1]] = m[2];
|
|
80
94
|
}
|
|
81
95
|
if (!Object.keys(values).length) return { ok: false, command: 'lab set', why: 'usage: homie-studio lab set <game> <name>=<value> ...' };
|
|
82
|
-
const r =
|
|
96
|
+
const r = setLabTunables(spec, values);
|
|
83
97
|
if (r.refused.length) return { ok: false, command: 'lab set', why: r.refused.join('; ') };
|
|
84
98
|
writeFileSync(join(g.dir, 'tunables.json'), formatTunables(r.spec));
|
|
85
99
|
return { ok: true, command: 'lab set', game: g.id, file: `games/${g.id}/tunables.json`, changed: r.changed };
|
|
@@ -109,16 +123,16 @@ export function commitOf(root, ref = 'HEAD') {
|
|
|
109
123
|
|
|
110
124
|
/** Whether games/<id>/ differs from HEAD (staged, changed or new files). */
|
|
111
125
|
export function dirtyOf(root, id) {
|
|
112
|
-
const r = git(root, ['status', '--porcelain', '--',
|
|
126
|
+
const r = git(root, ['status', '--porcelain', '--', relative(root, experienceDir(root, id))]);
|
|
113
127
|
return r.status === 0 ? r.stdout.trim().split('\n').filter(Boolean).length : null;
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
/** The paths of `sha` that New's bundle read from outside games/<id>/ (a shared folder), plus the game's own. */
|
|
117
131
|
function pathsFor(root, id, sha, metafile) {
|
|
118
|
-
const want = new Set([
|
|
132
|
+
const want = new Set([relative(root, experienceDir(root, id))]);
|
|
119
133
|
for (const input of Object.keys(metafile?.inputs ?? {})) {
|
|
120
134
|
const rel = input.split(sep).join('/');
|
|
121
|
-
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;
|
|
122
136
|
want.add(rel);
|
|
123
137
|
}
|
|
124
138
|
for (const f of ['package.json', 'studio.json']) want.add(f);
|
|
@@ -132,7 +146,7 @@ function checkout(root, id, sha, paths) {
|
|
|
132
146
|
const dir = join(labDir(root, id), 'checkout');
|
|
133
147
|
const stamp = join(labDir(root, id), 'checkout.json');
|
|
134
148
|
const had = readJson(stamp);
|
|
135
|
-
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;
|
|
136
150
|
removeCheckout(root, dir);
|
|
137
151
|
mkdirSync(labDir(root, id), { recursive: true });
|
|
138
152
|
const add = git(root, ['worktree', 'add', '--no-checkout', '--detach', dir, sha]);
|
|
@@ -140,8 +154,8 @@ function checkout(root, id, sha, paths) {
|
|
|
140
154
|
const co = git(dir, ['checkout', sha, '--', ...paths]);
|
|
141
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()}`);
|
|
142
156
|
// A game with its own build (game.json build.mode "command") builds with its own node_modules: the working tree's.
|
|
143
|
-
const nm = join(root,
|
|
144
|
-
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 */ } }
|
|
145
159
|
writeFileSync(stamp, `${JSON.stringify({ sha, paths, at: new Date().toISOString() })}\n`);
|
|
146
160
|
return dir;
|
|
147
161
|
}
|
|
@@ -179,7 +193,7 @@ class Builds {
|
|
|
179
193
|
const t0 = Date.now();
|
|
180
194
|
this.new = { ...this.new, building: true };
|
|
181
195
|
try {
|
|
182
|
-
const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', log: this.log });
|
|
196
|
+
const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', lab: true, log: this.log });
|
|
183
197
|
this.new = { ok: true, building: false, error: null, dir: out, at: new Date().toISOString(), ms: Date.now() - t0, bytes: sizeOf(join(out, 'assets', 'main.js')), metafile: r.metafile, mode: r.mode, dirty: dirtyOf(this.root, this.id) };
|
|
184
198
|
} catch (error) {
|
|
185
199
|
this.new = { ok: false, building: false, error: error.message, dir: out, at: new Date().toISOString() };
|
|
@@ -209,7 +223,7 @@ class Builds {
|
|
|
209
223
|
const g = listGames(dir).find((x) => x.id === this.id);
|
|
210
224
|
if (!g) throw new Error(`games/${this.id}/game.json is not in ${c.short}`);
|
|
211
225
|
this.esbuild ??= await studioEsbuild(this.root);
|
|
212
|
-
await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', log: this.log });
|
|
226
|
+
await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', lab: true, log: this.log });
|
|
213
227
|
let tunables = null;
|
|
214
228
|
try { tunables = readTunables(g.dir); } catch { tunables = null; }
|
|
215
229
|
const at = new Date().toISOString();
|
|
@@ -229,7 +243,7 @@ class Builds {
|
|
|
229
243
|
// A commit changes Today: look every 2 s (one cheap `git rev-parse`).
|
|
230
244
|
this.refTimer = setInterval(() => { void this.followRef(); }, 2000);
|
|
231
245
|
this.refTimer.unref?.();
|
|
232
|
-
const dir =
|
|
246
|
+
const dir = experienceDir(this.root, this.id);
|
|
233
247
|
try {
|
|
234
248
|
this.watcher = watch(dir, { recursive: true }, (_e, file) => {
|
|
235
249
|
const f = String(file ?? '');
|
|
@@ -350,7 +364,7 @@ export async function startLabServer(root, { port = LAB_PORT, today = 'HEAD', lo
|
|
|
350
364
|
if (what === 'tunables') {
|
|
351
365
|
const spec = readTunables(dir);
|
|
352
366
|
if (!spec) return send(res, 400, { ok: false, why: `games/${id} has no tunables.json` });
|
|
353
|
-
const r =
|
|
367
|
+
const r = setLabTunables(spec, json.values ?? {});
|
|
354
368
|
if (r.refused.length) return send(res, 400, { ok: false, why: r.refused.join('; ') });
|
|
355
369
|
writeFileSync(join(dir, 'tunables.json'), formatTunables(r.spec));
|
|
356
370
|
log(`kept in games/${id}/tunables.json: ${r.changed.map((c) => `${c.name} ${c.from} -> ${c.to}`).join(', ') || 'nothing changed'}`);
|
|
@@ -422,7 +436,7 @@ function stateOf(root, b) {
|
|
|
422
436
|
let tunablesError = null;
|
|
423
437
|
try { tunables = readTunables(g.dir); } catch (error) { tunablesError = error.message; }
|
|
424
438
|
const pane = (p) => ({ ok: p.ok, building: p.building, error: p.error ?? null, none: p.none ?? null, at: p.at ?? null, ms: p.ms ?? null, bytes: p.bytes ?? null });
|
|
425
|
-
const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(b.today.tunables).map(([k, s]) => [k, tunableValue(s)])) : null;
|
|
439
|
+
const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(flatTunables(b.today.tunables)).map(([k, s]) => [k, tunableValue(s)])) : null;
|
|
426
440
|
return {
|
|
427
441
|
ok: true,
|
|
428
442
|
version: STUDIO_VERSION,
|
|
@@ -431,7 +445,7 @@ function stateOf(root, b) {
|
|
|
431
445
|
new: { ...pane(b.new), dirty: b.new.dirty ?? null, mode: b.new.mode ?? null },
|
|
432
446
|
today: { ...pane(b.today), ref: b.todayRef, commit: b.today.commit ?? null },
|
|
433
447
|
takes: { default: takes.default, takes: takes.takes, problems: takes.problems, file: `games/${g.id}/lab.json`, exists: takes.exists },
|
|
434
|
-
tunables: { file: `games/${g.id}/tunables.json`, spec: tunables, today: todayValues, error: tunablesError },
|
|
448
|
+
tunables: { file: `games/${g.id}/tunables.json`, spec: flatTunables(tunables), today: todayValues, error: tunablesError },
|
|
435
449
|
};
|
|
436
450
|
}
|
|
437
451
|
|
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
|
@@ -28,7 +28,7 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
|
|
|
28
28
|
import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
|
|
29
29
|
import { newStudio, slugify } from './scaffold.mjs';
|
|
30
30
|
import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
31
|
-
import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
|
|
31
|
+
import { GAME_ID, PACKAGE_ROOT, findStudio, isRulesGame, listExperiences as listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
|
|
32
32
|
import { runningDev } from './dev.mjs';
|
|
33
33
|
import { deployWords } from './deploy-state.mjs';
|
|
34
34
|
import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
|
|
@@ -863,6 +863,25 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
863
863
|
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
864
|
},
|
|
865
865
|
},
|
|
866
|
+
{
|
|
867
|
+
name: 'app_make', title: 'Make an app',
|
|
868
|
+
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.',
|
|
869
|
+
inputSchema: { type: 'object', properties: { id: str('App id: lowercase, digits, hyphens'), name: str('Display name'), ...STUDIO_ARG }, required: ['id', 'name'] },
|
|
870
|
+
annotations: { title: 'Make an app', ...RW },
|
|
871
|
+
run: async (a) => {
|
|
872
|
+
if (!GAME_ID.test(String(a.id ?? ''))) return fail('invalid app id');
|
|
873
|
+
const root = ctx.root(a.studio);
|
|
874
|
+
const r = await cli(ctx, root, `app new ${a.id}`, ['app', 'new', a.id, '--name', String(a.name ?? a.id).slice(0, 60)]);
|
|
875
|
+
if (!r.ended) return stillRunning(r.job, 'Making the app');
|
|
876
|
+
if (r.job.code !== 0) return fail(`Not made: ${whyOf(r.job)}`);
|
|
877
|
+
let install = '';
|
|
878
|
+
if (r.result?.installNeeded) {
|
|
879
|
+
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.`; }
|
|
880
|
+
else install = ' Run studio_install for its engine dependencies.';
|
|
881
|
+
}
|
|
882
|
+
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 });
|
|
883
|
+
},
|
|
884
|
+
},
|
|
866
885
|
{
|
|
867
886
|
name: 'game_make', title: 'Make a multiplayer game',
|
|
868
887
|
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.',
|
|
@@ -1007,7 +1026,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1007
1026
|
},
|
|
1008
1027
|
{
|
|
1009
1028
|
name: 'check', title: 'Two-browser check',
|
|
1010
|
-
description: 'Prove a
|
|
1029
|
+
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
1030
|
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
1031
|
annotations: { title: 'Two-browser check', ...RW }, _meta: ui(UI.build),
|
|
1013
1032
|
run: async (a) => {
|
|
@@ -1017,7 +1036,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1017
1036
|
const game = a.game ?? (games.length === 1 ? games[0].id : null);
|
|
1018
1037
|
if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to check yet');
|
|
1019
1038
|
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: [
|
|
1039
|
+
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: [
|
|
1040
|
+
...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
|
|
1021
1041
|
{ label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
|
|
1022
1042
|
] });
|
|
1023
1043
|
},
|
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 ?? '');
|