@homie-rocks/studio 0.30.1 → 0.31.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 +111 -1
- package/README.md +361 -22
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +142 -123
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +15 -5
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +15 -5
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +141 -23
- package/worker/parts.mjs +234 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/assets/assets.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* runtime (homie.rocks's starter library is copied into the game, never hot-linked). `allowOrigins` opens one on
|
|
19
19
|
* purpose.
|
|
20
20
|
*
|
|
21
|
-
* In development (localhost, or ?debug=1) a model over its budget says so in the console
|
|
21
|
+
* In development (localhost, or ?debug=1) a model over its budget says so in the console (the budget is a small
|
|
22
|
+
* prop's unless the game sets its own: game.json `"assets": { "budgets": { "triangles", "bytes" } }`), and `stats()` (also
|
|
22
23
|
* `window.__homieModels`) has the totals the perf skill reads: models, triangles, texture memory and refusals.
|
|
23
24
|
*
|
|
24
25
|
* KTX2 pictures (KHR_texture_basisu) need a transcoder (about 580 KB): pass your own KTX2Loader as `ktx2` when a
|
|
@@ -70,7 +71,7 @@ export interface LoadedModel {
|
|
|
70
71
|
export interface ModelsOptions {
|
|
71
72
|
/** Narrow or widen the file checks (LIMITS.game by default). */
|
|
72
73
|
limits?: Partial<ModelLimits>;
|
|
73
|
-
/** A per-model budget for the development warnings (the prop tier
|
|
74
|
+
/** A per-model budget for the development warnings (over game.json's `assets.budgets`, over the prop tier). */
|
|
74
75
|
budget?: ModelBudget;
|
|
75
76
|
/** A KTX2Loader the game set up (transcoder path, renderer support): only for models with KTX2 pictures. */
|
|
76
77
|
ktx2?: { load?: unknown } | null;
|
|
@@ -98,6 +99,16 @@ export interface Models {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
const PROP_BUDGET: Required<ModelBudget> = { triangles: 1500, texturePx: 512, materials: 1, bytes: 300 * 1024 };
|
|
102
|
+
/**
|
|
103
|
+
* The game's own budgets: game.json `"assets": { "budgets": { "triangles": 8000, "bytes": 1500000 } }`, which
|
|
104
|
+
* `homie-studio build` writes into the bundle as this constant (lib/build.mjs modelBudgetsOf). The defaults above are
|
|
105
|
+
* a small prop's; a 3D game whose every model is a character or a piece of level sets its own once, there, instead
|
|
106
|
+
* of reading a warning per model. Any other bundler leaves the name undefined, and the defaults stand.
|
|
107
|
+
*/
|
|
108
|
+
declare const __HOMIE_MODEL_BUDGETS__: ModelBudget | undefined;
|
|
109
|
+
const gameBudget = (): ModelBudget => {
|
|
110
|
+
try { return typeof __HOMIE_MODEL_BUDGETS__ === 'object' && __HOMIE_MODEL_BUDGETS__ ? __HOMIE_MODEL_BUDGETS__ : {}; } catch { return {}; }
|
|
111
|
+
};
|
|
101
112
|
const devHost = (): boolean => {
|
|
102
113
|
try { return /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname) || new URLSearchParams(location.search).get('debug') === '1'; } catch { return false; }
|
|
103
114
|
};
|
|
@@ -461,7 +472,7 @@ export function createModels(opts: ModelsOptions = {}): Models {
|
|
|
461
472
|
const key = url;
|
|
462
473
|
let p = cache.get(key);
|
|
463
474
|
if (!p) {
|
|
464
|
-
p = loadOnce(url, { ...limits, ...(o.limits ?? {}) } as ModelLimits, { ...PROP_BUDGET, ...(opts.budget ?? {}), ...(o.budget ?? {}) });
|
|
475
|
+
p = loadOnce(url, { ...limits, ...(o.limits ?? {}) } as ModelLimits, { ...PROP_BUDGET, ...gameBudget(), ...(opts.budget ?? {}), ...(o.budget ?? {}) });
|
|
465
476
|
cache.set(key, p);
|
|
466
477
|
p.catch(() => cache.delete(key));
|
|
467
478
|
}
|
package/assets/render-page.ts
CHANGED
|
@@ -35,7 +35,7 @@ interface Tokens {
|
|
|
35
35
|
shape?: { language?: string; bevel?: number };
|
|
36
36
|
proportions?: { heads?: number; heightM?: number };
|
|
37
37
|
}
|
|
38
|
-
interface ModelIn { id: string; glb: string; label?: string; place?: 'hero' | 'prop' | 'dressing'; scale?: number; retint?: boolean; tint?: string; pull?: number; anims?: string; pose?: string; poseAt?: number }
|
|
38
|
+
interface ModelIn { id: string; glb: string; label?: string; place?: 'hero' | 'prop' | 'dressing'; scale?: number; retint?: boolean; tint?: string; pull?: number; anims?: string | string[]; pose?: string; poseAt?: number }
|
|
39
39
|
|
|
40
40
|
const canvas = document.createElement('canvas');
|
|
41
41
|
document.body.appendChild(canvas);
|
|
@@ -51,7 +51,15 @@ async function parse(m: ModelIn): Promise<Group> {
|
|
|
51
51
|
const gltf = await new Promise<{ scene: Group; animations: AnimationClip[] }>((res, rej) => loader.parse(b64(m.glb), '', res as never, rej));
|
|
52
52
|
gltf.scene.name = m.id;
|
|
53
53
|
// A character with a clip library stands in a pose (idle, a little way in), not in its bind pose.
|
|
54
|
-
|
|
54
|
+
// One clip library, or several (its skeleton's own, then any declared supplemental): a verb's first clip is kept.
|
|
55
|
+
let clips = gltf.animations;
|
|
56
|
+
if (m.anims) {
|
|
57
|
+
clips = [];
|
|
58
|
+
for (const lib of Array.isArray(m.anims) ? m.anims : [m.anims]) {
|
|
59
|
+
const more = (await new Promise<{ animations: AnimationClip[] }>((res, rej) => loader.parse(b64(lib), '', res as never, rej))).animations;
|
|
60
|
+
for (const c of more) if (!clips.some((x) => x.name === c.name)) clips.push(c);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
55
63
|
gltf.scene.userData.clips = clips;
|
|
56
64
|
if (clips?.length && (m.anims || m.pose)) {
|
|
57
65
|
const clip = clips.find((c) => c.name === (m.pose ?? 'idle')) ?? clips.find((c) => c.name === 'idle') ?? clips[0];
|
package/assets/safety.mjs
CHANGED
|
@@ -87,12 +87,18 @@ export function readGlb(bytes, { maxJson = LIMITS.import.json } = {}) {
|
|
|
87
87
|
* { ok, problems: [plain words], warnings: [plain words], info: { bytes, triangles, vertices, meshes, nodes,
|
|
88
88
|
* materials, images, textures, animations, skins, joints, extensions: { used, required } } }
|
|
89
89
|
* Never throws: a file it cannot read is `ok: false` with the reason.
|
|
90
|
+
*
|
|
91
|
+
* The JSDoc types say what the code always took: any limits (numbers, a part of them is enough), and extension names
|
|
92
|
+
* as strings. Without them a strict TypeScript caller read the frozen defaults as literal types and the empty lists
|
|
93
|
+
* as never[].
|
|
94
|
+
* @param {Uint8Array} bytes
|
|
95
|
+
* @param {Partial<Record<keyof typeof LIMITS.game, number>>} [limits]
|
|
90
96
|
*/
|
|
91
97
|
export function checkGlb(bytes, limits = LIMITS.game) {
|
|
92
98
|
const L = { ...LIMITS.game, ...limits };
|
|
93
99
|
const problems = [];
|
|
94
100
|
const warnings = [];
|
|
95
|
-
const info = { bytes: bytes?.byteLength ?? 0, triangles: 0, vertices: 0, meshes: 0, nodes: 0, materials: 0, images: 0, textures: 0, animations: 0, skins: 0, joints: 0, extensions: { used: [], required: [] } };
|
|
101
|
+
const info = { bytes: bytes?.byteLength ?? 0, triangles: 0, vertices: 0, meshes: 0, nodes: 0, materials: 0, images: 0, textures: 0, animations: 0, skins: 0, joints: 0, extensions: { used: /** @type {string[]} */ ([]), required: /** @type {string[]} */ ([]) } };
|
|
96
102
|
if (info.bytes > L.bytes) problems.push(`the file is ${size(info.bytes)}, over the ${size(L.bytes)} a model may be here`);
|
|
97
103
|
let glb;
|
|
98
104
|
try { glb = readGlb(bytes, { maxJson: L.json }); } catch (error) { problems.push(error.message); return { ok: false, problems, warnings, info }; }
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -14,15 +14,34 @@
|
|
|
14
14
|
* extension, Claude Code or any MCP client builds in the same chat that shows
|
|
15
15
|
* the cards (lib/mcp.mjs); --studios: the folder the studios live in
|
|
16
16
|
* homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
|
|
17
|
-
* homie-studio game remix <source.json url> --id <new id>
|
|
18
17
|
* homie-studio games
|
|
19
|
-
* homie-studio build [<id>] [--maps] (--maps: also keep each bundle's source map and module sizes in
|
|
20
|
-
* never in site/dist: what `perf` reads a CPU profile through
|
|
21
|
-
*
|
|
18
|
+
* homie-studio build [<id>] [--maps] [--types] (--maps: also keep each bundle's source map and module sizes in
|
|
19
|
+
* .studio/maps/<id>/, never in site/dist: what `perf` reads a CPU profile through;
|
|
20
|
+
* --types: check the games' TypeScript first, with the studio's own
|
|
21
|
+
* `typescript`; a type error stops the build. The site is built in a folder of
|
|
22
|
+
* its own and put in place only when all of it is there: a game that does not
|
|
23
|
+
* build fails the command and leaves site/dist as it was. Each game's line says
|
|
24
|
+
* changed or unchanged, its build hash, and whether its source is shared)
|
|
25
|
+
* homie-studio preview <id> [--port 8788] (one built game's files at an address on this computer, nothing else: no
|
|
26
|
+
* Wrangler, no rooms, no database; for a capture, a screenshot or a perf script.
|
|
27
|
+
* The game plays offline with its bots. Stop it with Ctrl-C)
|
|
28
|
+
* homie-studio dev [--port 8787] [--remote-ai] [--no-local-ai] [--timestamps] (--stop: stop exactly this studio's dev server,
|
|
29
|
+
* nothing else, and clear a stale record; rooms here are always local: production
|
|
30
|
+
* routes in wrangler.jsonc are left out; a game built while it runs is picked up;
|
|
31
|
+
* --timestamps: a time on every line of Wrangler's, not only the room and error ones;
|
|
22
32
|
* AI guides, chat review and game decisions think with Clef on this computer
|
|
23
33
|
* when Ollama has clef-flash (free; dev never downloads it), else scripted;
|
|
24
34
|
* --remote-ai: the real Workers AI, billed)
|
|
25
35
|
* homie-studio check <id> [--url <site>] [--shots <dir>]
|
|
36
|
+
* homie-studio shoot <id> [--url <site> | --preview] [--frames 60] [--fps 30] [--device computer|phone]
|
|
37
|
+
* [--out <dir>] [--hold <key code>] [--no-smoke] [--timeout 180]
|
|
38
|
+
* pictures of the game on a clock this command steps (one frame = 1/fps s
|
|
39
|
+
* of the game's own time, however slow the renderer: for a 3D game on a
|
|
40
|
+
* machine with no GPU), after a two-client seat smoke check in a private
|
|
41
|
+
* room. Frames and shoot.json go to --out (default .studio/shoot/<id>/).
|
|
42
|
+
* --preview: no site needed; it serves the built game itself (as
|
|
43
|
+
* `preview` does: alone, offline) and shoots that, with no smoke check,
|
|
44
|
+
* and says which build (its hash and bundle) the frames are of.
|
|
26
45
|
* homie-studio perf <id> [--url <site>] [--device computer,phone] [--runs 1] [--seconds 15] [--warm 3] [--profile]
|
|
27
46
|
* [--cpu 4] [--out <dir>] [--max-load 0.8] [--pair <label>]
|
|
28
47
|
* (how fast it runs: per device, two browsers in a fresh room, the host and a replica,
|
|
@@ -32,6 +51,10 @@
|
|
|
32
51
|
* each browser and its hottest functions. Files under .perf/<id>/<time>/; the phone is
|
|
33
52
|
* emulated (a --cpu times slower CPU, 4G) on this computer's GPU. The plugin's perf
|
|
34
53
|
* skill runs the whole measure, change, compare, keep-or-revert loop)
|
|
54
|
+
* homie-studio trailer <id> [--url <site>] [--seconds 40] [--length 20] [--title "…"] [--end "…"] [--skills <folder>]
|
|
55
|
+
* (a trailer in one command, by the plugin's video skill: the game rendered frame by
|
|
56
|
+
* frame on a virtual clock, its sound rebuilt from its own files and what it played,
|
|
57
|
+
* the highlights picked, an end card, 16:9, 1:1 and 9:16 in videos/<id>-trailer/)
|
|
35
58
|
* homie-studio perf sizes <id> (what a player downloads: every built file, raw and gzipped, the biggest first; with
|
|
36
59
|
* build --maps, which modules make up the bundle; each big script read for whether it
|
|
37
60
|
* is minified, from its code, and how much of it is GLSL shader source in strings)
|
|
@@ -61,9 +84,12 @@
|
|
|
61
84
|
* homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
|
|
62
85
|
* homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
|
|
63
86
|
* homie-studio deploy [--plan] (--plan: what it will create on Cloudflare and what it costs; changes nothing)
|
|
87
|
+
* One deploy of a studio at a time (a lock in .studio/); it says which games changed
|
|
88
|
+
* with each one's content hash, keeps the studio's own custom-domain and exact-host
|
|
89
|
+
* routes in wrangler.jsonc, and never deploys a wildcard or catch-all route.
|
|
64
90
|
* In Cloudflare's Workers Builds (WORKERS_CI=1, or --ci) it only applies the D1
|
|
65
91
|
* migrations and deploys: the Worker and database are the Deploy button's.
|
|
66
|
-
* homie-studio publish
|
|
92
|
+
* homie-studio publish (the directory's beta has a daily cap: it says how many publishes are left)
|
|
67
93
|
* homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
|
|
68
94
|
* homie-studio media list (every song and video page, where each file is served from, what moves to R2)
|
|
69
95
|
* homie-studio media move [<file>...] [--dry-run] [--verify]
|
|
@@ -94,7 +120,7 @@
|
|
|
94
120
|
* homie-studio office key [--hours 1] a key for the Homie MCP's owner tools (studio_office, room_kick, ...)
|
|
95
121
|
* homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
|
|
96
122
|
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>] [--server <id>]
|
|
97
|
-
* homie-studio office launch <game> private|invite|public [--
|
|
123
|
+
* homie-studio office launch <game> private|invite|public [--max <n>|game]
|
|
98
124
|
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
99
125
|
* homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
|
|
100
126
|
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
@@ -187,11 +213,15 @@
|
|
|
187
213
|
* pages; the account id goes into studio.json, the token stays in Projects'
|
|
188
214
|
* vault and its git-ignored .env, and Wrangler runs with it here. The usual
|
|
189
215
|
* way, npx wrangler login, stays the default. lib/projects.mjs)
|
|
190
|
-
* homie-studio setup status [--connector yes|no] (also: homie-studio doctor)
|
|
216
|
+
* homie-studio setup status [--connector yes|no] [--client claude|codex|grok] (also: homie-studio doctor)
|
|
191
217
|
* what this computer and the person's accounts have for a studio: Node, the Homie
|
|
192
218
|
* connector, Cloudflare (signed in, email verified), Chrome, ffmpeg, GitHub,
|
|
193
219
|
* ElevenLabs, fal; green, missing or "do this now", what each unlocks and its
|
|
194
220
|
* exact fix. Read-only and safe any time, inside a studio or before one exists.
|
|
221
|
+
* A missing connector never blocks: a session with a shell makes a studio with
|
|
222
|
+
* `new`. In Codex and Grok Build (--client, or the app's own environment) it
|
|
223
|
+
* also says whether Homie's holds are on (the plugin's hooks ran just now), and
|
|
224
|
+
* when another MCP server named homie has taken the connector's name there.
|
|
195
225
|
*
|
|
196
226
|
* homie-studio codex new <id> [--name "<Name>"]
|
|
197
227
|
* games/<id>/CODEX.md: the Game Codex, every section, in the game's colours (a game
|
|
@@ -232,6 +262,14 @@
|
|
|
232
262
|
* homie-studio anim add <id> <asset> --verbs jump,attack [--from <library item>] more verbs, retargeted onto its
|
|
233
263
|
* skeleton at build time into its clip library (public/anims/<skeleton>.glb)
|
|
234
264
|
* homie-studio anim preview <id> [--asset <asset>] looping previews of every clip (animated WebP) and a sheet
|
|
265
|
+
* homie-studio collision bake <id> <model.glb> [--cell 0.25] a height grid off a model's triangles, beside it, with
|
|
266
|
+
* a checksum of the mesh and a true-scale plan (lib/collision.mjs)
|
|
267
|
+
* homie-studio collision check <id> every collision file: stale against its model, its proxies, its routes walked
|
|
268
|
+
*
|
|
269
|
+
* homie-studio parts find|new|add|share|unshare|check …
|
|
270
|
+
* game parts (parts/PARTS.md): pieces of games a studio shares, and brings into
|
|
271
|
+
* its own games from other studios. The plumbing behind the chat tools
|
|
272
|
+
* parts_find, part_add, part_new and part_share; nobody is asked to type it
|
|
235
273
|
*
|
|
236
274
|
* homie-studio statusline the current build in one line (what Claude Code's status line shows)
|
|
237
275
|
* homie-studio statusline --install [--project <folder>]
|
|
@@ -241,14 +279,18 @@
|
|
|
241
279
|
*
|
|
242
280
|
* Every command prints a few lines for a person; --json prints the result.
|
|
243
281
|
*/
|
|
282
|
+
// FIRST, before any other file of the toolkit is evaluated: a Node.js that is too old ends here, with the reason.
|
|
283
|
+
import '../lib/node-version.mjs';
|
|
244
284
|
import { spawn, spawnSync } from 'node:child_process';
|
|
245
|
-
import { existsSync,
|
|
246
|
-
import { basename,
|
|
285
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
286
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
247
287
|
import { build } from '../lib/build.mjs';
|
|
288
|
+
import { PREVIEW_PORT, previewServer } from '../lib/preview.mjs';
|
|
248
289
|
import { check } from '../lib/check.mjs';
|
|
249
290
|
import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
250
291
|
import { setupAttach } from '../lib/setup.mjs';
|
|
251
292
|
import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
|
|
293
|
+
import { deployWords } from '../lib/deploy-state.mjs';
|
|
252
294
|
import { publish } from '../lib/directory.mjs';
|
|
253
295
|
import { look } from '../lib/look.mjs';
|
|
254
296
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
@@ -256,17 +298,18 @@ import { portCheck } from '../lib/port-check.mjs';
|
|
|
256
298
|
import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
|
|
257
299
|
import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
|
|
258
300
|
import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
|
|
259
|
-
import {
|
|
301
|
+
import { newStudio } from '../lib/scaffold.mjs';
|
|
260
302
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
261
303
|
import { whatsNewLines } from '../lib/changelog.mjs';
|
|
262
|
-
import { listGames, newGame, readStudio,
|
|
304
|
+
import { listGames, newGame, readStudio, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
263
305
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
264
306
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
265
307
|
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
266
308
|
import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeMute, officeRevoke, officeShow } from '../lib/office.mjs';
|
|
267
309
|
import { chatBudget, chatLines, chatRemove, chatRulesSet, chatShow, chatWords } from '../lib/chat-cli.mjs';
|
|
268
310
|
import { loungeLines, loungeMod, loungeNight, loungeRemove, loungeRulesSet, loungeShow } from '../lib/lounge-cli.mjs';
|
|
269
|
-
import { detectLocalAi
|
|
311
|
+
import { detectLocalAi } from '../lib/local-ai.mjs';
|
|
312
|
+
import { dev, stopDev } from '../lib/dev.mjs';
|
|
270
313
|
import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, agentsTry, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
|
|
271
314
|
import { AgentSeat } from '../lib/agent-seat.mjs';
|
|
272
315
|
import { shopCheck, shopConnect, shopDisconnect, shopInit, shopLines, shopOrders, shopRefund, shopStatements, shopStatus } from '../lib/shop.mjs';
|
|
@@ -281,11 +324,16 @@ import { restartWithProxy } from '../lib/net.mjs';
|
|
|
281
324
|
import { demoGames, formatDemo } from '../lib/demo.mjs';
|
|
282
325
|
import { serveMcp } from '../lib/mcp.mjs';
|
|
283
326
|
import { animCommand, artLines, assetsCommand, castView, styleCommand } from '../lib/art-cli.mjs';
|
|
327
|
+
import { collisionCommand } from '../lib/collision.mjs';
|
|
284
328
|
import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
329
|
+
// GAME PARTS (parts/PARTS.md): `parts …` is lib/parts-cli.mjs; the deploy plan shows brought-in parts' licences.
|
|
330
|
+
import { partsCommand, partsLines } from '../lib/parts-cli.mjs';
|
|
331
|
+
import { partsPlanLines, partsPublishReport } from '../lib/parts-build.mjs';
|
|
332
|
+
import { trailerCommand, trailerLines } from '../lib/trailer.mjs';
|
|
285
333
|
|
|
286
334
|
const argv = process.argv.slice(2);
|
|
287
335
|
const flags = new Map();
|
|
288
|
-
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai'];
|
|
336
|
+
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite'];
|
|
289
337
|
const positional = [];
|
|
290
338
|
for (let i = 0; i < argv.length; i++) {
|
|
291
339
|
const a = argv[i];
|
|
@@ -302,21 +350,16 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
|
|
|
302
350
|
function print(result) {
|
|
303
351
|
if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
|
|
304
352
|
// A check that found problems still prints them (its rows say what to fix), never a bare "failed".
|
|
305
|
-
if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows) && !(result.command === 'assets check' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
|
|
353
|
+
if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows) && !(result.command === 'assets check' && result.rows) && !(result.command === 'parts check' && result.rows) && !(result.command === 'shoot' && result.frames !== undefined)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
|
|
306
354
|
if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
307
355
|
const lines = [];
|
|
356
|
+
if (/^parts /.test(String(result.command ?? ''))) { process.stdout.write(`${partsLines(result).join('\n')}\n`); return; }
|
|
308
357
|
if (/^(style|assets|cast|anim)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
|
|
358
|
+
if (/^collision /.test(String(result.command ?? '')) && result.lines) { process.stdout.write(`${result.lines.join('\n')}\n`); return; }
|
|
309
359
|
switch (result.command) {
|
|
310
360
|
case 'new':
|
|
311
361
|
lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
|
|
312
362
|
break;
|
|
313
|
-
case 'game remix':
|
|
314
|
-
lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Its game.json credits the original: "${result.credit}"${result.page ? ` (${result.page})` : ''}, on its landing and in its credits. Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
|
|
315
|
-
if (result.assets?.carried) lines.push(` assets: ${result.assets.fetched.length} carried from the original (checked by SHA-256)${result.assets.placeholders.length ? `, ${result.assets.placeholders.length} grey placeholder(s) where the licence does not let a remix carry them (${result.assets.placeholders.map((p) => p.asset).join(', ')}): see games/${result.id}/assets/RIGHTS.md` : ''}`);
|
|
316
|
-
if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
|
|
317
|
-
for (const h of result.needsHeld ?? []) lines.push(`It was written against ${h.name} ${h.want}; this studio pins ${h.have}, which stays.`);
|
|
318
|
-
if (result.needsNotAdded?.length) lines.push(`Its game.json also asks for ${result.needsNotAdded.join(', ')}: not added (a remix adds only what the toolkit's starters use). Read the code before adding any.`);
|
|
319
|
-
break;
|
|
320
363
|
case 'game new':
|
|
321
364
|
lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
|
|
322
365
|
if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
|
|
@@ -328,12 +371,22 @@ function print(result) {
|
|
|
328
371
|
}
|
|
329
372
|
break;
|
|
330
373
|
case 'dev stop':
|
|
331
|
-
lines.push(result.stopped.length ? `Stopped this studio's dev server (${result.stopped.join(', ')}).` : `Nothing to stop: ${result.why ?? 'no dev server of this studio is running'}.`);
|
|
374
|
+
lines.push(result.stopped.length ? `Stopped this studio's dev server (${result.stopped.join(', ')}).${result.why ? ` Note: ${result.why}.` : ''}` : `Nothing to stop: ${result.why ?? 'no dev server of this studio is running'}.`);
|
|
332
375
|
break;
|
|
333
376
|
case 'build':
|
|
334
377
|
lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
|
|
378
|
+
// Per game: did it change since the build before, which build it is (the hash the live site's manifest says once
|
|
379
|
+
// deployed), and what loads later.
|
|
380
|
+
for (const g of result.games) lines.push(` ${g.id}: ${g.changed ?? 'new'}, build ${g.hash ?? '?'}${g.bundle ? ` (${g.bundle}${g.chunks ? ` + ${g.chunks} ${g.chunks === 1 ? 'chunk' : 'chunks'} loaded later` : ''})` : ''}`);
|
|
381
|
+
if (result.retired) lines.push(`Note: ${result.retired}`);
|
|
382
|
+
if (result.types) lines.push(`Types: ${result.types.games.filter((g) => g.checked).map((g) => g.id).join(', ') || 'no game has TypeScript to check'}${result.types.games.some((g) => g.checked) ? ` checked with TypeScript ${result.types.typescript ?? '?'}, no errors` : ''}`);
|
|
335
383
|
if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
|
|
336
|
-
for (const l of result.landings ?? [])
|
|
384
|
+
for (const l of result.landings ?? []) {
|
|
385
|
+
lines.push(`Landing /${l.id}/: hero from ${l.hero === 'footage' ? 'its footage' : l.hero === 'art' ? 'its art (moving)' : 'the studio\'s colours (it has no picture or footage yet)'}${l.credits ? ', credits' : ''}`);
|
|
386
|
+
// Which files, and what shape: the names the landing looks for (lib/site.mjs HERO_NAMES). Any size is shown,
|
|
387
|
+
// cropped to fill; these are the sizes that fill a computer and an upright phone without waste.
|
|
388
|
+
if (l.hero === 'colours') lines.push(` give it one: games/${l.id}/hero/wide.jpg (16:9, 1280×720 or larger) and games/${l.id}/hero/tall.jpg (9:16, 720×1280, for an upright phone); footage beside them as hero/wide.mp4 and hero/tall.mp4 (the same shapes, a silent loop of 8 to 15 s, under 3 MB each); or "cover" in its game.json`);
|
|
389
|
+
}
|
|
337
390
|
if (result.posts?.length) lines.push(`Posts: ${result.posts.map((x) => `/posts/${x}/`).join(', ')} (feeds: /posts/feed.xml, /posts/feed.json)`);
|
|
338
391
|
if (result.pages?.length || result.partials?.length) lines.push(`The studio's own: ${[...(result.pages ?? []).map((x) => `page ${x}`), ...(result.partials ?? []).map((x) => `partial ${x}`)].join(', ')}`);
|
|
339
392
|
for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
@@ -390,12 +443,14 @@ function print(result) {
|
|
|
390
443
|
}
|
|
391
444
|
case 'deploy':
|
|
392
445
|
if (result.ci) {
|
|
393
|
-
lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}`));
|
|
446
|
+
lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}${g.hash ? ` [${deployWords(g)}]` : ''}`));
|
|
394
447
|
break;
|
|
395
448
|
}
|
|
396
449
|
lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
|
|
397
450
|
...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
|
|
398
|
-
|
|
451
|
+
// Which games this deploy changed since the last deploy from here, and each one's build hash
|
|
452
|
+
// (lib/deploy-state.mjs): the hash `build` printed and the one the live site's manifest answers with.
|
|
453
|
+
...result.games.map((g) => ` ${g.id}: ${g.play}${g.hash ? ` [${deployWords(g)}]` : ''}`), ...(result.removed?.length ? [` no longer on the site: ${result.removed.join(', ')}`] : []), ...(result.notes ?? []).map((n) => ` Note: ${n}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
|
|
399
454
|
...(result.media?.moved ?? []).map((m) => ` moved to R2 (checked by SHA-256): ${m.path}, still at ${m.url}`), ...(result.media?.failed ?? []).map((m) => ` not moved to R2: ${m.path}: ${m.why}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
|
|
400
455
|
result.claim ? 'The site claimed itself in the directory: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the site claims itself when the directory first reads it; publish does).');
|
|
401
456
|
break;
|
|
@@ -403,13 +458,16 @@ function print(result) {
|
|
|
403
458
|
lines.push(`What \`npm run deploy\` does for ${result.studio}, on the Cloudflare account the person approves:`, '',
|
|
404
459
|
...result.cloudflare.map((r) => ` ${r.kind}${r.name ? ` ${r.name}` : ''}: ${r.what} [${r.state}${r.plan ? `; ${r.plan}` : ''}]`), '',
|
|
405
460
|
`Cost: ${result.cost}`, `Sign-in: ${result.login}`, `Address: ${result.address}`,
|
|
406
|
-
`The directory (${result.directory.site}) stores: ${result.directory.stores}`, result.never
|
|
461
|
+
`The directory (${result.directory.site}) stores: ${result.directory.stores}`, result.never,
|
|
462
|
+
...(result.parts && partsPlanLines(result.parts).length ? ['', ...partsPlanLines(result.parts)] : []));
|
|
407
463
|
break;
|
|
408
464
|
case 'storage add':
|
|
409
465
|
lines.push(result.already ? `Storage is already added: R2 bucket ${result.bucket}.` : `Storage added: R2 bucket ${result.bucket}.`, 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
410
466
|
break;
|
|
411
467
|
case 'publish':
|
|
412
|
-
lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`))
|
|
468
|
+
lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`), ...(result.publishes?.line ? [result.publishes.line] : []),
|
|
469
|
+
// The parts in what was listed, in the deploy plan's own lines (lib/parts-build.mjs).
|
|
470
|
+
...(result.parts && partsPlanLines(result.parts, { at: 'publish' }).length ? ['', ...partsPlanLines(result.parts, { at: 'publish' })] : []));
|
|
413
471
|
break;
|
|
414
472
|
case 'port plan': {
|
|
415
473
|
const f = result.facts;
|
|
@@ -662,8 +720,23 @@ function print(result) {
|
|
|
662
720
|
lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
|
|
663
721
|
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`),
|
|
664
722
|
...(result.frames ?? []).map((f) => ` ${f.browser} drew ${f.fps ?? '?'} fps${f.renderer ? ` on ${f.renderer}` : ''}`),
|
|
723
|
+
// Seated, ready and connected are three things: each gets its own line beside the verdict.
|
|
724
|
+
...(result.readiness ?? []).map((r) => ` ${r.browser} ${r.ready === true ? `ready: the loading cover lifted${r.liftedMs !== null ? ` at ${(r.liftedMs / 1000).toFixed(1)} s` : ''}${r.by ? ` (by the ${r.by})` : ''}` : r.ready === false ? `NOT READY: ${r.why}` : `readiness unknown: ${r.why}`}`),
|
|
725
|
+
` Connection: ${result.uninterrupted === true ? 'uninterrupted' : result.uninterrupted === false ? 'INTERRUPTED' : 'unknown'} (${result.note ?? 'not reported'})`,
|
|
665
726
|
...(result.software ? [` ${result.software}`] : []));
|
|
666
727
|
break;
|
|
728
|
+
case 'shoot':
|
|
729
|
+
lines.push(`Shot ${result.frames} of ${result.asked} frames of ${result.game} at ${result.fps} a second of the game's own clock (${result.virtualSeconds} s of game time in ${result.realSeconds} s) into ${result.out}`,
|
|
730
|
+
` Smoke (two clients, one private room): ${result.smoke.verdict}${result.smoke.why ? `: ${result.smoke.why}` : ''}${result.smoke.note ? ` (${result.smoke.note})` : ''}`,
|
|
731
|
+
...(result.build ? [` Build: ${result.build.hash} (${result.build.bundle}${result.build.loaded === true ? ', loaded by the page' : result.build.loaded === false ? ', NOT loaded by the page' : ''})`] : []),
|
|
732
|
+
` Ready: ${result.ready.ready === true ? 'the loading cover had lifted' : result.ready.ready === false ? `NO: ${result.ready.why}` : `unknown: ${result.ready.why}`}`,
|
|
733
|
+
` Renderer: ${result.renderer ?? 'unknown'}${result.software ? ' (software: these frames are still one step of the clock each, but slow to make)' : ''}`,
|
|
734
|
+
...(result.partial ? [` PARTIAL: ${result.partial}`] : []), ...(result.note ? [` ${result.note}`] : []),
|
|
735
|
+
` ${result.limits}`, ` Every frame's time, round and position: ${result.out}/shoot.json`);
|
|
736
|
+
break;
|
|
737
|
+
case 'trailer':
|
|
738
|
+
lines.push(...trailerLines(result));
|
|
739
|
+
break;
|
|
667
740
|
default:
|
|
668
741
|
lines.push(JSON.stringify(result, null, 2));
|
|
669
742
|
}
|
|
@@ -692,7 +765,7 @@ async function main() {
|
|
|
692
765
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
693
766
|
if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
|
|
694
767
|
if (cmd === 'chrome') { const chrome = findChrome(); return chrome ? { ok: true, command: 'chrome', chrome, args: chromeArgs() } : { ok: false, command: 'chrome', why: noChrome() }; }
|
|
695
|
-
if ((cmd === 'setup' && sub === 'status') || cmd === 'doctor') return setupStatus({ connector: flags.get('connector') ?? null, homie: flags.get('homie') ?? null });
|
|
768
|
+
if ((cmd === 'setup' && sub === 'status') || cmd === 'doctor') return setupStatus({ connector: flags.get('connector') ?? null, homie: flags.get('homie') ?? null, client: flags.get('client') ?? null });
|
|
696
769
|
if (cmd === 'statusline' && !flags.has('install') && !flags.has('remove')) {
|
|
697
770
|
const line = statusLine({ columns: Number(process.env.COLUMNS) || 100, color: !process.env.NO_COLOR && !asJson && process.stdout.isTTY });
|
|
698
771
|
if (asJson) return { ok: true, command: 'statusline', line };
|
|
@@ -706,9 +779,13 @@ async function main() {
|
|
|
706
779
|
// The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
|
|
707
780
|
if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
|
|
708
781
|
const root = requireStudio();
|
|
782
|
+
// A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
|
|
783
|
+
if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
|
|
709
784
|
if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
|
|
710
785
|
if (cmd === 'assets') return assetsCommand(root, sub, positional, flags, { log });
|
|
711
786
|
if (cmd === 'anim') return animCommand(root, sub, positional, flags, { log });
|
|
787
|
+
if (cmd === 'collision') return collisionCommand(root, sub, positional, flags);
|
|
788
|
+
if (cmd === 'parts') return partsCommand(root, sub, positional, flags, { log });
|
|
712
789
|
if (cmd === 'cast') { const games = listGames(root); const id = sub ?? (games.length === 1 ? games[0].id : null); if (!id) return { ok: false, command: 'cast', why: `name the game: homie-studio cast <id>${games.length ? ` (${games.map((g) => g.id).join(', ')})` : ''}` }; return castView(root, id); }
|
|
713
790
|
if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
|
|
714
791
|
if (cmd === 'codex') return codexCommand(root, sub);
|
|
@@ -729,9 +806,12 @@ async function main() {
|
|
|
729
806
|
return tracked(root, 'checks', (report) => portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'port check');
|
|
730
807
|
}
|
|
731
808
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
|
732
|
-
|
|
809
|
+
// Remix was retired (a game is never handed over whole): the command is refused in a sentence that says where to
|
|
810
|
+
// go, so an old note, an old card or an agent's memory of the toolkit does not end at "unknown command".
|
|
811
|
+
if (cmd === 'game' && sub === 'remix') return { ok: false, command: 'game remix', why: 'remix was retired: a game is no longer handed over whole, and no studio serves one. Games build on each other through parts, the pieces of a game its studio chose to share: find one with `homie-studio parts find <words>` and bring it in with `homie-studio parts add` (in chat: parts_find, part_add; parts/PARTS.md)' };
|
|
733
812
|
if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
|
|
734
|
-
if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps') }), 'build');
|
|
813
|
+
if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps'), types: flags.has('types') }), 'build');
|
|
814
|
+
if (cmd === 'preview') return preview(root, positional[1] ?? (listGames(root).length === 1 ? listGames(root)[0].id : null));
|
|
735
815
|
if (cmd === 'perf' && sub === 'sizes') return perfSizes(root, positional[2] ?? listGames(root)[0]?.id);
|
|
736
816
|
if (cmd === 'lab') return labCommand(root, sub);
|
|
737
817
|
if (cmd === 'perf') {
|
|
@@ -750,8 +830,9 @@ async function main() {
|
|
|
750
830
|
const plan = upgradePlan(root);
|
|
751
831
|
return flags.has('apply') || flags.has('yes') ? upgradeApply(root, plan) : plan;
|
|
752
832
|
}
|
|
833
|
+
// lib/dev.mjs: the dev server, its registration file, and what `--stop` will and will not signal.
|
|
753
834
|
if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
|
|
754
|
-
if (cmd === 'dev') return dev(root);
|
|
835
|
+
if (cmd === 'dev') return dev(root, { port: flags.get('port') ?? 8787, remoteAi: flags.has('remote-ai'), localAi: !flags.has('no-local-ai'), timestamps: flags.has('timestamps'), log });
|
|
755
836
|
if (cmd === 'check') {
|
|
756
837
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
757
838
|
const url = flags.get('url') ?? siteUrl(root);
|
|
@@ -762,6 +843,20 @@ async function main() {
|
|
|
762
843
|
if (big.length) return { ok: false, command: 'check', why: `committed files over 5 MB under games/${game}: ${big.map((f) => `${f.path} (${(f.bytes / 1024 / 1024).toFixed(1)} MB)`).join(', ')}. Big media goes to the studio's R2 (homie-studio storage add, then media move); raw models stay in art/<slug>/raw/ (git-ignored); a shipped model is made phone-sized with homie-studio assets optimise. Take it out of git (git rm --cached), then check again.` };
|
|
763
844
|
return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
|
|
764
845
|
}
|
|
846
|
+
if (cmd === 'shoot') {
|
|
847
|
+
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
848
|
+
// --preview: the frames half from the light preview server this command starts itself (no Wrangler, no rooms).
|
|
849
|
+
const preview = flags.has('preview');
|
|
850
|
+
const url = preview ? null : flags.get('url') ?? siteUrl(root);
|
|
851
|
+
if (!url && !preview) return { ok: false, command: 'shoot', why: 'give --url (the local dev address or the live site), or --preview to shoot the built game by itself with no site running' };
|
|
852
|
+
const { shoot } = await import('../lib/shoot.mjs');
|
|
853
|
+
const num = (k, d) => (flags.get(k) === undefined || flags.get(k) === true ? d : Number(flags.get(k)));
|
|
854
|
+
return shoot({
|
|
855
|
+
url, preview, root, game, frames: num('frames', 60), fps: num('fps', 30), device: String(flags.get('device') ?? 'computer'),
|
|
856
|
+
out: flags.get('out') && flags.get('out') !== true ? resolve(flags.get('out')) : join(root, '.studio', 'shoot', String(game)),
|
|
857
|
+
smoke: !flags.has('no-smoke'), hold: typeof flags.get('hold') === 'string' ? flags.get('hold') : null, timeoutMs: Math.max(20, num('timeout', 180)) * 1000, log,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
765
860
|
if (cmd === 'look') {
|
|
766
861
|
const url = flags.get('url') ?? siteUrl(root);
|
|
767
862
|
if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
|
|
@@ -775,7 +870,7 @@ async function main() {
|
|
|
775
870
|
const only = flags.get('only') ? String(flags.get('only')).split(',').filter((d) => ['computer', 'phone', 'sideways'].includes(d)) : undefined;
|
|
776
871
|
return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
|
|
777
872
|
}
|
|
778
|
-
if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
|
|
873
|
+
if (cmd === 'deploy' && flags.has('plan')) return { ...deployPlan(root), parts: partsPublishReport(root) };
|
|
779
874
|
// Cloudflare's Workers Builds runs `npm run deploy` with WORKERS_CI=1 (and its own token for this one account).
|
|
780
875
|
if (cmd === 'deploy' && (process.env.WORKERS_CI === '1' || flags.has('ci'))) return tracked(root, 'deploy', () => ciDeploy(root, { log }), 'deploy');
|
|
781
876
|
if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
|
|
@@ -785,7 +880,7 @@ async function main() {
|
|
|
785
880
|
const has = Boolean(cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`));
|
|
786
881
|
return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
|
|
787
882
|
}
|
|
788
|
-
if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
|
|
883
|
+
if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site'), log });
|
|
789
884
|
if (cmd === 'players' && sub === 'owner') return playersOwner(root, { url: flags.get('url'), revoke: flags.has('revoke') });
|
|
790
885
|
if (cmd === 'players' && !sub) return playersShow(root, { url: flags.get('url') });
|
|
791
886
|
if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
|
|
@@ -800,7 +895,7 @@ async function main() {
|
|
|
800
895
|
if (sub === 'key') return officeKey(root, { url, hours: flags.get('hours') });
|
|
801
896
|
if (sub === 'announce') return officeAnnounce(root, positional.slice(2).join(' '), { url, game: flags.get('game'), room: flags.get('room'), seconds: flags.get('seconds') });
|
|
802
897
|
if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days'), server: flags.get('server') });
|
|
803
|
-
if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url,
|
|
898
|
+
if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, max: flags.get('max') });
|
|
804
899
|
if (sub === 'kick') return officeKick(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes') });
|
|
805
900
|
if (sub === 'mute') return officeMute(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes'), off: flags.has('off') });
|
|
806
901
|
if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
|
|
@@ -1025,97 +1120,21 @@ async function progressCommand(root, sub) {
|
|
|
1025
1120
|
} catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
|
|
1026
1121
|
}
|
|
1027
1122
|
|
|
1028
|
-
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
* never has to reach for `pkill -f "wrangler dev"`, which stops every project's dev server on the machine.
|
|
1123
|
+
/**
|
|
1124
|
+
* One built game's files at an address on this computer (lib/preview.mjs), until the process is stopped. Its address
|
|
1125
|
+
* is the first thing on stdout (a line of JSON with --json), so a script that starts it reads where to go.
|
|
1032
1126
|
*/
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
if (!
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
if (ps.status !== 0) return process.platform === 'win32'; // no ps: the PID file is all there is
|
|
1039
|
-
let real = root;
|
|
1040
|
-
try { real = realpathSync(root); } catch { /* keep */ }
|
|
1041
|
-
// A PID the file names is still ours when it runs from this studio, or is still the kind of process it was
|
|
1042
|
-
// (a recycled PID is neither), so nothing else on the machine is ever stopped.
|
|
1043
|
-
return ps.stdout.includes(root) || ps.stdout.includes(real) || (kind === 'dev' ? /homie-studio/.test(ps.stdout) : /wrangler|workerd/.test(ps.stdout));
|
|
1044
|
-
}
|
|
1045
|
-
async function stopDev(root) {
|
|
1046
|
-
const file = devFile(root);
|
|
1047
|
-
if (!existsSync(file)) return { ok: true, command: 'dev stop', stopped: [], why: 'no dev server of this studio is running' };
|
|
1048
|
-
let rec = {};
|
|
1049
|
-
try { rec = JSON.parse(readFileSync(file, 'utf8')); } catch { /* a torn file */ }
|
|
1050
|
-
const pids = [[rec.child, 'wrangler'], [rec.pid, 'dev']].filter(([p, kind]) => processOfStudio(p, root, kind)).map(([p]) => p);
|
|
1051
|
-
for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
|
|
1052
|
-
const deadline = Date.now() + 8000;
|
|
1053
|
-
const alive = () => pids.filter((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
|
|
1054
|
-
while (alive().length && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
|
|
1055
|
-
for (const pid of alive()) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
|
|
1056
|
-
rmSync(file, { force: true });
|
|
1057
|
-
return { ok: true, command: 'dev stop', stopped: pids };
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
/** The whole site locally: pages, rooms (room.mjs in a local Durable Object), D1. */
|
|
1061
|
-
async function dev(root) {
|
|
1062
|
-
const port = String(flags.get('port') ?? 8787);
|
|
1063
|
-
const b = await build(root, { log });
|
|
1064
|
-
if (!b.catalogue.length && !b.songs.length && !b.videos.length) log('No game yet: the home page says "First game coming soon" until the first one is made.');
|
|
1065
|
-
const bin = wranglerBin(root);
|
|
1066
|
-
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
1067
|
-
const studio = readStudio(root);
|
|
1068
|
-
const env = { ...process.env, ...projectsCloudflareEnv(root), WRANGLER_SEND_METRICS: 'false', CI: '1' };
|
|
1069
|
-
for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
|
|
1127
|
+
async function preview(root, id) {
|
|
1128
|
+
const p = await previewServer(root, id, { port: flags.get('port') ?? PREVIEW_PORT, strict: flags.has('port') });
|
|
1129
|
+
if (!p.ok) return p;
|
|
1130
|
+
const { server, dir, ...said } = p;
|
|
1131
|
+
process.stdout.write(asJson ? `${JSON.stringify({ ...said, dir: relative(root, dir) })}\n` : `Preview of ${id}: ${p.url} (its built files only, from ${relative(process.cwd(), dir) || dir}: no rooms, it plays offline with its bots; a new build shows on the next reload. Ctrl-C stops it)\n`);
|
|
1070
1132
|
await new Promise((done) => {
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1133
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => server.close(done));
|
|
1134
|
+
server.on('close', done);
|
|
1073
1135
|
});
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
if (ai.note) log(ai.note);
|
|
1077
|
-
// The person's own Clef (0.24.4, lib/local-ai.mjs): with Ollama and clef-flash on this computer, the local Worker's
|
|
1078
|
-
// guides, chat review and game decisions think here, free. Never a download: dev only says what one would cost.
|
|
1079
|
-
let localVars = [];
|
|
1080
|
-
if (!flags.has('remote-ai') && !flags.has('no-local-ai')) {
|
|
1081
|
-
const found = await detectLocalAi();
|
|
1082
|
-
localVars = localAiVars(found);
|
|
1083
|
-
log(found.ok ? `AI guides, chat review and game decisions think with ${found.model} on this computer (Ollama ${found.version}): free, nothing sent to Cloudflare.` : found.say);
|
|
1084
|
-
}
|
|
1085
|
-
// --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
|
|
1086
|
-
// (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
|
|
1087
|
-
const child = spawn(bin, ['dev', ...(flags.has('remote-ai') ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args, ...localVars], { cwd: workerDir(root), env, stdio: 'inherit' });
|
|
1088
|
-
mkdirSync(dirname(devFile(root)), { recursive: true });
|
|
1089
|
-
writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
|
|
1090
|
-
log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
|
|
1091
|
-
// Stopping this process stops Wrangler with it, so no dev server is left running on its own.
|
|
1092
|
-
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
|
|
1093
|
-
await new Promise((done) => child.on('close', done));
|
|
1094
|
-
rmSync(devFile(root), { force: true });
|
|
1095
|
-
return { ok: true, command: 'dev', stopped: true };
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* AI guides under `dev` (0.17.0): they think scripted, for free, unless --remote-ai (real Workers AI on the signed-in
|
|
1100
|
-
* account, billed like the live site). A config whose AI binding is not what was asked for runs from a copy in
|
|
1101
|
-
* .wrangler/ (absolute paths, the same local state), so wrangler.jsonc itself is never changed by dev.
|
|
1102
|
-
*/
|
|
1103
|
-
function devConfig(root, remoteAi) {
|
|
1104
|
-
const file = join(workerDir(root), 'wrangler.jsonc');
|
|
1105
|
-
let json;
|
|
1106
|
-
try { json = JSON.parse(readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return { args: [] }; }
|
|
1107
|
-
if (Boolean(json.ai) === remoteAi) return { args: [], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null };
|
|
1108
|
-
const base = workerDir(root);
|
|
1109
|
-
const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
|
|
1110
|
-
delete json.$schema;
|
|
1111
|
-
json.main = abs(json.main);
|
|
1112
|
-
if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
|
|
1113
|
-
json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
|
|
1114
|
-
if (remoteAi) json.ai = { binding: 'AI', remote: true }; else delete json.ai;
|
|
1115
|
-
const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
|
|
1116
|
-
mkdirSync(dirname(copy), { recursive: true });
|
|
1117
|
-
writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
|
|
1118
|
-
return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.' };
|
|
1136
|
+
server.closeAllConnections?.();
|
|
1137
|
+
return { ok: true, command: 'help' };
|
|
1119
1138
|
}
|
|
1120
1139
|
|
|
1121
1140
|
/** `agents sit`: an AI guide's seat from this terminal, for a demo or a test of a vocabulary. Lines on stdin act. */
|
|
@@ -1187,7 +1206,7 @@ try {
|
|
|
1187
1206
|
if (result && result.command !== 'help') print(result);
|
|
1188
1207
|
if (result?.ok === false) process.exitCode = 1;
|
|
1189
1208
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
1190
|
-
if (['check', 'port check', 'look', 'perf'].includes(result?.command)) process.exit(process.exitCode ?? 0);
|
|
1209
|
+
if (['check', 'port check', 'look', 'perf', 'shoot'].includes(result?.command)) process.exit(process.exitCode ?? 0);
|
|
1191
1210
|
} catch (error) {
|
|
1192
1211
|
await flushProgress().catch(() => {});
|
|
1193
1212
|
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|