@homie-rocks/studio 0.31.0 → 0.32.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +44 -6
  3. package/bin/homie-studio.mjs +101 -8
  4. package/lib/build.mjs +1 -1
  5. package/lib/cloudflare.mjs +80 -6
  6. package/lib/directory.mjs +61 -9
  7. package/lib/doctor.mjs +23 -10
  8. package/lib/mcp-tools.mjs +18 -11
  9. package/lib/mcp.mjs +2 -0
  10. package/lib/parts.mjs +27 -0
  11. package/lib/routes.mjs +110 -2
  12. package/lib/scaffold.mjs +3 -3
  13. package/lib/standalone-cli.mjs +106 -0
  14. package/lib/standalone-files.mjs +360 -0
  15. package/lib/standalone-icons.mjs +106 -0
  16. package/lib/standalone-lock.json +3404 -0
  17. package/lib/standalone-pins.json +18 -0
  18. package/lib/standalone-tools.mjs +63 -0
  19. package/lib/standalone.mjs +868 -0
  20. package/lib/template-history.json +80 -0
  21. package/netplay/NETPLAY.md +148 -10
  22. package/netplay/netplay.ts +234 -33
  23. package/package.json +2 -1
  24. package/parts/PARTS.md +8 -2
  25. package/port/index.ts +1 -1
  26. package/port/room.ts +228 -23
  27. package/standalone/STANDALONE.md +313 -0
  28. package/standalone/electron/files.cjs +50 -0
  29. package/standalone/electron/main.cjs +139 -0
  30. package/standalone/electron/pack.mjs +82 -0
  31. package/standalone/shell/index.html +36 -0
  32. package/standalone/shell/shell.css +46 -0
  33. package/standalone/shell/shell.js +346 -0
  34. package/starters/ember-vale/src/main.ts +4 -0
  35. package/starters/gem-rush/src/main.ts +29 -7
  36. package/starters/gem-rush-3d/src/main.ts +36 -7
  37. package/starters/hero-rush-3d/src/main.ts +36 -7
  38. package/worker/index.mjs +55 -8
  39. package/worker/pages.mjs +19 -10
  40. package/worker/parts.mjs +43 -2
  41. package/worker/standalone.mjs +34 -0
  42. package/worker/version.mjs +1 -1
package/lib/directory.mjs CHANGED
@@ -16,10 +16,11 @@ import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
16
16
  * `publishesLeft`, or those inside `quota` / `limits` / `rateLimit`), or the standard rate-limit headers
17
17
  * (`ratelimit-remaining`, `x-ratelimit-remaining`, `retry-after`). What it finds is said after every publish.
18
18
  *
19
- * Before a publish there is nothing to ask (the directory has no "how many are left" call this toolkit knows), so
20
- * the toolkit says what THIS COMPUTER knows: how many it has sent today (UTC, the day the cap counts in), and
21
- * what the directory said was left after the last one. That is a floor, never a promise: a publish from another
22
- * computer or from the Homie connector is not counted here.
19
+ * Before a publish, `publishBefore` asks the directory itself, with a read (GET /api/studio/publish?site=<origin>
20
+ * answers { ok, site, listed, limit, remaining, resetsAt } and changes nothing). When the directory cannot be
21
+ * reached, or is one that has no such read, it says so plainly and falls back to what THIS COMPUTER knows: how many
22
+ * it has sent today (UTC, the day the cap counts in), and what the directory said was left after the last one. That
23
+ * is a floor, never a promise: a publish from another computer or from the Homie connector is not counted here.
23
24
  */
24
25
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v) : null);
25
26
  const first = (...values) => { for (const v of values) { const n = num(v); if (n !== null) return n; } return null; };
@@ -82,16 +83,67 @@ export function partsForListing(root) {
82
83
  return { ...report, games, ok: games.every((g) => g.ok) && !report.why };
83
84
  }
84
85
 
