@homie-rocks/studio 0.36.0 → 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 +16 -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 +13 -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 +1 -1
- 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 +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +1 -1
- package/lib/lab.mjs +8 -8
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +22 -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/scaffold.mjs +21 -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 +41 -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/build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appProblems } from '../worker/app-format.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio build` — every game in games/ bundled into site/dist, plus the
|
|
3
4
|
* catalogue the Worker serves (games.json).
|
|
@@ -55,7 +56,7 @@ import { buildMedia } from './media.mjs';
|
|
|
55
56
|
import { buildSiteFiles, isoDate, landingOf, readPosts, readTheme } from './site.mjs';
|
|
56
57
|
import { checkJsonLd } from './schema-check.mjs';
|
|
57
58
|
import { SCHEMA_REFUSED } from '../worker/schema.mjs';
|
|
58
|
-
import { PACKAGE_ROOT, isRulesGame, listGames, readStudio } from './studio.mjs';
|
|
59
|
+
import { PACKAGE_ROOT, isRulesGame, listExperiences as listGames, readStudio } from './studio.mjs';
|
|
59
60
|
|
|
60
61
|
// The rules build brings a JavaScript parser with it. It is loaded when a build first needs it, never when this
|
|
61
62
|
// module is: Homie for Claude Desktop starts the toolkit with no node_modules beside it (scripts/desktop.mjs).
|
|
@@ -78,7 +79,7 @@ import { buildParts, partsPlugin } from './parts-build.mjs';
|
|
|
78
79
|
// RULES ON THE SERVER (NETPLAY.md section 29): a game declaring a room object and src/rules.ts builds as a view bundle plus a rules module.
|
|
79
80
|
|
|
80
81
|
/** Never copied into a static game's served folder. */
|
|
81
|
-
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
|
|
82
|
+
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'app.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
|
|
82
83
|
const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
|
|
83
84
|
|
|
84
85
|
/** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
|
|
@@ -269,7 +270,7 @@ export function netplayOf(g, out = null) {
|
|
|
269
270
|
*/
|
|
270
271
|
export function seatsFor(g, net = netplayOf(g)) {
|
|
271
272
|
const named = [net.maxPlayers, net.players?.max, g.players?.max].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
|
|
272
|
-
const asked = named ?? 8;
|
|
273
|
+
const asked = named ?? (g.kind === 'app' ? 32 : 8);
|
|
273
274
|
const max = Math.min(SEAT_MAX, asked);
|
|
274
275
|
const minNamed = [net.minPlayers, net.players?.min, g.players?.min].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
|
|
275
276
|
return { min: Math.min(max, minNamed ?? 1), max, asked };
|
|
@@ -495,6 +496,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
495
496
|
const live = join(root, 'site', 'dist');
|
|
496
497
|
const games = listGames(root).filter((g) => !only || g.id === only);
|
|
497
498
|
if (only && !games.length) throw new Error(`no game "${only}" in games/`);
|
|
499
|
+
for (const g of games) if (g.kind === 'app') { const bad = appProblems(g); if (bad.length) throw new Error(`apps/${g.id}/app.json: ${bad.join('; ')}`); }
|
|
498
500
|
// `--types`: the games' TypeScript is checked first (esbuild only strips types, it never reads them), and a type
|
|
499
501
|
// error stops the build before anything is built.
|
|
500
502
|
const typed = types ? typecheck(root, games, { log }) : null;
|
|
@@ -601,6 +603,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
601
603
|
const bundle = bundleOf(dir);
|
|
602
604
|
builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
|
|
603
605
|
return {
|
|
606
|
+
...(g.kind === 'app' ? { kind: 'app', roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
|
|
604
607
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
605
608
|
...(ui ? { ui } : {}),
|
|
606
609
|
// A rules game's round is its rules' own (`room.rounds`), and its movement a per-kind choice there: game.json's
|
package/lib/characters.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { readManifest, recordAsset, writeManifest } from './asset-manifest.mjs';
|
|
|
28
28
|
import { VERBS, VERB_IDS, bakeLibrary, findClip, gaitOf, rigOf, writeLibrary } from './clips.mjs';
|
|
29
29
|
import { readDecisions } from './decisions.mjs';
|
|
30
30
|
import { FAMILIES, normaliseRig, skeletonOf, socketBone } from './rig.mjs';
|
|
31
|
-
import { GAME_ID } from './studio.mjs';
|
|
31
|
+
import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
|
|
32
32
|
|
|
33
33
|
/** Where humanoid clips come from when a character has none of its own: KayKit's adventurers (CC0, 76 clips). */
|
|
34
34
|
export const CLIP_SOURCES = Object.freeze({
|
|
@@ -146,7 +146,7 @@ function appendClips(into, from) {
|
|
|
146
146
|
* `own`: its own clips (a clip source) or null. Returns { path, skeleton, verbs, clips, missing, bytes, entry }.
|
|
147
147
|
*/
|
|
148
148
|
async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, cache = new Map(), license = null } = {}) {
|
|
149
|
-
const gdir = join(root,
|
|
149
|
+
const gdir = join(experienceDir(root, game));
|
|
150
150
|
const skel = rig.skeleton;
|
|
151
151
|
const fallback = from ?? CLIP_SOURCES[skel.family] ?? null;
|
|
152
152
|
const existing = await existingLibrary(gdir, skel.id);
|
|
@@ -206,7 +206,7 @@ async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, c
|
|
|
206
206
|
*/
|
|
207
207
|
export async function addCharacter(root, game, opts = {}) {
|
|
208
208
|
if (!GAME_ID.test(String(game ?? ''))) throw new Error(`"${game}" is not a game id`);
|
|
209
|
-
const gdir = join(root,
|
|
209
|
+
const gdir = join(experienceDir(root, game));
|
|
210
210
|
if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${game}"`);
|
|
211
211
|
const t = await tools();
|
|
212
212
|
const { optimiseModel, measureDoc } = await import('./optimise.mjs');
|
|
@@ -297,7 +297,7 @@ export async function bakeClips(root, game, asset, { verbs = [], from = null, li
|
|
|
297
297
|
if (!a?.rig) throw new Error(`${asset} is not a rigged character in ${game} (assets add brings one in)`);
|
|
298
298
|
const t = await tools();
|
|
299
299
|
const model = a.files.find((f) => f.role === 'model');
|
|
300
|
-
const doc = await t.io.readBinary(readFileSync(join(root,
|
|
300
|
+
const doc = await t.io.readBinary(readFileSync(join(experienceDir(root, game), model.path)));
|
|
301
301
|
doc.setLogger(t.logger);
|
|
302
302
|
const rig = rigOf(doc);
|
|
303
303
|
if (rig.skeleton.id !== a.rig.skeleton) rig.skeleton.id = a.rig.skeleton;
|
|
@@ -368,7 +368,7 @@ export async function measureGait(root, game, assetId) {
|
|
|
368
368
|
const a = m.assets.find((x) => x.id === assetId);
|
|
369
369
|
const model = (a?.files ?? []).find((f) => f.role === 'model');
|
|
370
370
|
if (!a?.rig || !model) return null;
|
|
371
|
-
const gdir = join(root,
|
|
371
|
+
const gdir = join(experienceDir(root, game));
|
|
372
372
|
const t = await tools();
|
|
373
373
|
const read = async (rel) => { const d = await t.io.readBinary(readFileSync(join(gdir, rel))); d.setLogger(t.logger); return d; };
|
|
374
374
|
const rig = rigOf(await read(model.path));
|
|
@@ -420,7 +420,7 @@ export function animPlan(root, game) {
|
|
|
420
420
|
return {
|
|
421
421
|
ok: true, command: 'anim plan', game, verbs: need, rows, unrigged,
|
|
422
422
|
decisions: Object.fromEntries(['anim.style', 'anim.clips', 'anim.source', 'anim.motion', 'anim.blend', 'anim.procedural', 'rig.skeleton', 'rig.source', 'rig.bones', 'game.feel'].map((k) => [k, d[k] ? { label: d[k].label, state: d[k].state } : null])),
|
|
423
|
-
lab: existsSync(join(root,
|
|
423
|
+
lab: existsSync(join(experienceDir(root, game), 'lab.json')) ? JSON.parse(readFileSync(join(experienceDir(root, game), 'lab.json'), 'utf8')) : null,
|
|
424
424
|
};
|
|
425
425
|
}
|
|
426
426
|
|
|
@@ -434,7 +434,7 @@ export async function animPreview(root, game, { asset = null, verbs = null, size
|
|
|
434
434
|
const rows = plan.rows.filter((r) => !asset || r.id === asset);
|
|
435
435
|
if (!rows.length) return { ok: false, command: 'anim preview', game, why: asset ? `${asset} is not a rigged character in ${game}` : `${game} has no rigged characters yet (assets add <game> <character item>)` };
|
|
436
436
|
const m = readManifest(root, game);
|
|
437
|
-
const gdir = join(root,
|
|
437
|
+
const gdir = join(experienceDir(root, game));
|
|
438
438
|
let tokens = null;
|
|
439
439
|
try { tokens = JSON.parse(readFileSync(join(gdir, 'style.json'), 'utf8')); } catch { tokens = null; }
|
|
440
440
|
tokens = tokens?.palette ? tokens : { palette: { bg: '#e9eef2', ink: '#1d2b3a', accent: '#ff8a5b', accent2: '#5cbf8a', danger: '#e2553f', good: '#45b36b', gold: '#ffd25a' } };
|
package/lib/check.mjs
CHANGED
|
@@ -141,6 +141,9 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
|
|
|
141
141
|
// it either, that refusal (for a lookup: BLOCKED, this computer's network, local testing offered) is the report.
|
|
142
142
|
const cannotAsk = siteRefusal(await reachSite(base, { path: `/${game}/play`, timeout: 15_000 }), { command: 'check', play });
|
|
143
143
|
if (cannotAsk?.preflight === 'local') return cannotAsk;
|
|
144
|
+
let appMeta = null;
|
|
145
|
+
try { appMeta = (await (await fetch(`${base}/api/games`)).json()).games?.find((g) => g.id === game && g.kind === 'app'); } catch { /* browser preflight below still names failures */ }
|
|
146
|
+
if (appMeta) return checkApp({ url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
|
|
144
147
|
if (shots) mkdirSync(shots, { recursive: true });
|
|
145
148
|
const profiles = [];
|
|
146
149
|
const browsers = [];
|
|
@@ -333,3 +336,49 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
|
|
|
333
336
|
for (const p of profiles) rmSync(p, { recursive: true, force: true });
|
|
334
337
|
}
|
|
335
338
|
}
|
|
339
|
+
|
|
340
|
+
/** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
|
|
341
|
+
export async function checkApp({ url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
|
|
342
|
+
const proof = meta.check;
|
|
343
|
+
if (!proof || typeof proof.action !== 'string' || typeof proof.observe !== 'string') return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
|
|
344
|
+
if (shots) mkdirSync(shots, { recursive: true });
|
|
345
|
+
const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
|
|
346
|
+
let browser;
|
|
347
|
+
const surfaces = [];
|
|
348
|
+
const room = `check-${Date.now().toString(36)}`;
|
|
349
|
+
try {
|
|
350
|
+
browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
351
|
+
for (const [name, width, height, path] of [['wall', 1280, 720, 'tv'], ['phone', 390, 844, 'open'], ['phone-two', 390, 844, 'open']]) {
|
|
352
|
+
const context = await browser.createBrowserContext();
|
|
353
|
+
const page = await context.newPage();
|
|
354
|
+
await page.setViewport({ width, height, isMobile: name !== 'wall', hasTouch: name !== 'wall' });
|
|
355
|
+
const params = new URLSearchParams(proof.params ?? {}); params.set('room', room);
|
|
356
|
+
await page.goto(`${url}/${game}/${path}?${params}`, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
|
357
|
+
await page.waitForFunction(() => window.__shell?.link?.state === 'online', { timeout: 90_000 });
|
|
358
|
+
const frame = page.frames().find((f) => /\/__game\//.test(f.url()));
|
|
359
|
+
if (!frame) throw new Error(`${name} never loaded the app`);
|
|
360
|
+
await frame.waitForSelector(proof.observe, { timeout: 30_000 });
|
|
361
|
+
surfaces.push({ name, page, frame });
|
|
362
|
+
log(`${name} connected to ${room}`);
|
|
363
|
+
}
|
|
364
|
+
const textOf = (f) => f.$eval(proof.observe, (e) => e.textContent);
|
|
365
|
+
const actor = surfaces[1];
|
|
366
|
+
await actor.frame.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action);
|
|
367
|
+
const before = await textOf(actor.frame);
|
|
368
|
+
await actor.frame.click(proof.action);
|
|
369
|
+
await actor.frame.waitForFunction((selector, was) => document.querySelector(selector)?.textContent !== was, {}, proof.observe, before);
|
|
370
|
+
const after = await textOf(actor.frame);
|
|
371
|
+
for (const s of surfaces) await s.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
|
|
372
|
+
// The actor reloads with its ticket and room. The other screens remain live.
|
|
373
|
+
await actor.page.reload({ waitUntil: 'domcontentloaded' });
|
|
374
|
+
await actor.page.waitForFunction(() => window.__shell?.link?.state === 'online', { timeout: 90_000 });
|
|
375
|
+
actor.frame = actor.page.frames().find((f) => /\/__game\//.test(f.url()));
|
|
376
|
+
await actor.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
|
|
377
|
+
const rooms = await Promise.all(surfaces.map((s) => s.page.evaluate(() => window.__shell.room)));
|
|
378
|
+
if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
|
|
379
|
+
if (shots) for (const s of surfaces) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 65 });
|
|
380
|
+
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
381
|
+
return { ok: true, command: 'check', kind: 'app', room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: true };
|
|
382
|
+
} catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
|
|
383
|
+
finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
|
|
384
|
+
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { whyFailed } from './net.mjs';
|
|
|
40
40
|
import { repoOf } from './repo.mjs';
|
|
41
41
|
import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
|
|
42
42
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
43
|
-
import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
43
|
+
import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
44
44
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
45
45
|
|
|
46
46
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
package/lib/codex.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import { extname, join, relative, resolve, sep } from 'node:path';
|
|
|
41
41
|
import { latestFeedFor, summarize } from './feed-summary.mjs';
|
|
42
42
|
import { escapeHtml as esc, renderMarkdown } from './markdown.mjs';
|
|
43
43
|
import { readTheme } from './site.mjs';
|
|
44
|
-
import { GAME_ID, listGames, readStudio } from './studio.mjs';
|
|
44
|
+
import { experienceDir, experienceFile, GAME_ID, listExperiences as listGames, readStudio } from './studio.mjs';
|
|
45
45
|
import { decisionRows, readDecisions } from './decisions.mjs';
|
|
46
46
|
|
|
47
47
|
export const CODEX_FILE = 'CODEX.md';
|
|
@@ -514,7 +514,7 @@ function codexGame(root, id, { create = false } = {}) {
|
|
|
514
514
|
const game = listGames(root).find((g) => g.id === id);
|
|
515
515
|
if (game) return game;
|
|
516
516
|
if (!GAME_ID.test(String(id ?? ''))) return null;
|
|
517
|
-
const dir = join(root,
|
|
517
|
+
const dir = join(experienceDir(root, id));
|
|
518
518
|
if (existsSync(join(dir, 'game.json'))) return null;
|
|
519
519
|
return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, name: null, planned: true } : null;
|
|
520
520
|
}
|
package/lib/collision.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { createRequire } from 'node:module';
|
|
|
32
32
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
33
33
|
import { pathToFileURL } from 'node:url';
|
|
34
34
|
import { modelTools } from './optimise.mjs';
|
|
35
|
-
import { listGames } from './studio.mjs';
|
|
35
|
+
import { listExperiences as listGames } from './studio.mjs';
|
|
36
36
|
|
|
37
37
|
/** The body that must fit when a file names none: a person, in metres. */
|
|
38
38
|
export const DEFAULT_MOVER = Object.freeze({ radius: 0.35, height: 1.8, step: 0.3, maxDrop: 0.5, arrival: 1, speed: 4 });
|
package/lib/decisions.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { dirname, join } from 'node:path';
|
|
|
20
20
|
import { HEX, deltaHex, hexToRgb, luminance, mix, nudge } from './colour.mjs';
|
|
21
21
|
import { CAMERAS, FAMILIES, FONT_SETS, GENRES, LIGHTS, MOODS, PALETTES, PALETTE_KEYS, RENDERS, SHAPES } from './style-presets.mjs';
|
|
22
22
|
import { readTheme } from './site.mjs';
|
|
23
|
-
import { GAME_ID, listGames } from './studio.mjs';
|
|
23
|
+
import { experienceDir, experienceFile, GAME_ID, listExperiences as listGames } from './studio.mjs';
|
|
24
24
|
|
|
25
25
|
export const DECISIONS_FILE = join('codex', 'decisions.json');
|
|
26
26
|
export const STATES = Object.freeze(['auto', 'steered', 'pinned', 'locked']);
|
|
@@ -35,7 +35,7 @@ const clean = (s, n = 200) => String(s ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').
|
|
|
35
35
|
|
|
36
36
|
/** What the automatic picks read: the person's words, the codex (its concept, art text and cards), the game. */
|
|
37
37
|
export function contextOf(root, id, { prompt = '' } = {}) {
|
|
38
|
-
const dir = join(root,
|
|
38
|
+
const dir = join(experienceDir(root, id));
|
|
39
39
|
let codex = '';
|
|
40
40
|
try { codex = readFileSync(join(dir, 'CODEX.md'), 'utf8').replace(/<!--[\s\S]*?-->/g, ''); } catch { codex = ''; }
|
|
41
41
|
let game = {};
|
|
@@ -282,12 +282,12 @@ function castFrom(ctx) {
|
|
|
282
282
|
|
|
283
283
|
/* ------------------------------------------------------------------ the file */
|
|
284
284
|
|
|
285
|
-
const fileOf = (root, id) => join(root,
|
|
285
|
+
const fileOf = (root, id) => join(experienceDir(root, id), DECISIONS_FILE);
|
|
286
286
|
|
|
287
287
|
function gameDirOf(root, id) {
|
|
288
288
|
if (!GAME_ID.test(String(id ?? ''))) throw new Error(`"${id}" is not a game id`);
|
|
289
|
-
const dir = join(root,
|
|
290
|
-
if (!existsSync(
|
|
289
|
+
const dir = join(experienceDir(root, id));
|
|
290
|
+
if (!existsSync(experienceFile(root, id)) && !existsSync(join(dir, 'CODEX.md'))) throw new Error(`no game "${id}" (no games/${id}/game.json or CODEX.md)${listGames(root).length ? `; games: ${listGames(root).map((g) => g.id).join(', ')}` : ''}`);
|
|
291
291
|
return dir;
|
|
292
292
|
}
|
|
293
293
|
|
|
@@ -332,7 +332,7 @@ export function styleTokens(doc) {
|
|
|
332
332
|
function writeStyleTokens(root, id, doc) {
|
|
333
333
|
const t = styleTokens(doc);
|
|
334
334
|
if (!t) return;
|
|
335
|
-
const file = join(root,
|
|
335
|
+
const file = join(experienceDir(root, id), 'style.json');
|
|
336
336
|
const text = `${JSON.stringify(t, null, 2)}\n`;
|
|
337
337
|
if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
|
|
338
338
|
}
|
|
@@ -790,7 +790,7 @@ export function pickDirection(root, id, choice, { mixes = {}, manifest = null }
|
|
|
790
790
|
|
|
791
791
|
/** One dated line under the codex's Latest, newest first ("- 2026-10-02: …"). */
|
|
792
792
|
export function addLatestLine(root, id, text) {
|
|
793
|
-
const file = join(root,
|
|
793
|
+
const file = join(experienceDir(root, id), 'CODEX.md');
|
|
794
794
|
if (!existsSync(file)) return false;
|
|
795
795
|
const src = readFileSync(file, 'utf8');
|
|
796
796
|
const line = `- ${now().slice(0, 10)}: ${clean(text, 300)}`;
|
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,17 +414,20 @@ 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;
|
|
416
419
|
let published = Promise.resolve();
|
|
417
420
|
const rulesLook = async () => {
|
|
418
421
|
if (rebuilding || ending) return;
|
|
419
422
|
for (const g of listGames(root).filter(isRulesGame)) {
|
|
420
423
|
const now = rulesStamp(g);
|
|
421
|
-
|
|
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);
|
|
422
427
|
stamps.set(g.id, now);
|
|
423
428
|
rebuilding = true;
|
|
424
429
|
try {
|
|
425
|
-
log(
|
|
430
|
+
log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} changed: checking its rules and building it again…`);
|
|
426
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; } };
|
|
427
432
|
const before = hash();
|
|
428
433
|
let release;
|
|
@@ -439,9 +444,17 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
439
444
|
await stopped;
|
|
440
445
|
} });
|
|
441
446
|
} finally { release(); }
|
|
442
|
-
|
|
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.'}`);
|
|
443
449
|
} catch (error) {
|
|
444
|
-
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);
|
|
445
458
|
} finally { rebuilding = false; }
|
|
446
459
|
return;
|
|
447
460
|
}
|
|
@@ -484,3 +497,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
484
497
|
if (failed) return { ok: false, command: 'dev', needs: 'local-rooms', socket: failed.socket, expected: failed.expected, why: failed.why };
|
|
485
498
|
return { ok: true, command: 'dev', stopped: true };
|
|
486
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 { 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
|
@@ -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, isRulesGame, 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,7 @@ 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: [
|
|
1021
1040
|
...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
|
|
1022
1041
|
{ label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
|
|
1023
1042
|
] });
|
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}`);
|