@homie-rocks/studio 0.19.2 → 0.21.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 +93 -2
- package/README.md +39 -0
- package/bin/homie-studio.mjs +56 -3
- package/lab/harness.js +371 -0
- package/lab/lab.ts +180 -0
- package/lab/page/index.html +83 -0
- package/lab/page/lab.css +174 -0
- package/lab/page/page.js +802 -0
- package/lab/take.js +189 -0
- package/lib/build.mjs +61 -44
- package/lib/check.mjs +3 -1
- package/lib/lab-check.mjs +212 -0
- package/lib/lab.mjs +546 -0
- package/lib/mcp-tools.mjs +45 -1
- package/lib/mcp.mjs +4 -2
- package/lib/office.mjs +30 -8
- package/lib/port-check.mjs +3 -1
- package/lib/progress.mjs +16 -1
- package/lib/scaffold.mjs +13 -2
- package/lib/studio.mjs +14 -0
- package/lib/template-history.json +74 -0
- package/lib/thumb.mjs +82 -0
- package/mcp/ui/lab.js +67 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/starters/ember-vale/lab.json +22 -0
- package/starters/ember-vale/src/main.ts +271 -19
- package/starters/ember-vale/tunables.json +13 -0
- package/starters/gem-rush/lab.json +19 -0
- package/starters/gem-rush/src/main.ts +213 -38
- package/starters/gem-rush/tunables.json +13 -0
- package/worker/version.mjs +1 -1
package/lib/office.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
|
|
11
11
|
* homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
|
|
12
12
|
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
13
|
+
* homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
|
|
13
14
|
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
14
15
|
* homie-studio office revoke every office key ends; every play ticket and pending ask too
|
|
15
16
|
*
|
|
@@ -121,21 +122,42 @@ export async function officeLaunch(root, game, state, { url, remixable, max } =
|
|
|
121
122
|
return askedFor(root, url, r, 'office launch');
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/** One player of one live room, by seat number (from 1) or name: { room, client } or { message }. */
|
|
126
|
+
async function playerIn(call, game, room, who) {
|
|
127
|
+
const office = await call('/_studio/api/office');
|
|
128
|
+
const g = (office.games ?? []).find((x) => x.id === game);
|
|
129
|
+
const rm = (g?.rooms ?? []).find((x) => x.room === room || x.label.toLowerCase() === String(room).toLowerCase());
|
|
130
|
+
if (!rm) return { message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
|
|
131
|
+
const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
|
|
132
|
+
const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
|
|
133
|
+
if (!c) return { message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
|
|
134
|
+
return { room: rm, client: c };
|
|
135
|
+
}
|
|
136
|
+
|
|
124
137
|
export async function officeKick(root, game, room, who, { url, minutes } = {}) {
|
|
125
138
|
if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office kick', why: 'usage: homie-studio office kick <game> <room> <seat number | name> [--minutes 10]' };
|
|
126
139
|
const r = await withKey(root, url, async (call) => {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!rm) return { ok: false, message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
|
|
131
|
-
const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
|
|
132
|
-
const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
|
|
133
|
-
if (!c) return { ok: false, message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
|
|
134
|
-
return call('/_studio/api/kick', { game, room: rm.room, id: c.id, minutes: Number(minutes) || 10 });
|
|
140
|
+
const p = await playerIn(call, game, room, who);
|
|
141
|
+
if (!p.client) return { ok: false, message: p.message };
|
|
142
|
+
return call('/_studio/api/kick', { game, room: p.room.room, id: p.client.id, minutes: Number(minutes) || 10 });
|
|
135
143
|
});
|
|
136
144
|
return askedFor(root, url, r, 'office kick');
|
|
137
145
|
}
|
|
138
146
|
|
|
147
|
+
/**
|
|
148
|
+
* `office mute <game> <room> <seat | name> [--minutes 10] [--off]`: the player's chat and emotes reach nobody for a
|
|
149
|
+
* while (`--off` lifts it). Like a kick, it is only ASKED for: the owner confirms with one tap.
|
|
150
|
+
*/
|
|
151
|
+
export async function officeMute(root, game, room, who, { url, minutes, off = false } = {}) {
|
|
152
|
+
if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office mute', why: 'usage: homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]' };
|
|
153
|
+
const r = await withKey(root, url, async (call) => {
|
|
154
|
+
const p = await playerIn(call, game, room, who);
|
|
155
|
+
if (!p.client) return { ok: false, message: p.message };
|
|
156
|
+
return call('/_studio/api/mute', { game, room: p.room.room, id: p.client.id, minutes: Number(minutes) || 10, ...(off ? { off: true } : {}) });
|
|
157
|
+
});
|
|
158
|
+
return askedFor(root, url, r, 'office mute');
|
|
159
|
+
}
|
|
160
|
+
|
|
139
161
|
export async function officeClose(root, game, room, { url, minutes, reopen } = {}) {
|
|
140
162
|
if (!GAME.test(String(game ?? '')) || !room) return { ok: false, command: 'office close', why: 'usage: homie-studio office close <game> <room> [--minutes 10] [--reopen]' };
|
|
141
163
|
const r = await withKey(root, url, (call) => call('/_studio/api/close', { game, room, minutes: Number(minutes) || 10, reopen: Boolean(reopen) }));
|
package/lib/port-check.mjs
CHANGED
|
@@ -409,7 +409,9 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
409
409
|
await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000);
|
|
410
410
|
if (report?.preview && h.vp) {
|
|
411
411
|
const b64 = await T(h.page.screenshot({ type: 'jpeg', quality: 60, clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 480 / h.vp.width) }, encoding: 'base64' }), 15_000, null);
|
|
412
|
-
|
|
412
|
+
// And a small PNG, kept as raw pixels beside the feed for Claude Code's Homie mod (lib/thumb.mjs).
|
|
413
|
+
const png = b64 ? await T(h.page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 160 / h.vp.width) } }), 15_000, null) : null;
|
|
414
|
+
if (b64) { try { report.preview({ url: `${base}/${game}/play`, image: `data:image/jpeg;base64,${b64}`, caption: PORT_CHECK_LABELS[name.replace(/-(?:before|after|playing|over|\d+)$/, '')] ?? name, ...(png ? { png: Buffer.from(png) } : {}) }); } catch { /* too big: no picture */ } }
|
|
413
415
|
}
|
|
414
416
|
};
|
|
415
417
|
const privateRoom = (tag) => `chk-${stamp.slice(-6)}-${tag}`.slice(0, 32);
|
package/lib/progress.mjs
CHANGED
|
@@ -56,6 +56,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
56
56
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
57
57
|
import { extname, join } from 'node:path';
|
|
58
58
|
import { refreshCodexFile } from './codex.mjs';
|
|
59
|
+
import { dropThumbs, rgbThumb, thumbName } from './thumb.mjs';
|
|
59
60
|
import { readStudio, siteUrl } from './studio.mjs';
|
|
60
61
|
import { request, whyRefused } from './net.mjs';
|
|
61
62
|
|
|
@@ -307,8 +308,22 @@ export class Feed {
|
|
|
307
308
|
/** Forget the checks of one stage (a stage run again starts its checks afresh). */
|
|
308
309
|
resetChecks(stage) { return this.change((doc) => { doc.checks = doc.checks.filter((c) => c.stage !== stage); }); }
|
|
309
310
|
|
|
310
|
-
|
|
311
|
+
/**
|
|
312
|
+
* The build's preview: the address to play it, a small picture (the feed's JPEG, for the Claude app's card) and its
|
|
313
|
+
* caption. `png`, a PNG screenshot's bytes (or a .png `image` file), is also kept small as raw RGB beside the feed
|
|
314
|
+
* (lib/thumb.mjs), for Claude Code's Homie mod, which has no image decoder; it never goes into the feed or the
|
|
315
|
+
* shared copy.
|
|
316
|
+
*/
|
|
317
|
+
preview({ url, image, caption, png } = {}) {
|
|
311
318
|
const picture = image ? imageData(image) : undefined;
|
|
319
|
+
const raw = png ?? (typeof image === 'string' && /\.png$/i.test(image) && existsSync(image) ? readFileSync(image) : null);
|
|
320
|
+
if (raw) {
|
|
321
|
+
try {
|
|
322
|
+
const t = rgbThumb(raw, 160);
|
|
323
|
+
dropThumbs(dir(this.root), this.id);
|
|
324
|
+
writeFileSync(join(dir(this.root), thumbName(this.id, t.w, t.h)), t.rgb);
|
|
325
|
+
} catch { /* no raw preview this time: the feed's picture stands */ }
|
|
326
|
+
}
|
|
312
327
|
return this.change((doc) => {
|
|
313
328
|
doc.preview = {
|
|
314
329
|
url: url ? String(url).slice(0, 300) : doc.preview?.url ?? null,
|
package/lib/scaffold.mjs
CHANGED
|
@@ -104,7 +104,7 @@ directory lists its games; homie.rocks does not host them.
|
|
|
104
104
|
|
|
105
105
|
| Path | What it is |
|
|
106
106
|
| --- | --- |
|
|
107
|
-
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts
|
|
107
|
+
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
|
|
108
108
|
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
109
109
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
110
110
|
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
|
|
@@ -140,6 +140,12 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
140
140
|
per frame, time to playable, what it downloads, the heap, netplay messages a second (files under \`.perf/\`). The
|
|
141
141
|
Homie plugin's \`perf\` skill runs the whole loop: one change at a time, kept only when it is better beyond the noise
|
|
142
142
|
and \`check\` still passes, and a report in \`perf/<id>/\`.
|
|
143
|
+
- \`npx --no-install homie-studio lab <id>\` — the Game Lab, on this computer (run it in the background; stop it with
|
|
144
|
+
\`lab --stop\`): one take of a move (\`games/<id>/lab.json\`) played in the working tree beside the last commit, on
|
|
145
|
+
one clock, slowed down or a frame at a time, with the phases, graphs and tunables the game reports
|
|
146
|
+
(\`@homie-rocks/studio/lab\`). \`lab check <id>\` plays it headless and writes the numbers and a contact sheet. The
|
|
147
|
+
Homie plugin's \`lab\` skill runs the whole loop: instrument and commit first, change how it feels, keep what the
|
|
148
|
+
person likes.
|
|
143
149
|
- \`npx --no-install homie-studio deploy --plan\` — says what deploy will create on Cloudflare and what it
|
|
144
150
|
costs, and changes nothing. Tell the person before the first deploy.
|
|
145
151
|
- \`npm run deploy\` — the site on this studio's Cloudflare: one Worker, one D1 database, two
|
|
@@ -273,7 +279,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
273
279
|
- The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
|
|
274
280
|
Mute, Kick; Announce). Nobody else's page has it.
|
|
275
281
|
- **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
|
|
276
|
-
\`office invite <id>\` happen at once; \`office kick\`, \`office close\` and \`office launch\` only ASK, and print a
|
|
282
|
+
\`office invite <id>\` happen at once; \`office kick\`, \`office mute\`, \`office close\` and \`office launch\` only ASK, and print a
|
|
277
283
|
one-time link that opens the ask in the owner's own browser, where one tap does it. An ask is not done until the
|
|
278
284
|
owner tapped. \`office key\` gives a key for the Homie MCP's owner tools (\`studio_office\`, \`room_announce\`,
|
|
279
285
|
\`room_kick\`, \`room_close\`, \`game_launch_state\`), which ask the same way; never paste a key anywhere else.
|
|
@@ -331,6 +337,11 @@ chat's card follows the work from then on) and, for a studio still being set up,
|
|
|
331
337
|
studio.json as \`cloudflare.domain\`.
|
|
332
338
|
- A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
|
|
333
339
|
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
340
|
+
- Optional in studio.json: \`"protect"\` (globs of files the owner wants to look at before any change, such as
|
|
341
|
+
\`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
|
|
342
|
+
in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
|
|
343
|
+
the budget until the person says Proceed, and takes keys out of command output. When it refuses a call, say what
|
|
344
|
+
you meant to do and why; do not retry it unless the person asks.
|
|
334
345
|
|
|
335
346
|
## Beta
|
|
336
347
|
|
package/lib/studio.mjs
CHANGED
|
@@ -103,6 +103,18 @@ export function starters() {
|
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A game's Game Lab takes (lab.json) seed its saves by the game's id (`homie-saves.local.<id>`, saves/saves.ts): a copy
|
|
108
|
+
* under a new id renames them, so a take's saved hero is still found.
|
|
109
|
+
*/
|
|
110
|
+
function renameTakeSaves(dir, from, id) {
|
|
111
|
+
const file = join(dir, 'lab.json');
|
|
112
|
+
if (!existsSync(file) || from === id) return;
|
|
113
|
+
const text = readFileSync(file, 'utf8');
|
|
114
|
+
const next = text.split(`"homie-saves.local.${from}"`).join(`"homie-saves.local.${id}"`);
|
|
115
|
+
if (next !== text) writeFileSync(file, next);
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
/** `homie-studio game new <id> --from <starter> --name "<Name>"`: a starter copied in as this studio's own game. */
|
|
107
119
|
export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
108
120
|
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
|
|
@@ -121,6 +133,7 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
|
121
133
|
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
122
134
|
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
123
135
|
if (existsSync(main)) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
136
|
+
renameTakeSaves(dest, from, id);
|
|
124
137
|
const html = join(dest, 'index.html');
|
|
125
138
|
if (name && existsSync(html)) writeFileSync(html, readFileSync(html, 'utf8').replace(/<title>[^<]*<\/title>/, `<title>${String(name).replace(/[<&]/g, '')}</title>`));
|
|
126
139
|
return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String) };
|
|
@@ -174,5 +187,6 @@ export async function remixGame(root, source, id, { name } = {}) {
|
|
|
174
187
|
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
175
188
|
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
176
189
|
if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
190
|
+
if (from) renameTakeSaves(dest, from, id);
|
|
177
191
|
return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote };
|
|
178
192
|
}
|
|
@@ -850,6 +850,80 @@
|
|
|
850
850
|
}
|
|
851
851
|
}
|
|
852
852
|
}
|
|
853
|
+
},
|
|
854
|
+
"0.20.0": {
|
|
855
|
+
"files": {
|
|
856
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
857
|
+
"README.md": "684b3cca8001673c",
|
|
858
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
859
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
860
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
861
|
+
"videos/README.md": "16babc3437d5545b",
|
|
862
|
+
"posts/README.md": "37841aae476bd540",
|
|
863
|
+
"site/README.md": "ca131cd0472464fb",
|
|
864
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
865
|
+
},
|
|
866
|
+
"sections": {
|
|
867
|
+
"AGENTS.md": {
|
|
868
|
+
"(intro)": "214aa46eb3a895d5",
|
|
869
|
+
"## Layout": "43a4c5bf07db36f9",
|
|
870
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
871
|
+
"## The site": "ab922bb6bde23293",
|
|
872
|
+
"## Making games": "a8a893eeada33bf5",
|
|
873
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
874
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
875
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
876
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
877
|
+
"## Rules": "969318425c99f5a5",
|
|
878
|
+
"## Beta": "9d02638207a26e73"
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
"named": {
|
|
882
|
+
"Homie Arcade": {
|
|
883
|
+
"sections": {
|
|
884
|
+
"AGENTS.md": {
|
|
885
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
"0.21.0": {
|
|
892
|
+
"files": {
|
|
893
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
894
|
+
"README.md": "684b3cca8001673c",
|
|
895
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
896
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
897
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
898
|
+
"videos/README.md": "16babc3437d5545b",
|
|
899
|
+
"posts/README.md": "37841aae476bd540",
|
|
900
|
+
"site/README.md": "ca131cd0472464fb",
|
|
901
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
902
|
+
},
|
|
903
|
+
"sections": {
|
|
904
|
+
"AGENTS.md": {
|
|
905
|
+
"(intro)": "214aa46eb3a895d5",
|
|
906
|
+
"## Layout": "43a4c5bf07db36f9",
|
|
907
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
908
|
+
"## The site": "ab922bb6bde23293",
|
|
909
|
+
"## Making games": "a8a893eeada33bf5",
|
|
910
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
911
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
912
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
913
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
914
|
+
"## Rules": "7e8458adc1b81ece",
|
|
915
|
+
"## Beta": "9d02638207a26e73"
|
|
916
|
+
}
|
|
917
|
+
},
|
|
918
|
+
"named": {
|
|
919
|
+
"Homie Arcade": {
|
|
920
|
+
"sections": {
|
|
921
|
+
"AGENTS.md": {
|
|
922
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
853
927
|
}
|
|
854
928
|
}
|
|
855
929
|
}
|
package/lib/thumb.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A build's preview as raw pixels, for Claude Code's Homie mod (the plugin's hooks/homie.mjs): a mod's code has no
|
|
3
|
+
* image decoder, so the latest check frame is also kept, small, as plain RGB beside the feed:
|
|
4
|
+
*
|
|
5
|
+
* .studio/progress/<build>.preview.<width>x<height>.rgb width * height * 3 bytes, rows top to bottom
|
|
6
|
+
*
|
|
7
|
+
* The mod draws it as coloured terminal cells (or hands the file to the terminal as a picture). The feed's own JPEG
|
|
8
|
+
* stays what the Claude app's card shows. Plain Node: zlib inflates the PNG Chrome wrote; nothing else is needed.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, rmSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { inflateSync } from 'node:zlib';
|
|
13
|
+
|
|
14
|
+
/** A PNG (8-bit RGB or RGBA, not interlaced: what Chrome writes) as { w, h, rgba }. */
|
|
15
|
+
export function decodePng(buf) {
|
|
16
|
+
if (!Buffer.isBuffer(buf) || buf.length < 33 || buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
|
|
17
|
+
let at = 8; let w = 0; let h = 0; let type = 0; const idat = [];
|
|
18
|
+
while (at + 8 <= buf.length) {
|
|
19
|
+
const len = buf.readUInt32BE(at);
|
|
20
|
+
const kind = buf.toString('latin1', at + 4, at + 8);
|
|
21
|
+
const data = buf.subarray(at + 8, at + 8 + len);
|
|
22
|
+
if (kind === 'IHDR') {
|
|
23
|
+
w = data.readUInt32BE(0); h = data.readUInt32BE(4); type = data[9];
|
|
24
|
+
if (data[8] !== 8 || data[12] !== 0) throw new Error('only 8-bit, non-interlaced PNGs');
|
|
25
|
+
} else if (kind === 'IDAT') idat.push(data);
|
|
26
|
+
else if (kind === 'IEND') break;
|
|
27
|
+
at += 12 + len;
|
|
28
|
+
}
|
|
29
|
+
const bpp = type === 6 ? 4 : type === 2 ? 3 : 0;
|
|
30
|
+
if (!bpp || !w || !h) throw new Error('only RGB or RGBA PNGs');
|
|
31
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
32
|
+
const stride = w * bpp;
|
|
33
|
+
const px = Buffer.alloc(stride * h);
|
|
34
|
+
for (let y = 0; y < h; y++) {
|
|
35
|
+
const f = raw[y * (stride + 1)];
|
|
36
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
37
|
+
const o = y * stride;
|
|
38
|
+
for (let x = 0; x < stride; x++) {
|
|
39
|
+
const a = x >= bpp ? px[o + x - bpp] : 0;
|
|
40
|
+
const b = y ? px[o - stride + x] : 0;
|
|
41
|
+
const c = x >= bpp && y ? px[o - stride + x - bpp] : 0;
|
|
42
|
+
let v = line[x];
|
|
43
|
+
if (f === 1) v += a;
|
|
44
|
+
else if (f === 2) v += b;
|
|
45
|
+
else if (f === 3) v += (a + b) >> 1;
|
|
46
|
+
else if (f === 4) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
|
|
47
|
+
px[o + x] = v & 255;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const rgba = Buffer.alloc(w * h * 4);
|
|
51
|
+
for (let i = 0, j = 0; i < w * h; i++, j += bpp) { rgba[i * 4] = px[j]; rgba[i * 4 + 1] = px[j + 1]; rgba[i * 4 + 2] = px[j + 2]; rgba[i * 4 + 3] = bpp === 4 ? px[j + 3] : 255; }
|
|
52
|
+
return { w, h, rgba };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A PNG scaled down (each pixel the average of the ones it covers) to at most `width` pixels wide, as plain RGB. */
|
|
56
|
+
export function rgbThumb(png, width = 160) {
|
|
57
|
+
const img = decodePng(png);
|
|
58
|
+
const w = Math.max(1, Math.min(width, img.w));
|
|
59
|
+
const h = Math.max(1, Math.round((img.h * w) / img.w));
|
|
60
|
+
const rgb = Buffer.alloc(w * h * 3);
|
|
61
|
+
for (let y = 0; y < h; y++) {
|
|
62
|
+
const y0 = Math.floor((y * img.h) / h); const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * img.h) / h));
|
|
63
|
+
for (let x = 0; x < w; x++) {
|
|
64
|
+
const x0 = Math.floor((x * img.w) / w); const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * img.w) / w));
|
|
65
|
+
let r = 0; let g = 0; let b = 0; let n = 0;
|
|
66
|
+
for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) { const i = (yy * img.w + xx) * 4; r += img.rgba[i]; g += img.rgba[i + 1]; b += img.rgba[i + 2]; n++; }
|
|
67
|
+
const o = (y * w + x) * 3;
|
|
68
|
+
rgb[o] = Math.round(r / n); rgb[o + 1] = Math.round(g / n); rgb[o + 2] = Math.round(b / n);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { w, h, rgb };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The file name of a build's raw preview. */
|
|
75
|
+
export const thumbName = (build, w, h) => `${build}.preview.${w}x${h}.rgb`;
|
|
76
|
+
|
|
77
|
+
/** Remove a build's earlier raw previews (one is kept: the newest). */
|
|
78
|
+
export function dropThumbs(dir, build) {
|
|
79
|
+
let names = [];
|
|
80
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
81
|
+
for (const n of names) if (n.startsWith(`${build}.preview.`) && n.endsWith('.rgb')) rmSync(join(dir, n), { force: true });
|
|
82
|
+
}
|
package/mcp/ui/lab.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* The Game Lab card: one take of a game's mechanic, New (the working tree) beside Today (the last commit). A still of
|
|
2
|
+
* both at the take's busiest moment, the phases of each as a timing strip, whether replays match, the game's
|
|
3
|
+
* JavaScript per frame, and Open (the lab runs on this computer). */
|
|
4
|
+
(function () {
|
|
5
|
+
var C = Card;
|
|
6
|
+
var COLOURS = ['#a374ff', '#17c3b2', '#f5a524', '#ff6fb5', '#8bd346', '#4dd2ff', '#ffd84a', '#e58cff'];
|
|
7
|
+
function strip(rows, frames) {
|
|
8
|
+
// rows: { label, phases: [{ name, from, to }] }: blocks at their frames on the take's own axis; colours by name, shared.
|
|
9
|
+
var names = [];
|
|
10
|
+
rows.forEach(function (r) { (r.phases || []).forEach(function (p) { if (names.indexOf(p.name) < 0) names.push(p.name); }); });
|
|
11
|
+
var box = C.el('div');
|
|
12
|
+
box.style.cssText = 'display:grid;gap:6px;margin:12px 0 4px';
|
|
13
|
+
rows.forEach(function (r) {
|
|
14
|
+
var line = C.el('div');
|
|
15
|
+
line.style.cssText = 'display:grid;grid-template-columns:52px 1fr;gap:8px;align-items:center';
|
|
16
|
+
var label = C.el('span', '', r.label);
|
|
17
|
+
label.style.cssText = 'font:700 11px/1 var(--f-mono);letter-spacing:.08em;color:' + r.colour;
|
|
18
|
+
var bar = C.el('div');
|
|
19
|
+
bar.style.cssText = 'position:relative;height:20px;border-radius:4px;overflow:hidden;background:var(--c-bg2)';
|
|
20
|
+
(r.phases || []).forEach(function (p) {
|
|
21
|
+
var b = C.el('span', '', p.name);
|
|
22
|
+
var colour = COLOURS[names.indexOf(p.name) % COLOURS.length];
|
|
23
|
+
b.title = p.name + ': frames ' + p.from + ' to ' + p.to;
|
|
24
|
+
b.style.cssText = 'position:absolute;top:0;bottom:0;left:' + ((p.from - 1) / frames) * 100 + '%;width:calc(' + ((p.to - p.from + 1) / frames) * 100 + '% - 1px);background:' + colour + ';color:#0b0d14;font:700 10px/20px var(--f-mono);padding:0 4px;white-space:nowrap;overflow:hidden;text-overflow:clip';
|
|
25
|
+
bar.appendChild(b);
|
|
26
|
+
});
|
|
27
|
+
if (!(r.phases || []).length) { var e = C.el('span', 'fine', r.empty || 'no phases'); e.style.cssText = 'margin:0;line-height:20px;padding:0 8px'; bar.appendChild(e); }
|
|
28
|
+
line.appendChild(label); line.appendChild(bar);
|
|
29
|
+
box.appendChild(line);
|
|
30
|
+
});
|
|
31
|
+
return box;
|
|
32
|
+
}
|
|
33
|
+
function render(sc) {
|
|
34
|
+
var card = C.el('main', 'card');
|
|
35
|
+
var det = sc.deterministic || {};
|
|
36
|
+
var same = det.new === null && (det.today === null || det.today === undefined);
|
|
37
|
+
var head = sc.running ? C.pill('Checking', 'live', true) : same ? C.pill('Replays match', 'ok') : C.pill('Replays differ', 'bad');
|
|
38
|
+
C.add(card, C.top('Game Lab', C.add(C.el('div', 'actions'), head, C.fullscreenButton())), C.el('h1', '', (sc.name || sc.game) + (sc.take ? ' · ' + sc.take : '')),
|
|
39
|
+
C.el('p', 'sub', sc.running ? 'The lab is open on this computer; its first check is still running.' : 'New (the working tree) beside Today' + (sc.today ? ' (' + sc.today + ')' : '') + ' · ' + sc.frames + ' frames at ' + sc.fps + ' fps'));
|
|
40
|
+
if (sc.still) {
|
|
41
|
+
var box = C.el('div', 'preview');
|
|
42
|
+
var pic = C.img(sc.still, 'New beside Today at the take\'s busiest moment');
|
|
43
|
+
if (pic) { pic.style.maxHeight = 'none'; pic.style.objectFit = 'contain'; box.appendChild(pic); }
|
|
44
|
+
card.appendChild(box);
|
|
45
|
+
}
|
|
46
|
+
if (sc.timeline && sc.frames) {
|
|
47
|
+
card.appendChild(strip([
|
|
48
|
+
{ label: 'NEW', colour: '#e08a12', phases: sc.timeline.new },
|
|
49
|
+
{ label: 'TODAY', colour: '#2f8fd8', phases: sc.timeline.today || [], empty: sc.todayNote || 'no phases' },
|
|
50
|
+
], sc.frames));
|
|
51
|
+
card.appendChild(C.el('p', 'fine', 'Phases on the take\'s ' + sc.frames + ' frames (' + (sc.frames / sc.fps).toFixed(2) + ' s).'));
|
|
52
|
+
}
|
|
53
|
+
if (sc.cost && sc.cost.new) {
|
|
54
|
+
card.appendChild(C.el('p', 'fine', 'The game\'s JavaScript a frame, on average: New ' + sc.cost.new.mean + ' ms (p95 ' + sc.cost.new.p95 + ')' + (sc.cost.today ? ', Today ' + sc.cost.today.mean + ' ms (p95 ' + sc.cost.today.p95 + ')' : '') + '.'));
|
|
55
|
+
}
|
|
56
|
+
if (!same && !sc.running) card.appendChild(C.el('p', 'note bad', 'The same build played the take differently' + (det.new !== null ? ' (New from frame ' + det.new + ')' : '') + (det.today ? ' (Today from frame ' + det.today + ')' : '') + ': something in the game reads a clock or dice the lab does not drive.'));
|
|
57
|
+
(sc.errors || []).slice(0, 3).forEach(function (e) { card.appendChild(C.el('p', 'note bad', e)); });
|
|
58
|
+
var foot = C.el('div', 'foot');
|
|
59
|
+
foot.appendChild(C.el('span', 'when', 'Runs on this computer'));
|
|
60
|
+
var acts = C.el('div', 'actions');
|
|
61
|
+
if (sc.url) acts.appendChild(C.btn('Open the lab', '', function () { C.open(sc.url); C.tell('lab-open', 'The person opened the Game Lab for ' + (sc.name || sc.game) + ' from the card.'); }));
|
|
62
|
+
foot.appendChild(acts);
|
|
63
|
+
card.appendChild(foot);
|
|
64
|
+
return card;
|
|
65
|
+
}
|
|
66
|
+
C.start({ kind: 'lab', label: 'Game Lab', fullscreen: true, render: render, poll: null });
|
|
67
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./agents/agents.ts",
|
|
35
35
|
"default": "./agents/agents.ts"
|
|
36
36
|
},
|
|
37
|
+
"./lab": {
|
|
38
|
+
"types": "./lab/lab.ts",
|
|
39
|
+
"default": "./lab/lab.ts"
|
|
40
|
+
},
|
|
37
41
|
"./worker": "./worker/index.mjs",
|
|
38
42
|
"./package.json": "./package.json"
|
|
39
43
|
},
|
|
@@ -46,6 +50,7 @@
|
|
|
46
50
|
"mcp/",
|
|
47
51
|
"saves/",
|
|
48
52
|
"agents/",
|
|
53
|
+
"lab/",
|
|
49
54
|
"starters/",
|
|
50
55
|
"media/",
|
|
51
56
|
"site/",
|
package/port/index.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* jitter, engages, standoff
|
|
19
19
|
* the dial for the rest of a bot's decisions (port/skill.ts)
|
|
20
20
|
* createSaves player accounts and cloud saves (a character that lasts days, on every device; saves/SAVES.md)
|
|
21
|
+
* lab the Game Lab's calls (@homie-rocks/studio/lab): tracks, phases, tunables, overlays, views
|
|
21
22
|
* and everything from @homie-rocks/studio/netplay.
|
|
22
23
|
*/
|
|
23
24
|
import './early';
|
|
@@ -35,4 +36,5 @@ export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap,
|
|
|
35
36
|
export { createHud, type HudOptions } from './hud';
|
|
36
37
|
export { BotBrain, brainOf, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
|
|
37
38
|
export { jitter, engages, standoff } from './skill';
|
|
39
|
+
export { lab, type TunableSpec, type TunableFile, type Tuned } from '../lab/lab';
|
|
38
40
|
export { createSaves, SAVES_VERSION, type Saves, type SavesOptions, type Player, type SaveEntry, type SetResult, type Memorial, type SavesStatus, type Conflict } from '../saves/saves';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"v": 1,
|
|
3
|
+
"default": "strike",
|
|
4
|
+
"takes": {
|
|
5
|
+
"strike": {
|
|
6
|
+
"note": "Three strikes on a big training slime",
|
|
7
|
+
"seconds": 2.6,
|
|
8
|
+
"seed": 11,
|
|
9
|
+
"stage": "dummy",
|
|
10
|
+
"device": "desk",
|
|
11
|
+
"fps": 60,
|
|
12
|
+
"view": "close",
|
|
13
|
+
"track": "slime squash",
|
|
14
|
+
"storage": { "homie-saves.local.ember-vale": { "saves": { "hero": { "v": { "v": 1, "name": "Wren", "level": 1, "xp": 0, "gold": 0, "hardcore": false, "kills": 0, "deaths": 0, "born": 1767225600000 }, "ver": 1, "at": 1767225600000 } }, "stats": {}, "fallen": [] } },
|
|
15
|
+
"inputs": [
|
|
16
|
+
{ "at": 0.4, "key": "Space", "hold": 0.08 },
|
|
17
|
+
{ "at": 1.0, "key": "Space", "hold": 0.08 },
|
|
18
|
+
{ "at": 1.6, "key": "Space", "hold": 0.08 }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|