85
- export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
86
- const studio = readStudio(root);
87
- const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
88
- const url = site || siteUrl(root, studio);
89
- // Licences first: a public game may ship only assets with a licence record that allows it.
86
+ /** The asset licence problems that stop a listing: a public game may ship only assets with a licence record that allows it. */
87
+ function licenceRefusals(root) {
90
88
  const refused = [];
91
89
  for (const g of listGames(root)) {
92
90
  if (g.launch === 'private' || g.launch === 'invite') continue;
93
91
  for (const p of licenceProblems(root, g.id, readManifest(root, g.id))) if (p.level === 'refuse') refused.push({ game: g.id, ...p });
94
92
  }
93
+ return refused;
94
+ }
95
+
96
+ /**
97
+ * `homie-studio publish --before` and the studio_publish tool's `before: true`, one code path: what a publish would
98
+ * meet, and NOTHING is published. It sends one GET (the directory's own read-only count for this site), never a
99
+ * POST, and writes no file, not even this computer's count. { ok: true, published: false, listed, publishes: {
100
+ * remaining, limit, resetsAt, sentToday, from: 'directory' | 'this computer', line }, parts?, refused? }, and
101
+ * `unreached: { why }` when the directory could not be asked. `ok` says the preflight ran, never that a publish
102
+ * would go through.
103
+ */
104
+ export async function publishBefore(root, { homie, site, log = () => {}, fetchFn } = {}) {
105
+ const studio = readStudio(root);
106
+ const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
107
+ const url = site || siteUrl(root, studio);
108
+ const refused = licenceRefusals(root);
109
+ const parts = partsForListing(root);
110
+ const local = publishesSoFar(root);
111
+ const mine = `this computer has sent ${local.sent} publish${local.sent === 1 ? '' : 'es'} today (UTC)${local.remaining !== null ? `, and the directory said ${local.remaining === 0 ? 'none were' : `${local.remaining} ${local.remaining === 1 ? 'was' : 'were'}`} left after the last one${local.limit !== null ? ` (of ${local.limit} a day)` : ''}` : ''}; a publish from another computer or from the Homie connector is not counted here`;
112
+ let origin = null;
113
+ try { origin = url ? new URL(url).origin : null; } catch { origin = null; }
114
+ let asked = null;
115
+ if (origin) asked = await request(`${directory}/api/studio/publish?site=${encodeURIComponent(origin)}`, { method: 'GET', headers: { accept: 'application/json' } }, { timeout: 15_000, ...(fetchFn ? { fetchFn } : {}) });
116
+ const q = asked?.ok ? quotaOf(asked.body, asked.headers) : null;
117
+ const counted = Boolean(q && q.remaining !== null);
118
+ const listed = asked?.ok && typeof asked.body?.listed === 'boolean' ? asked.body.listed : null;
119
+ const why = !origin ? null : asked.ok ? 'it answered without a count' : String(asked.body?.message ?? asked.why ?? 'no answer').split('\n')[0];
120
+ const line = !origin
121
+ ? 'Not published. This studio has no live site yet, so there is nothing to list: put it online first (npm run deploy).'
122
+ : counted
123
+ ? `Not published. The directory says: this site is ${listed === true ? 'listed' : listed === false ? 'not listed yet' : 'known to it or not, it did not say'}; ${quotaLine(q).replace(/^P/, 'p')}`
124
+ : `Not published. The directory could not be asked how many publishes are left (${why}), so this is only what this computer knows: ${mine}.`;
125
+ const lines = [
126
+ line,
127
+ ...(refused.length ? [`A publish now would be refused: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'} (${refused.slice(0, 6).map((p) => `${p.game}/${p.asset}: ${p.problem}`).join('; ')}${refused.length > 6 ? '; …' : ''}). homie-studio assets check <id> says how to fix each.`] : []),
128
+ ...partsPlanLines(parts, { at: 'publish' }),
129
+ ];
130
+ for (const l of lines) log(l);
131
+ return {
132
+ ok: true, command: 'publish before', published: false, directory, site: url ?? null, listed,
133
+ publishes: { remaining: counted ? q.remaining : local.remaining, limit: (counted ? q.limit : null) ?? local.limit, resetsAt: q?.resetsAt ?? null, sentToday: local.sent, from: counted ? 'directory' : 'this computer', line },
134
+ ...(origin && !counted ? { unreached: { why, ...(asked.status !== undefined ? { status: asked.status } : {}) } } : {}),
135
+ ...(refused.length ? { refused } : {}),
136
+ ...(parts.games.length || parts.sharing.length || parts.why ? { parts } : {}),
137
+ lines,
138
+ };
139
+ }
140
+
141
+ export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
142
+ const studio = readStudio(root);
143
+ const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
144
+ const url = site || siteUrl(root, studio);
145
+ // Licences first: a public game may ship only assets with a licence record that allows it.
146
+ const refused = licenceRefusals(root);
95
147
  if (refused.length) return { ok: false, command: 'publish', refused, why: `not listed: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'}:\n${refused.map((p) => ` ${p.game}/${p.asset}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).join('\n')}\nhomie-studio assets check <id> says the same; fix them, deploy, then publish again.` };
96
148
  if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
97
149
  // Parts from other studios in the games about to be listed: their licences and credits, and plainly when two
package/lib/doctor.mjs CHANGED
@@ -11,8 +11,8 @@
11
11
  * homie in its own config, the row says so: that name is the plugin's connector's
12
12
  * Homie's holds in Codex and Grok Build only: whether the plugin's hooks ran just now (they leave a dated
13
13
  * mark, hooks/codex.mjs), so a session where nothing is held says so, and how to turn
14
- * them on (Codex: trust them in /hooks; Grok Build 1.0.41 runs no plugin's hooks, so
15
- * there the row is off until a Grok that runs them). The app is the one the AI names
14
+ * them on (Codex: trust them in /hooks; Grok Build: trust the plugin, which
15
+ * `grok plugin install … --trust` does). The app is the one the AI names
16
16
  * (--client codex|grok) or its environment shows
17
17
  * Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
18
18
  * first deploy; a deploy that went through proves it)
@@ -27,6 +27,9 @@
27
27
  * Stripe for selling only in a studio with shop.json (0.24.3): Stripe's own agent plugin (its MCP server
28
28
  * and skills) for this AI, set up when the studio starts selling; the shop's key itself
29
29
  * goes in through `homie-studio shop connect`, never here
30
+ * Standalone builds optional only in a studio where a game.json has a "standalone" block: Xcode, the Android SDK
31
+ * and a JDK it builds on, Apple signing (a count of identities, never a name),
32
+ * notarization and steamcmd (lib/standalone.mjs standaloneRows)
30
33
  * Clef locally optional Ollama with clef-flash: AI under dev for free (never downloaded without a yes)
31
34
  * status line optional (Claude Code only) the build's progress under the prompt
32
35
  *
@@ -47,7 +50,8 @@ import { detectLocalAi } from './local-ai.mjs';
47
50
  import { findChrome } from './chrome.mjs';
48
51
  import { whyFailed } from './net.mjs';
49
52
  import { isOurs } from './statusline.mjs';
50
- import { findStudio, readLocal, readStudio } from './studio.mjs';
53
+ import { findStudio, listGames, readLocal, readStudio } from './studio.mjs';
54
+ import { standaloneRows } from './standalone.mjs';
51
55
  import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
52
56
  import { STUDIO_VERSION } from './version.mjs';
53
57
 
@@ -271,26 +275,27 @@ export async function setupStatus({
271
275
  });
272
276
  }
273
277
 
274
- // Homie's holds, in Codex and Grok Build: Codex runs the plugin's hooks only once the person has trusted them, Grok
275
- // Build 1.0.41 was seen to run no plugin's hooks at all, and neither app says so. The hooks leave a dated mark at
276
- // every message and tool call; a fresh one means they are on. Off in Grok is Grok's doing, so it is said as that.
278
+ // Homie's holds, in Codex and Grok Build: each app runs a plugin's hooks only once the person has trusted them
279
+ // (Codex in /hooks, Grok by trusting the plugin), and neither says so when it skips them. The hooks leave a dated
280
+ // mark at every message and tool call; a fresh one means they are on. (0.30.2 said Grok runs no plugin's hooks.
281
+ // It does: the plugin's root manifest named no hooks file, so Grok loaded the Claude Code mod's and found none.)
277
282
  if (app === 'codex' || app === 'grok') {
278
283
  const name = APPS[app].name;
279
284
  const seen = holdsMark(app, { env, now: nowMs });
280
285
  const on = Boolean(seen && seen.ageMs <= HOLDS_FRESH_MS);
281
286
  rows.push({
282
- // Off in Codex is the person's to fix now (trust the hooks); off in Grok is nothing they can fix.
283
- id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' : app === 'codex' ? 'act' : 'optional', on, app,
287
+ // Off is the person's to fix now, in either app: trust the hooks.
288
+ id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' : 'act', on, app,
284
289
  detail: on ? `on: Homie's hooks ran in ${name} ${ago(seen.ageMs)} ago`
