@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.
- package/CHANGELOG.md +68 -1
- package/README.md +44 -6
- package/bin/homie-studio.mjs +101 -8
- package/lib/build.mjs +1 -1
- package/lib/cloudflare.mjs +80 -6
- package/lib/directory.mjs +61 -9
- package/lib/doctor.mjs +23 -10
- package/lib/mcp-tools.mjs +18 -11
- package/lib/mcp.mjs +2 -0
- package/lib/parts.mjs +27 -0
- package/lib/routes.mjs +110 -2
- package/lib/scaffold.mjs +3 -3
- package/lib/standalone-cli.mjs +106 -0
- package/lib/standalone-files.mjs +360 -0
- package/lib/standalone-icons.mjs +106 -0
- package/lib/standalone-lock.json +3404 -0
- package/lib/standalone-pins.json +18 -0
- package/lib/standalone-tools.mjs +63 -0
- package/lib/standalone.mjs +868 -0
- package/lib/template-history.json +80 -0
- package/netplay/NETPLAY.md +148 -10
- package/netplay/netplay.ts +234 -33
- package/package.json +2 -1
- package/parts/PARTS.md +8 -2
- package/port/index.ts +1 -1
- package/port/room.ts +228 -23
- package/standalone/STANDALONE.md +313 -0
- package/standalone/electron/files.cjs +50 -0
- package/standalone/electron/main.cjs +139 -0
- package/standalone/electron/pack.mjs +82 -0
- package/standalone/shell/index.html +36 -0
- package/standalone/shell/shell.css +46 -0
- package/standalone/shell/shell.js +346 -0
- package/starters/ember-vale/src/main.ts +4 -0
- package/starters/gem-rush/src/main.ts +29 -7
- package/starters/gem-rush-3d/src/main.ts +36 -7
- package/starters/hero-rush-3d/src/main.ts +36 -7
- package/worker/index.mjs +55 -8
- package/worker/pages.mjs +19 -10
- package/worker/parts.mjs +43 -2
- package/worker/standalone.mjs +34 -0
- 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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
15
|
-
*
|
|
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:
|
|
275
|
-
//
|
|
276
|
-
// every message and tool call; a fresh one means they are on.
|
|
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
|
|
283
|
-
id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' :
|
|
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
|
-
:
|
|
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
|
-
: '
|
|
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 {
|
|
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
|
|
1123
|
-
inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: '
|
|
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
|
-
|
|
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.
|
|
20
|
-
*
|
|
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\` (\`
|
|
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
|
|
493
|
-
|
|
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
|
+
}
|