285
290
  : seen ? `off: Homie's hooks last ran in ${name} ${ago(seen.ageMs)} ago, and not for this session's calls`
286
- : app === 'grok' ? 'off: Grok Build runs no plugin\'s hooks yet (1.0.41), so nothing is held in Grok' : `off: Homie's hooks have not run in ${name} on this computer`,
291
+ : `off: Homie's hooks have not run in ${name} on this computer`,
287
292
  ...(seen ? { seen: { at: new Date(seen.at).toISOString(), event: seen.event } } : {}),
288
293
  unlocks: 'a wait for your own "proceed <code>" before a production deploy, an edit to a file the studio protects, a Cloudflare change outside the deploy, a paid call past the budget and a model download; and secrets taken out of what your AI reads',
289
294
  fix: on ? null : {
290
295
  who: 'person',
291
296
  say: app === 'codex'
292
297
  ? 'In Codex, open /hooks and trust Homie\'s three hooks: Codex runs no plugin\'s hooks until you do. Then run this again. Until then nothing is held, so your AI asks you before each of those itself.'
293
- : 'Nothing to do on your side: Grok Build 1.0.41 runs no plugin\'s hooks (none are registered in its headless and agent sessions, from any plugin; checked 2026-10-04), so Homie\'s holds are off in Grok. Nothing is held and no secret is taken out of what your AI reads, so your AI asks you before each of those itself. This row turns on by itself when a Grok that runs them leaves the hooks\' mark.',
298
+ : 'Grok runs a plugin\'s hooks only once the plugin is trusted, and only from a Homie plugin new enough to name them for Grok. Install it again with trust (grok plugin install homie-rocks/homie#plugins/homie --trust), start a new session (/hooks lists Homie\'s three), then run this again. Until then nothing is held, so your AI asks you before each of those itself.',
294
299
  },
295
300
  });
296
301
  }
@@ -414,6 +419,14 @@ export async function setupStatus({
414
419
  });
415
420
  }
416
421
 
422
+ // Standalone copies (standalone/STANDALONE.md), only in a studio where a game has a "standalone" block: what the
423
+ // desktop and phone builds need here. Every one is optional: a target whose tool is missing is skipped, never blocking.
424
+ {
425
+ let asked = false;
426
+ try { asked = Boolean(root) && listGames(root).some((g) => g.standalone && typeof g.standalone === 'object'); } catch { asked = false; }
427
+ if (asked) rows.push(...await standaloneRows({ platform, exec, env }));
428
+ }
429
+
417
430
  // The Claude Code status line (only in Claude Code, only in a studio).
418
431
  if (root && env.CLAUDECODE === '1') {
419
432
  let on = false;
package/lib/mcp-tools.mjs CHANGED
@@ -31,7 +31,7 @@ import { repoFromUrl, studioRepo } from './repo.mjs';
31
31
  import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
32
32
  import { runningDev } from './dev.mjs';
33
33
  import { deployWords } from './deploy-state.mjs';
34
- import { beforeLine, partsForListing, publishesSoFar } from './directory.mjs';
34
+ import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
35
35
  import { STUDIO_VERSION } from './version.mjs';
36
36
  import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
37
37
  import { pinnedVersion } from './upgrade.mjs';
@@ -42,6 +42,7 @@ import { pictureFor } from './pictures.mjs';
42
42
  import { ART_UI, artToolDefs } from './art-tools.mjs';
43
43
  // GAME PARTS (parts/PARTS.md): parts_find, part_add, part_new and part_share are lib/parts-tools.mjs.
44
44
  import { PARTS_FIRST, partsToolDefs } from './parts-tools.mjs';
45
+ import { standaloneToolDefs } from './standalone-tools.mjs';
45
46
  import { partsPlanLines } from './parts-build.mjs';
46
47
  import { FEEDBACK_APPS, FEEDBACK_KINDS, FEEDBACK_LIMITS, appOf, cleanNote, draftId, noteBlock, sendNote, withLine } from './feedback.mjs';
47
48
 
@@ -1085,7 +1086,7 @@ export function toolDefs(ctx, avail = {}) {
1085
1086
  {
1086
1087
  name: 'studio_deploy', title: 'Put the studio online',
1087
1088
  description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects, free plan, no payment method. Call it with plan: true first and tell the person in two or three lines what it creates and costs. If Cloudflare is not signed in, cloudflare_login opens it in their browser to approve once. Runs in the background with the build card.',
1088
- inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ...STUDIO_ARG } },
1089
+ inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ownRoute: { type: 'boolean', description: 'Only after the plan or a deploy warned that another site\'s route on the studio\'s custom domain covers its hostname, and the person agreed: add the studio\'s own exact-host route (the one line the warning gives) and deploy. It never edits or removes any other route' }, ...STUDIO_ARG } },
1089
1090
  annotations: { title: 'Put the studio online', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(UI.build),
1090
1091
  run: async (a) => {
1091
1092
  const root = ctx.root(a.studio);
@@ -1095,10 +1096,10 @@ export function toolDefs(ctx, avail = {}) {
1095
1096
  if (!r.ended) return stillRunning(r.job, 'The deploy plan');
1096
1097
  if (r.job.code !== 0) return fail(whyOf(r.job));
1097
1098
  const p = r.result;
1098
- return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1099
+ return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1099
1100
  }
1100
1101
  const studio = readStudio(root);
1101
- return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy'] }] });
1102
+ return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
1102
1103
  },
1103
1104
  },
1104
1105
  {
@@ -1119,18 +1120,22 @@ export function toolDefs(ctx, avail = {}) {
1119
1120
  },
1120
1121
  {
1121
1122
  name: 'studio_publish', title: 'List the studio in the Homie directory',
1122
- description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta). The beta also caps how many times a studio may publish in a day: the answer says how many are left when the directory gives the number, and before: true only says what this computer knows (how many it has sent today, what the directory last said was left) without publishing.',
1123
- inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: 'Only say how many publishes this computer has sent today and what the directory last said was left; publish nothing' }, ...STUDIO_ARG } },
1123
+ description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta). The beta also caps how many times a studio may publish in a day: the answer says how many are left when the directory gives the number, and before: true publishes nothing: it asks the directory how many are left and whether the site is listed (a read), and says so plainly when the directory cannot be reached.',
1124
+ inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: 'Publish nothing: ask the directory (a read) whether this site is listed and how many publishes are left today, and say the parts\' licence lines. When the directory cannot be reached it says so, with what this computer has sent today' }, ...STUDIO_ARG } },
1124
1125
  annotations: { title: 'List in the directory', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1125
1126
  run: async (a) => {
1126
1127
  const root = ctx.root(a.studio);
1127
- // What this computer knows of the day's cap, before anything is sent (lib/directory.mjs).
1128
- const soFar = beforeLine(publishesSoFar(root));
1129
1128
  // The parts in the games a listing shows, in the deploy plan's own lines (licences, credits, and plainly when
1130
1129
  // two cannot be combined): said before the studio is listed, and again with the answer.
1131
1130
  const partsNow = partsForListing(root);
1132
1131
  const partsSaid = partsPlanLines(partsNow, { at: 'publish' });
1133
- if (a.before === true) return ok([`${soFar.replace(/^Publishing to the directory: /, 'Not published. ')} A publish from another computer or from the Homie connector is not counted here.`, ...partsSaid].join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...(partsSaid.length ? { parts: partsNow } : {}) });
1132
+ // before: true is `publish --before`, the same function the terminal runs (lib/directory.mjs publishBefore):
1133
+ // the directory's read-only count for this site, the parts' lines, and nothing published. Run here, by THIS
1134
+ // toolkit, never handed to the studio's pinned one: a 0.31.0 toolkit reads `publish --before` as `publish`.
1135
+ if (a.before === true) {
1136
+ const b = await publishBefore(root, { site: a.site ? String(a.site) : undefined });
1137
+ return ok(b.lines.join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...b });
1138
+ }
1134
1139
  const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
1135
1140
  if (!r.ended) return stillRunning(r.job, 'Listing');
1136
1141
  if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`, r.result?.publishes ? { kind: 'publish', ok: false, needs: r.result.needs ?? null, publishes: r.result.publishes } : undefined);
@@ -1264,7 +1269,7 @@ export function toolDefs(ctx, avail = {}) {
1264
1269
  },
1265
1270
  {
1266
1271
  name: 'studio_job', title: 'A job, so far',
1267
- description: 'Where a job a tool handed back is (an npm install, a check, a deploy, a render): running or done, its last lines and its result. Waits up to 30 s for it to finish.',
1272
+ description: 'Where a job a tool handed back is (an npm install, a check, a deploy, a render, a standalone build): running or done, its last lines and its result. A result that comes with its own words for the person (a standalone build\'s) is given in those words: read them to the person. Waits up to 30 s for it to finish.',
1268
1273
  inputSchema: { type: 'object', properties: { job: str('The job id (j_…)') }, required: ['job'] },
1269
1274
  annotations: { title: 'A job', ...RO },
1270
1275
  run: async (a) => {
@@ -1273,7 +1278,7 @@ export function toolDefs(ctx, avail = {}) {
1273
1278
  await waitJob(job, Math.min(ctx.waitMs, 30_000));
1274
1279
  const v = jobView(job, { lines: 20 });
1275
1280
  if (job.endedAt) ctx.status = null;
1276
- return ok(`${v.label}: ${v.state}${v.state === 'running' ? ` (${v.seconds} s so far)` : ` after ${v.seconds} s`}${v.state === 'failed' ? `: ${whyOf(job)}` : ''}${v.result ? `\n${JSON.stringify(v.result, null, 1).slice(0, 8000)}` : v.tail.length ? `\n${v.tail.join('\n')}` : ''}`, { kind: 'job', ...v });
1281
+ return ok(`${v.label}: ${v.state}${v.state === 'running' ? ` (${v.seconds} s so far)` : ` after ${v.seconds} s`}${v.state === 'failed' ? `: ${whyOf(job)}` : ''}${Array.isArray(v.result?.say) && v.result.say.length ? `\n${v.result.say.join('\n').slice(0, 16_000)}` : v.result ? `\n${JSON.stringify(v.result, null, 1).slice(0, 8000)}` : v.tail.length ? `\n${v.tail.join('\n')}` : ''}`, { kind: 'job', ...v });
1277
1282
  },
1278
1283
  },
1279
1284
  {
@@ -1362,6 +1367,8 @@ export function toolDefs(ctx, avail = {}) {
1362
1367
  // Art direction, the cast, the starter library, generated props, checks, the lineup and the rights (lib/art-tools.mjs).
1363
1368
  tools.push(...artToolDefs(ctx, { ok, fail, cli, stillRunning, whyOf, needsInstall, ui, str, STUDIO_ARG, RO, RW, pictureFor, findNode, startJob, waitJob }));
1364
1369
  tools.push(...partsToolDefs(ctx, { ok, fail, str, STUDIO_ARG, RO, RW }));
1370
+ // A game as an app of its own, for computers and phones (lib/standalone-tools.mjs).
1371
+ tools.push(...standaloneToolDefs(ctx, { ok, fail, cli, stillRunning, whyOf, needsInstall, str, STUDIO_ARG }));
1365
1372
  const topics = guideTopics(ctx);
1366
1373
  if (topics.length) {
1367
1374
  tools.push({
package/lib/mcp.mjs CHANGED
@@ -53,6 +53,8 @@ To make one mechanic of a game feel better (a jump, a hit, a dash), game_lab ope
53
53
 
54
54
  A game's look is a set of decisions (render style, palette, light, camera, fonts, the cast and its budgets), automatic from the person's words until they steer or lock one (studio_guide { "topic": "style" }). style_explore draws three directions with the game engine for free; decision_set picks, steers and locks; assets_find searches the free CC0 starter library and asset_add copies a model in with its licence; asset_make makes a prop on the person's own fal account only after they agreed to its price; asset_check, asset_lineup and asset_rights keep it one game, phone-sized and properly licensed (studio_guide { "topic": "models" }).
55
55
 
56
+ A game as an app of its own (a desktop app for macOS, Windows and Linux; an iPhone or Android app): game_standalone, with plan: true first. It is the same web game in a thin shell and still plays through the studio's own site. Say plainly what its plan says a standalone copy does not have (no accounts, cloud saves, shop or chat), that a build is the kind of file Steam or a store accepts for upload and never a promise it is accepted, that nothing is uploaded for them, and what its result says was started and what was only built (studio_guide { "topic": "standalone" }).
57
+
56
58
  Telling Homie (homie_feedback): when the person is stuck, confused or frustrated, after an error you could not fix, or at the end of their first studio setup or first publish, you may OFFER, once a session, to send the people who make Homie a short note about it. Draft it in plain words from what happened (it sends nothing), show it exactly as it would go (its card has Send, Edit and Don't send), and send only after they say yes. Never nag: a no is final for the session. When they ask to tell Homie something, draft it with offered: false.
57
59
 
58
60
  Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
package/lib/parts.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  * is `parts/<id>/part.json` and the files beside it. A part is a piece of a game, never the whole game.
7
7
  *
8
8
  * checkPart() the shape of a part.json, each problem in plain words with its fix
9
+ * previewAspect() a preview's declared shape ("4:3") as two whole numbers, or null: the only reading of it
9
10
  * hashDir() every file of a part with its SHA-256 and size (node:crypto): written by the tool, never by hand
10
11
  * newPart() a part lifted out of one of the studio's games (the game still builds and plays the same)
11
12
  * fileRights() whose each file is: its own record, a game's asset record of the same bytes, or the part's provenance
@@ -42,6 +43,30 @@ const clean = (s, n = 300) => (typeof s === 'string' ? s.replace(/[\x00-\x1f\x7f
42
43
  const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
43
44
  export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
44
45
 
46
+ /* ------------------------------------------------------------------ a preview's shape */
47
+
48
+ /**
49
+ * The shape of a part's preview frame: `preview.aspect`, and `preview.phoneAspect` for a frame narrower than
50
+ * 600 CSS px. Each is "W:H" in whole numbers 1 to 32, no flatter than 3:1 and no taller than 1:2. The default
51
+ * is 16:9, which on a 390 px phone is a frame about 197 px tall: too short for a preview somebody plays with.
52
+ */
53
+ const PREVIEW_SHAPE = Object.freeze({ max: 32, flattest: 3, tallest: 2 });
54
+ /** { w, h } for a shape inside those bounds; null for anything else (the page then uses the default). */
55
+ export function previewAspect(value) {
56
+ const m = typeof value === 'string' ? /^([1-9]\d?):([1-9]\d?)$/.exec(value) : null;
57
+ if (!m) return null;
58
+ const w = Number(m[1]); const h = Number(m[2]);
59
+ if (w > PREVIEW_SHAPE.max || h > PREVIEW_SHAPE.max || w > PREVIEW_SHAPE.flattest * h || h > PREVIEW_SHAPE.tallest * w) return null;
60
+ return { w, h };
61
+ }
62
+ /** Why a declared shape is refused, in plain words; null when it is fine. */
63
+ function aspectProblem(value) {
64
+ if (previewAspect(value)) return null;
65
+ const m = typeof value === 'string' ? /^([1-9]\d?):([1-9]\d?)$/.exec(value) : null;
66
+ if (!m || Number(m[1]) > PREVIEW_SHAPE.max || Number(m[2]) > PREVIEW_SHAPE.max) return 'it is not a shape: two whole numbers from 1 to 32 with a colon between them';
67
+ return Number(m[1]) > Number(m[2]) ? 'it is flatter than 3:1, too short a frame to show a part in' : 'it is taller than 1:2, too tall a frame to fit a screen';
68
+ }
69
+
45
70
  /* ------------------------------------------------------------------ versions and ranges */
46
71
 
47
72
  const VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9A-Za-z.-]{1,40})?$/;
@@ -241,6 +266,8 @@ export function checkPart(part, { id = null } = {}) {
241
266
  else {
242
267
  if (part.preview.page != null && (!safePath(part.preview.page) || !/\.html?$/.test(part.preview.page))) bad('preview.page', 'it is not an HTML file inside the part', 'preview/index.html');
243
268
  if (part.preview.image != null && (!safePath(part.preview.image) || !/\.(png|jpe?g|webp|gif|svg)$/i.test(part.preview.image))) bad('preview.image', 'it is not a picture inside the part', 'preview/cover.jpg');
269
+ // The frame's shape. Refused, never repaired: the studio's page builds its CSS from these two numbers and nothing else.
270
+ for (const k of ['aspect', 'phoneAspect']) if (part.preview[k] !== undefined && aspectProblem(part.preview[k])) bad(`preview.${k}`, aspectProblem(part.preview[k]), k === 'aspect' ? '"4:3", "1:1", "3:4"; leave it out for 16:9' : '"3:4" or "1:1": the shape on a phone; leave it out for the usual one');
244
271
  const listed = new Set((Array.isArray(part.files) ? part.files : []).map((f) => f?.path));
245
272
  for (const k of ['page', 'image']) if (part.preview[k] && Array.isArray(part.files) && safePath(part.preview[k]) && !listed.has(part.preview[k])) bad(`preview.${k}`, `${part.preview[k]} is not one of the part's files`, 'check the path; checking the part rewrites its file list');
246
273
  }
package/lib/routes.mjs CHANGED
@@ -16,8 +16,17 @@
16
16
  * So: the routes the owner wrote for the studio's OWN hostnames (a custom domain, an exact host) are kept across
17
17
  * every rewrite; a wildcard or catch-all route is never deployed by the studio unless studio.json says the zone is
18
18
  * the studio's (`cloudflare.allowWildcardRoutes: true`); and a deploy whose domain answers as something else says
19
- * which route is needed. Nothing here calls Cloudflare: a zone's other routes belong to other Workers, and the
20
- * studio never lists, changes or removes one.
19
+ * which route is needed.
20
+ *
21
+ * 4. The zone ALREADY has another site's catch-all or wildcard route, and the studio's custom domain sits beside
22
+ * it. The studio's own config is clean, so nothing above speaks; the route answers the studio's hostname first,
23
+ * and the tempting repair (edit or remove that route) takes the other site down. So a deploy and its plan READ
24
+ * the zone's Worker routes when the studio has a custom domain (readZoneRoutes: two GETs with the sign-in
25
+ * Wrangler already has, never a write) and say, before anything is deployed, that the route is another site's,
26
+ * must not be edited or removed, and which one line makes the studio's hostname its own (zoneFinding). A list
27
+ * that cannot be read is said as unmeasured, never as fine.
28
+ *
29
+ * The studio never changes or removes a route it did not make: a zone's other routes belong to other Workers.
21
30
  */
22
31
  import { readFileSync } from 'node:fs';
23
32
  import { configPath } from './studio.mjs';
@@ -130,3 +139,102 @@ export function shadowedDomain(config, hostname, answer = {}) {
130
139
  why: `https://${host} ${said}, not as this studio: something else on the domain answers that hostname first. The usual reason is another Worker's route on the same zone that covers it (a wildcard like "*${route.zone_name}/*", or a catch-all): Cloudflare matches a route before a custom domain. The studio does not touch that route (it is not the studio's). Add the studio's own exact-host route to "routes" in wrangler.jsonc, which wins over a wildcard for this one hostname, and deploy again: ${JSON.stringify(route)} (zone_name is the domain as it is named in Cloudflare). Every deploy keeps it.`,
131
140
  };
132
141
  }
142
+
143
+ /* ------------------------------------------------------------------ the zone's routes, beside the studio's domain */
144
+
145
+ const CF_API = 'https://api.cloudflare.com/client/v4';
146
+
147
+ /** The zones a hostname may be in, likeliest first: the ones the config's routes name, then each parent of the hostname. */
148
+ export function zoneCandidates(hostname, config = null) {
149
+ const parts = String(hostname ?? '').toLowerCase().split('.').filter(Boolean);
150
+ const named = routesOf(config).map((r) => String(r.zone_name ?? '').toLowerCase()).filter(Boolean);
151
+ const parents = [];
152
+ for (let i = 0; i <= parts.length - 2; i += 1) parents.push(parts.slice(i).join('.'));
153
+ return [...new Set([...named, ...parents])].slice(0, 6);
154
+ }
155
+
156
+ /**
157
+ * The Worker routes of the zone a hostname is in, READ from Cloudflare (GET /zones?name=…, then GET
158
+ * /zones/<id>/workers/routes) with headers the toolkit already has (lib/brain-probe.mjs cloudflareAuth: Wrangler's
159
+ * own sign-in). { read: true, zone: { id, name }, routes: [{ pattern, script }] }, or { read: false, why } when it
160
+ * could not be read (no sign-in, a sign-in that may not read the zone, no such zone on it, no network). It never
161
+ * writes. `fetchFn` stands in for the network in tests.
162
+ */
163
+ export async function readZoneRoutes({ hostname, config = null, headers = null, fetchFn = globalThis.fetch, base = CF_API, ms = 10_000 } = {}) {
164
+ if (!headers) return { read: false, why: 'this computer has no Cloudflare sign-in to read them with' };
165
+ const root = String(base).replace(/\/+$/, '');
166
+ const get = async (path) => {
167
+ const res = await fetchFn(`${root}${path}`, { headers: { ...headers, accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(ms) });
168
+ let body = null;
169
+ try { body = await res.json(); } catch { body = null; }
170
+ return { status: res.status, ok: res.ok && body?.success !== false, result: Array.isArray(body?.result) ? body.result : null };
171
+ };
172
+ const refused = (status) => (status === 401 || status === 403 ? 'this Cloudflare sign-in may not read the domain\'s routes' : `Cloudflare answered ${status}`);
173
+ try {
174
+ let zone = null;
175
+ for (const name of zoneCandidates(hostname, config)) {
176
+ const r = await get(`/zones?name=${encodeURIComponent(name)}`);
177
+ if (!r.ok || !r.result) return { read: false, why: refused(r.status) };
178
+ const hit = r.result.find((z) => String(z?.name ?? '').toLowerCase() === name && z?.id);
179
+ if (hit) { zone = { id: String(hit.id), name }; break; }
180
+ }
181
+ if (!zone) return { read: false, why: `no domain that ${String(hostname).toLowerCase()} belongs to is on the Cloudflare account this computer is signed in to` };
182
+ const r = await get(`/zones/${encodeURIComponent(zone.id)}/workers/routes`);
183
+ if (!r.ok || !r.result) return { read: false, zone, why: refused(r.status) };
184
+ return { read: true, zone, routes: r.result.filter((x) => typeof x?.pattern === 'string' && x.pattern.trim()).map((x) => ({ pattern: x.pattern.trim(), script: x.script ? String(x.script) : null })) };
185
+ } catch (error) {
186
+ return { read: false, why: error?.name === 'TimeoutError' ? `Cloudflare did not answer in ${Math.round(ms / 1000)} s` : `Cloudflare could not be reached (${String(error?.cause?.code ?? error?.code ?? error?.message ?? error).slice(0, 80)})` };
187
+ }
188
+ }
189
+
190
+ const NOT_OURS = 'Never edit or remove a route this studio did not make';
191
+
192
+ /**
193
+ * What the zone's routes mean for a studio on a custom domain, in words for the plan, the terminal and the tool
194
+ * result. `zone` is readZoneRoutes' answer; `worker` the studio's own Worker; `config` its wrangler.jsonc.
195
+ * state 'clear' no other route covers the studio's hostname: nothing to say (`why` is null)
196
+ * state 'foreign' another site's wildcard or catch-all covers it and the studio has no exact-host route of its
197
+ * own: `warning`, with the one line to add (`route`, `line`)
198
+ * state 'own-route' another site's route covers it, and the studio's own exact-host route already wins for its
199
+ * hostname: `why` says to leave the other route alone
200
+ * state 'taken' the studio's own hostname is routed to another Worker: only the person can decide that
201
+ * state 'unmeasured' the list could not be read: said as that, with the reason
202
+ */
203
+ export function zoneFinding({ hostname, worker = null, config = null, zone = null } = {}) {
204
+ const host = String(hostname ?? '').toLowerCase();
205
+ const guess = zone?.zone?.name ?? likelyZone(host);
206
+ const route = exactRouteFor(host, guess);
207
+ const line = JSON.stringify(route);
208
+ const ownInConfig = sortRoutes(config).own.find((r) => routeKind(r) === 'exact-host' && routeHost(r) === host) ?? null;
209
+ if (!zone?.read) {
210
+ return {
211
+ state: 'unmeasured', host, zone: zone?.zone?.name ?? null, foreign: [], route, line,
212
+ why: `Not checked: whether another site's route on the domain covers ${host} (${zone?.why ?? 'not read'}). That is unmeasured, not a pass. If ${host} answers as something else after a deploy, the fix is the studio's own exact-host route in "routes" in wrangler.jsonc, ${line} (zone_name is the domain as Cloudflare names it), which every deploy keeps. ${NOT_OURS}.`,
213
+ };
214
+ }
215
+ const others = zone.routes.filter((r) => r.script !== worker && routeCovers(r, host)).map((r) => ({ pattern: r.pattern, script: r.script, kind: routeKind(r) }));
216
+ const taken = others.filter((r) => r.kind === 'exact-host');
217
+ const wide = others.filter((r) => r.kind !== 'exact-host');
218
+ const said = (list) => list.map((r) => `"${r.pattern}" (${r.script ? `Worker ${r.script}` : 'no Worker: it turns Workers off for what it covers'})`).join(', ');
219
+ const base = { host, zone: zone.zone.name, route, line };
220
+ if (taken.length) {
221
+ return {
222
+ ...base, state: 'taken', foreign: others,
223
+ warning: `${host} itself is routed to another Worker on ${zone.zone.name}: ${said(taken)}. That route is not this studio's, so the studio does not take, edit or remove it, and neither should you: whatever it serves would stop answering. Only the person can decide to hand the hostname over, in the domain's Workers Routes in the Cloudflare dashboard; or give the studio a hostname nothing else uses (studio.json cloudflare.domain).`,
224
+ };
225
+ }
226
+ if (!wide.length) return { ...base, state: 'clear', foreign: [], why: null };
227
+ const ownLive = zone.routes.find((r) => r.script === worker && routeKind(r) === 'exact-host' && routeHost(r) === host) ?? null;
228
+ const all = wide.some((r) => r.kind === 'catch-all');
229
+ const them = `${wide.length === 1 ? 'a route' : 'routes'} that cover${wide.length === 1 ? 's' : ''} ${host} and ${wide.length === 1 ? 'is' : 'are'} not this studio's: ${said(wide)}`;
230
+ if (ownInConfig || ownLive) {
231
+ return {
232
+ ...base, state: 'own-route', foreign: wide,
233
+ why: `${zone.zone.name} has ${them}. ${wide.length === 1 ? 'It belongs' : 'They belong'} to another site on this domain and must not be edited or removed. The studio's own exact-host route ("${(ownInConfig ?? ownLive).pattern}") wins over ${wide.length === 1 ? 'it' : 'them'} for this one hostname, and every deploy keeps it: nothing to change.`,
234
+ };
235
+ }
236
+ return {
237
+ ...base, state: 'foreign', foreign: wide,
238
+ warning: `${zone.zone.name} already has ${them}. ${wide.length === 1 ? 'That route belongs' : 'Those routes belong'} to another site on this domain: ${wide.length === 1 ? 'it' : 'they'} must not be edited or removed, here or in the Cloudflare dashboard, or that site stops answering. Cloudflare matches a route before a custom domain, so ${all ? 'the catch-all' : 'the wildcard'} answers ${host} first and the studio's own domain would show the other site. The one safe fix is the studio's own exact-host route, which wins over a wider route for this one hostname only and which every deploy keeps. Add this line to "routes" in wrangler.jsonc and deploy: ${line} (or \`homie-studio deploy --own-route\`, which adds exactly that line and nothing else).`,
239
+ };
240
+ }
package/lib/scaffold.mjs CHANGED
@@ -281,7 +281,7 @@ studio's pinned copy, never a registry lookup of the bare name.
281
281
  does not raise the callout.
282
282
  - **The frame has no storage of its own.** The game runs in a sandboxed frame without \`allow-same-origin\` (a
283
283
  stranger's game must never read this site's storage or the owner's session), so \`localStorage\` throws inside it.
284
- A setting or a personal best goes in \`net.prefs\` (\`await net.prefs.get('quality', 'high')\`,
284
+ A setting or a personal best goes in \`net.prefs\` (\`net.prefs.string('quality', 'high')\`, \`net.prefs.number('volume', 0.8, { min: 0, max: 1 })\` after \`await net.prefs.ready\`: the typed readers give your fallback for a missing key, never a null that turns into 0,
285
285
  \`net.prefs.set('quality', 'low')\`: the play page keeps 16 KB a game); progress that must last goes in saves.
286
286
  The play page's \`?debug\` and \`?q=…\` reach the game as \`net.params\`, with any names game.json
287
287
  \`"netplay": { "params": ["seed"] }\` declares (\`NETPLAY.md\` section 24).
@@ -489,8 +489,8 @@ chat's card follows the work from then on) and, for a studio still being set up,
489
489
  \`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
490
490
  in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
491
491
  the budget until the person says Proceed, and takes keys out of command output. In Codex the plugin's hooks hold
492
- the same calls until the person's own \`proceed <code>\`, once the person has trusted them; in Grok Build nothing
493
- holds them yet. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
492
+ the same calls until the person's own \`proceed <code>\`, once the person has trusted them, and in Grok Build once
493
+ the plugin is trusted. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
494
494
  holds are on, and while they are off, ask before each of those yourself. When a call is refused, say what you
495
495
  meant to do and why; do not retry it unless the person asks.
496
496
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `homie-studio standalone …`: a game as a desktop app (macOS, Windows, Linux) and a phone app (iOS, Android): the
3
+ * files Steam and the stores accept for upload. lib/standalone.mjs does the work; standalone/STANDALONE.md is the
4
+ * guide. In chat it is the game_standalone tool (lib/standalone-tools.mjs).
5
+ *
6
+ * standalone plan <game> [--for mac,windows,linux,ios,android] [--release] [--site <address>]
7
+ * what would be built, with what, what each target still needs on this computer, and what a standalone copy
8
+ * does not have. Changes nothing, installs nothing.
9
+ * standalone build <game> [--for …] [--release] [--site <address>] [--build <n>]
10
+ * builds the game for the web, then every target this computer can make; one it cannot is skipped with why.
11
+ * standalone run <game> [--for ios|android] the built copy, started here (none: this computer's desktop app)
12
+ * standalone steam <game> Steam's build files from game.json's numbers; uploads nothing
13
+ * standalone ci <game> .github/workflows/standalone.yml: every target on GitHub's machines
14
+ *
15
+ * `on` (tests): the computer the command is run "on" (its platform, environment and tools), handed to the library.
16
+ */
17
+ import { STANDALONE_VERBS, standaloneBuild, standaloneCi, standalonePlan, standaloneRun, standaloneSteam } from './standalone.mjs';
18
+
19
+ export { STANDALONE_VERBS };
20
+
21
+ /** What follows the list of what a standalone copy lacks, wherever an AI reads it: never softened, never left out. */
22
+ export const SAY_MISSING = 'Say this to the person as it is, before they ship anything: it is what a player of the standalone game does not get.';
23
+
24
+ export async function standaloneCommand(root, sub, positional, flags, { log = () => {}, on = {} } = {}) {
25
+ const id = positional[2] ?? null;
26
+ const str = (k) => (typeof flags.get(k) === 'string' ? flags.get(k) : null);
27
+ // `--for` with nothing after it is passed on as it is (true), so the command can refuse it: never "all five".
28
+ const opts = { for: flags.has('for') ? flags.get('for') : null, release: flags.has('release'), site: str('site'), build: flags.has('build') ? flags.get('build') : null, log, ...on };
29
+ /*
30
+ * WHAT A PERSON IS TOLD rides in the result itself (`say`): the same lines the command prints, then the instruction
31
+ * about the missing list. A chat tool that started this as a job and reads the result later (studio_job) says
32
+ * these lines, not a page of JSON with the list buried in it.
33
+ */
34
+ const told = (r) => (r && (r.targets || r.rows) ? { ...r, say: [...standaloneLines(r), '', SAY_MISSING] } : r);
35
+ switch (sub ?? 'plan') {
36
+ case 'plan': return told(await standalonePlan(root, id, opts));
37
+ case 'build': return told(await standaloneBuild(root, id, opts));
38
+ case 'run': return standaloneRun(root, id, { for: flags.has('for') ? flags.get('for') : null });
39
+ case 'steam': return standaloneSteam(root, id);
40
+ case 'ci': return standaloneCi(root, id);
41
+ default: return { ok: false, command: 'standalone', why: `"${sub}" is not a standalone command: ${STANDALONE_VERBS.join(', ')}` };
42
+ }
43
+ }
44
+
45
+ /* ------------------------------------------------------------------ plain lines */
46
+
47
+ const MARK = { ready: '→', built: '✓', skipped: '○', failed: '✗' };
48
+ const fixLine = (t) => (t.fix ? ` ${t.fix.run ? `${t.fix.run} ` : ''}${t.fix.open ? `${t.fix.open} ` : ''}${t.fix.say ?? ''}` : null);
49
+
50
+ /** What a standalone copy does not have, under the heading every surface uses. */
51
+ export function missingLines(missing) {
52
+ return ['What the standalone game does not have (v1):', ...missing.map((m) => ` - ${m.what}: ${m.why}.`)];
53
+ }
54
+
55
+ export function standaloneLines(r) {
56
+ const L = [];
57
+ // A refusal with something to do about it (a release with no id yet, Steam with no numbers) still says it all.
58
+ if (r.ok === false && !r.targets && !r.rows) { L.push(r.why ?? 'failed'); if (r.instead) L.push('', r.instead); return L; }
59
+ switch (r.command) {
60
+ case 'standalone plan': {
61
+ L.push(`${r.name} as a standalone game${r.release ? ' (a release)' : ' (a build to try)'}: ${r.appId}${r.appIdFrom === 'derived' ? ' (made up for now)' : ''}, version ${r.version} build ${r.build}, held ${r.orientation === 'any' ? 'any way' : r.orientation}.`,
62
+ ` The same web build of the game, in Electron on computers and Capacitor on phones. Everything is made in ${r.dir}/ (never committed).`,
63
+ ` Rooms: ${r.site ? `${r.site} (${r.siteFrom})` : 'none: this copy would play offline only'}${r.netplayVersion ? `, netplay version "${r.netplayVersion}"` : ''}.`,
64
+ ` Icon: ${r.icon.note}.`, '');
65
+ for (const t of r.targets) {
66
+ L.push(` ${MARK[t.state]} ${t.target.padEnd(8)} ${t.state === 'ready' ? `${t.with}: ${t.output}` : `SKIPPED here: ${t.why}`}`);
67
+ if (t.state === 'skipped') { const f = fixLine(t); if (f) L.push(f); if (t.instead) L.push(` ${t.instead}`); }
68
+ }
69
+ L.push('', 'On this computer:');
70
+ for (const row of r.rows) L.push(` ${row.state === 'ok' ? '✓' : '○'} ${row.label} (${row.need}): ${row.detail}`);
71
+ if (r.warnings.length) L.push('', ...r.warnings.map((w) => `Warning: ${w}`));
72
+ L.push('', r.toolkitNote ?? r.needs, '', ...missingLines(r.missing));
73
+ if (r.ok === false) L.push('', r.why, '', r.instead);
74
+ break;
75
+ }
76
+ case 'standalone build': {
77
+ L.push(`${r.name} ${r.version} build ${r.build}${r.release ? ' (release)' : ''}, ${r.appId}${r.appIdFrom === 'derived' ? ' (made up for now)' : ''}: in ${r.dir}/`);
78
+ for (const t of r.targets) {
79
+ L.push(` ${t.unsigned ? '!' : MARK[t.state]} ${t.target.padEnd(8)} ${t.state === 'built' ? `${t.unsigned ? 'BUILT, UNSIGNED (no store takes it): ' : ''}${t.path}` : t.state === 'skipped' ? `SKIPPED: ${t.why}` : `FAILED: ${t.why}`}`);
80
+ for (const n of t.notes ?? []) L.push(` ${n}`);
81
+ if (t.state === 'skipped') { const f = fixLine(t); if (f) L.push(f); if (t.instead) L.push(` ${t.instead}`); }
82
+ }
83
+ L.push(` Rooms: ${r.site ?? 'none: these copies play offline only'}. Icon: ${r.icon.note}.`);
84
+ if (r.warnings.length) L.push('', ...r.warnings.map((w) => `Warning: ${w}`));
85
+ if (r.next.length) L.push('', 'Next, by you (nothing was uploaded):', ...r.next.map((n) => ` ${n}`));
86
+ if (r.toolkitNote) L.push('', r.toolkitNote);
87
+ L.push('', ...missingLines(r.missing));
88
+ if (r.ok === false) L.push('', `NOT DONE: ${r.why}`);
89
+ break;
90
+ }
91
+ case 'standalone run':
92
+ L.push(`${r.game} (${r.target}) ended.`);
93
+ break;
94
+ case 'standalone steam':
95
+ L.push(`Steam's build file for app ${r.app}: ${r.dir}/${r.wrote.join(', ')} (each depot is that system's release build, out/<system>/release/).`,
96
+ ...(r.unbuilt.length ? [` NO RELEASE BUILD YET for ${r.unbuilt.join(', ')} (its depot would upload nothing): homie-studio standalone build ${r.game} --for ${r.unbuilt.join(',')} --release`] : []),
97
+ 'In Steamworks:', ...r.setup, ` ${r.upload}`, r.note);
98
+ break;
99
+ case 'standalone ci':
100
+ L.push(`${r.changed ? 'Wrote' : 'Unchanged:'} ${r.file}.`, ...r.next.map((n) => ` ${n}`));
101
+ break;
102
+ default:
103
+ L.push(JSON.stringify(r));
104
+ }
105
+ return L;
106
+ }