@homie-rocks/studio 0.49.0 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/homie-studio.mjs +5 -5
  3. package/functions/FUNCTIONS.md +71 -1
  4. package/guides/app/SKILL.md +130 -1
  5. package/guides/art/SKILL.md +154 -0
  6. package/guides/art/references/RIGHTS.md +20 -0
  7. package/guides/art/scripts/art.mjs +308 -0
  8. package/guides/models/SKILL.md +198 -0
  9. package/guides/models/references/RIGHTS.md +46 -0
  10. package/guides/models/references/models.json +175 -0
  11. package/guides/models/scripts/models.mjs +483 -0
  12. package/guides/music/SKILL.md +197 -0
  13. package/guides/music/references/COMPOSITION.md +59 -0
  14. package/guides/music/references/GAME-AUDIO.md +50 -0
  15. package/guides/music/references/RIGHTS.md +43 -0
  16. package/guides/music/scripts/lib/audio.mjs +258 -0
  17. package/guides/music/scripts/lib/eleven.mjs +260 -0
  18. package/guides/music/scripts/lib/studio.mjs +147 -0
  19. package/guides/music/scripts/music.mjs +644 -0
  20. package/guides/office/SKILL.md +99 -0
  21. package/guides/plan/SKILL.md +126 -0
  22. package/guides/plan/references/CODEX.md +105 -0
  23. package/guides/plan/references/INTERVIEW.md +86 -0
  24. package/guides/playtest/SKILL.md +205 -0
  25. package/guides/playtest/references/METHOD.md +124 -0
  26. package/guides/playtest/references/REVIEWER.md +78 -0
  27. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  28. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  29. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  30. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  31. package/guides/playtest/scripts/playtest.mjs +870 -0
  32. package/guides/servers/SKILL.md +111 -0
  33. package/guides/shop/SKILL.md +4 -0
  34. package/guides/sound/SKILL.md +156 -0
  35. package/guides/sound/references/GAME-SOUND.md +100 -0
  36. package/guides/sound/references/SCORE.md +131 -0
  37. package/guides/sound/references/TRAPS.md +93 -0
  38. package/guides/sound/scripts/lib/measure.mjs +252 -0
  39. package/guides/sound/scripts/lib/score.mjs +415 -0
  40. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  41. package/guides/sound/scripts/lib/synth.mjs +509 -0
  42. package/guides/sound/scripts/sound.mjs +520 -0
  43. package/guides/style/SKILL.md +105 -0
  44. package/lib/app-needs.mjs +63 -0
  45. package/lib/check.mjs +10 -4
  46. package/lib/codex.mjs +95 -18
  47. package/lib/decisions.mjs +9 -7
  48. package/lib/functions-build.mjs +10 -0
  49. package/lib/handoff.mjs +1 -1
  50. package/lib/mcp-tools.mjs +10 -9
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/scaffold.mjs +15 -7
  53. package/lib/studio.mjs +3 -3
  54. package/lib/template-history.json +84 -0
  55. package/lib/worker-config.mjs +1 -1
  56. package/package.json +1 -1
  57. package/shop/SHOP.md +4 -0
  58. package/shop/shop.ts +4 -4
  59. package/tools/TOOLS.md +21 -0
  60. package/tools/tools.ts +1 -1
  61. package/worker/build-id.mjs +1 -1
  62. package/worker/event-resources.mjs +116 -0
  63. package/worker/functions-schema.mjs +15 -0
  64. package/worker/functions.mjs +36 -22
  65. package/worker/index.mjs +23 -3
  66. package/worker/mcp-tools.mjs +14 -3
  67. package/worker/mcp.mjs +45 -8
  68. package/worker/shop-page.mjs +1 -1
  69. package/worker/shop.mjs +6 -2
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,128 @@
1
+ /**
2
+ * What a screenshot of a game shows, in numbers: brightness, contrast, colour, detail, flat dead
3
+ * areas, black frames, motion between two frames, and how much of the screen the page's own UI
4
+ * covers. ffmpeg decodes the PNG; plain JavaScript does the counting.
5
+ *
6
+ * Why these: a black or flat world is the most common way a game "works" in a test and fails on
7
+ * a person's screen (a shader that did not compile, a camera inside a wall, a world that never
8
+ * loaded for a late joiner). Edge density separates a finished-looking frame from a flat-shaded
9
+ * placeholder better than brightness or a histogram does; a flat, featureless frame passes those.
10
+ */
11
+ import { spawnSync } from 'node:child_process';
12
+
13
+ /**
14
+ * How long one PNG decode may take. ffmpeg decodes a phone screenshot in well under a second; a decoder that is
15
+ * still running after this is stuck (one sat for over five minutes with the game drawing fine behind it), and a
16
+ * `spawnSync` with no bound holds the whole controller in it: no report, no closed browser. HOMIE_PLAYTEST_DECODE_MS
17
+ * sets another bound (a very slow disk, or a test that wants it short).
18
+ */
19
+ export const DECODE_TIMEOUT_MS = Math.max(200, Number(process.env.HOMIE_PLAYTEST_DECODE_MS) || 20_000);
20
+
21
+ /**
22
+ * The instrument could not read its own picture: nothing about the game was measured. Callers report the row
23
+ * BLOCKED (never FAIL, never PASS) and close their browser; `blocked` is how they tell it from a game fault.
24
+ */
25
+ export class DecodeBlocked extends Error {
26
+ constructor(message) { super(message); this.name = 'DecodeBlocked'; this.blocked = true; }
27
+ }
28
+
29
+ /**
30
+ * Decode a PNG (a Buffer or a path) to RGB at `width` pixels wide. Returns { w, h, rgb }. Bounded: after
31
+ * `timeoutMs` the decoder is killed and a DecodeBlocked is thrown. `bin` is the decoder (a test puts a stuck one here).
32
+ */
33
+ export function decode(png, width = 480, { timeoutMs = DECODE_TIMEOUT_MS, bin = process.env.HOMIE_PLAYTEST_FFMPEG || 'ffmpeg' } = {}) {
34
+ const input = Buffer.isBuffer(png) ? ['-f', 'png_pipe', '-i', '-'] : ['-i', png];
35
+ const probe = spawnSync(bin, ['-hide_banner', '-loglevel', 'error', ...input, '-vf', `scale=${width}:-2:flags=area`, '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { input: Buffer.isBuffer(png) ? png : undefined, maxBuffer: 256 * 1024 * 1024, timeout: timeoutMs, killSignal: 'SIGKILL' });
36
+ // spawnSync reports its own timeout as error.code ETIMEDOUT (and the child killed by the signal above).
37
+ if (probe.error?.code === 'ETIMEDOUT') throw new DecodeBlocked(`the screenshot decoder (ffmpeg) did not finish in ${Math.round(timeoutMs / 1000)} s and was stopped: the picture was not measured`);
38
+ if (probe.error?.code === 'ENOENT') throw new DecodeBlocked('ffmpeg is not installed: screenshots cannot be measured on this computer');
39
+ if (probe.status !== 0) throw new Error(`ffmpeg could not decode a screenshot: ${String(probe.stderr).trim().split('\n').pop()}`);
40
+ const rgb = probe.stdout;
41
+ const h = Math.round(rgb.length / 3 / width);
42
+ return { w: width, h, rgb };
43
+ }
44
+
45
+ const lumaAt = (rgb, i) => 0.2126 * rgb[i] + 0.7152 * rgb[i + 1] + 0.0722 * rgb[i + 2];
46
+
47
+ /**
48
+ * { mean, sd, saturation, edges, darkShare, flatDarkShare, black, flat }
49
+ * mean/sd Rec.709 luma 0..255 and its spread (contrast)
50
+ * saturation mean HSV saturation 0..1
51
+ * edges share of pixels whose 3x3 luma range is over 24 (visible detail)
52
+ * darkShare share of pixels under luma 16
53
+ * flatBlackShare share of 8x8 blocks that are perfectly flat (range <= 2) and black (mean < 5): a hole where
54
+ * nothing drew (a shader that failed, a world that never loaded, the clear colour showing through)
55
+ * flatDarkShare the same for dark (mean < 24): a plain dark backdrop, or an unlit world
56
+ * black the frame is essentially black (mean < 8 and sd < 6); flat: sd < 4 (one colour)
57
+ * `crop` { x, y, w, h } in fractions measures only part of the frame (the play area without the HUD).
58
+ */
59
+ export function stats(img, { crop = null } = {}) {
60
+ const { w, h, rgb } = img;
61
+ const x0 = crop ? Math.floor(crop.x * w) : 0; const y0 = crop ? Math.floor(crop.y * h) : 0;
62
+ const x1 = crop ? Math.min(w, Math.ceil((crop.x + crop.w) * w)) : w; const y1 = crop ? Math.min(h, Math.ceil((crop.y + crop.h) * h)) : h;
63
+ const lum = new Float32Array(w * h);
64
+ for (let i = 0, p = 0; i < w * h; i++, p += 3) lum[i] = lumaAt(rgb, p);
65
+ let n = 0; let sum = 0; let sum2 = 0; let sat = 0; let dark = 0; let edges = 0;
66
+ for (let y = y0; y < y1; y++) {
67
+ for (let x = x0; x < x1; x++) {
68
+ const i = y * w + x; const p = i * 3;
69
+ const l = lum[i];
70
+ n++; sum += l; sum2 += l * l;
71
+ const mx = Math.max(rgb[p], rgb[p + 1], rgb[p + 2]); const mn = Math.min(rgb[p], rgb[p + 1], rgb[p + 2]);
72
+ sat += mx > 0 ? (mx - mn) / mx : 0;
73
+ if (l < 16) dark++;
74
+ if (x > x0 && y > y0 && x < x1 - 1 && y < y1 - 1) {
75
+ let lo = 255; let hi = 0;
76
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const v = lum[i + dy * w + dx]; if (v < lo) lo = v; if (v > hi) hi = v; }
77
+ if (hi - lo > 24) edges++;
78
+ }
79
+ }
80
+ }
81
+ let blocks = 0; let flatDark = 0; let flatBlack = 0;
82
+ for (let by = y0; by + 8 <= y1; by += 8) {
83
+ for (let bx = x0; bx + 8 <= x1; bx += 8) {
84
+ let lo = 255; let hi = 0; let s = 0;
85
+ for (let y = by; y < by + 8; y++) for (let x = bx; x < bx + 8; x++) { const v = lum[y * w + x]; s += v; if (v < lo) lo = v; if (v > hi) hi = v; }
86
+ blocks++;
87
+ if (hi - lo <= 2 && s / 64 < 24) flatDark++;
88
+ if (hi - lo <= 2 && s / 64 < 5) flatBlack++;
89
+ }
90
+ }
91
+ const mean = sum / Math.max(1, n);
92
+ const sd = Math.sqrt(Math.max(0, sum2 / Math.max(1, n) - mean * mean));
93
+ const r = (v, p = 3) => +v.toFixed(p);
94
+ return { mean: r(mean, 1), sd: r(sd, 1), saturation: r(sat / Math.max(1, n)), edges: r(edges / Math.max(1, n)), darkShare: r(dark / Math.max(1, n)), flatDarkShare: r(flatDark / Math.max(1, blocks)), flatBlackShare: r(flatBlack / Math.max(1, blocks)), black: mean < 8 && sd < 6, flat: sd < 4 };
95
+ }
96
+
97
+ /** Share of pixels that changed by more than `threshold` luma between two frames of the same size. */
98
+ export function motion(a, b, threshold = 12) {
99
+ if (a.w !== b.w || a.h !== b.h) return null;
100
+ let changed = 0; const n = a.w * a.h;
101
+ for (let i = 0, p = 0; i < n; i++, p += 3) if (Math.abs(lumaAt(a.rgb, p) - lumaAt(b.rgb, p)) > threshold) changed++;
102
+ return +(changed / n).toFixed(4);
103
+ }
104
+
105
+ /**
106
+ * How much of the screen the page's UI covers, from two screenshots taken with the world hidden and the
107
+ * page's background black (a) and then white (b): a pixel the UI does not cover shows the background, so
108
+ * it flips; a pixel under an opaque panel stays. alpha = 1 - |b - a| / 255.
109
+ * Returns { cover (alpha > 0.25), opaque (alpha > 0.6), centreOpaque (opaque share of the middle third) }.
110
+ */
111
+ export function uiCover(onBlack, onWhite) {
112
+ if (onBlack.w !== onWhite.w || onBlack.h !== onWhite.h) throw new Error('the two UI screenshots differ in size');
113
+ const { w, h } = onBlack;
114
+ let cover = 0; let opaque = 0; let centre = 0; let centreOpaque = 0;
115
+ for (let y = 0; y < h; y++) {
116
+ for (let x = 0; x < w; x++) {
117
+ const p = (y * w + x) * 3;
118
+ const d = Math.max(Math.abs(onWhite.rgb[p] - onBlack.rgb[p]), Math.abs(onWhite.rgb[p + 1] - onBlack.rgb[p + 1]), Math.abs(onWhite.rgb[p + 2] - onBlack.rgb[p + 2]));
119
+ const alpha = 1 - d / 255;
120
+ const inCentre = x >= w / 3 && x < (2 * w) / 3 && y >= h / 3 && y < (2 * h) / 3;
121
+ if (alpha > 0.25) cover++;
122
+ if (alpha > 0.6) { opaque++; if (inCentre) centreOpaque++; }
123
+ if (inCentre) centre++;
124
+ }
125
+ }
126
+ const n = w * h;
127
+ return { cover: +(cover / n).toFixed(3), opaque: +(opaque / n).toFixed(3), centreOpaque: +(centreOpaque / Math.max(1, centre)).toFixed(3) };
128
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Can THIS process reach the site, before any browser opens? Chrome and Node do not resolve names the same way (a
3
+ * browser may use DNS over HTTPS, its own cache or a system resolver Node's does not consult), so a public studio can
4
+ * load in Chrome while Node's fetch cannot even find the host. That says nothing about the game: it is a network
5
+ * preflight failure of the instrument, reported BLOCKED with the way round it (test against the local site).
6
+ *
7
+ * { ok: true }
8
+ * { ok: false, kind: 'dns' | 'refused' | 'timeout' | 'http' | 'network', verdict: 'BLOCKED' | 'FAIL', why }
9
+ *
10
+ * `dns`, `timeout` and `network` are BLOCKED: nothing was measured. `refused` (nothing listens there) and `http`
11
+ * (the site answered, and not with the play page) are FAIL: the address itself is wrong or the site is down.
12
+ */
13
+ import { lookup as dnsLookup } from 'node:dns/promises';
14
+ import { isIP } from 'node:net';
15
+
16
+ /*
17
+ * ONE CLASSIFICATION, ONE SENTENCE. The studio's own commands (check, perf, shoot, port check, a deploy's read-back)
18
+ * classify the same failure in @homie-rocks/studio's lib/net.mjs. This script cannot import that file (it runs against
19
+ * whatever version the studio has installed), so the list of codes that mean "this computer could not look the name
20
+ * up" and the words said for it are kept the same here by hand, and a test feeds both sides the same errors and
21
+ * fails when either drifts.
22
+ */
23
+ export const DNS_CODES = ['ENOTFOUND', 'EAI_AGAIN', 'EAI_NODATA', 'EAI_NONAME', 'ESERVFAIL'];
24
+ export const LOCAL_INSTEAD = 'To test without this computer\'s network in the way: run the site here (`npx --no-install homie-studio dev`, as a background task) and give the command --url http://127.0.0.1:8787 (or leave --url out where the command finds the dev site itself).';
25
+ const HEAD = 'network preflight failed, before any page or game was opened';
26
+
27
+ /** Every code on an error and its causes (Node's fetch wraps the real one: TypeError → cause → code). */
28
+ export function errorCodes(error) {
29
+ const out = [];
30
+ for (let e = error, i = 0; e && i < 6; e = e.cause, i++) { if (e.code) out.push(String(e.code)); if (Array.isArray(e.errors)) for (const x of e.errors) if (x?.code) out.push(String(x.code)); }
31
+ return out;
32
+ }
33
+
34
+ /** One failed request, classified from its error (no network needed: the pure half, and what a test injects). */
35
+ export function classifyNetError(error, host) {
36
+ const codes = errorCodes(error);
37
+ const has = (...c) => c.some((x) => codes.includes(x));
38
+ if (has(...DNS_CODES)) {
39
+ return { kind: 'dns', verdict: 'BLOCKED', why: `${HEAD}: this computer's Node.js could not look up ${host} (${codes.find((c) => DNS_CODES.includes(c))}). A browser on the same computer may still open ${host} (browsers can use their own DNS), so this says nothing about the site or the game: it is this computer's name lookup. Nothing was measured. ${LOCAL_INSTEAD}` };
40
+ }
41
+ if (has('ECONNREFUSED')) return { kind: 'refused', verdict: 'FAIL', why: `${host} does not answer (nothing is listening there): start the site (npm run dev, as a background task that outlives this command) or check the address` };
42
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError' || has('ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT')) {
43
+ return { kind: 'timeout', verdict: 'BLOCKED', why: `${HEAD}: no answer from ${host} in time. That is the connection from this computer to ${host}, not a result about the game. Nothing was measured. ${LOCAL_INSTEAD}` };
44
+ }
45
+ return { kind: 'network', verdict: 'BLOCKED', why: `${HEAD}: the request to ${host} failed (${codes[0] ?? error?.message ?? 'no reason given'}). That is the connection from this computer to ${host}, not a result about the game. Nothing was measured. ${LOCAL_INSTEAD}` };
46
+ }
47
+
48
+ /**
49
+ * Resolve the host, then fetch `url`. `lookup` and `fetcher` are injectable (a test injects a resolver that fails).
50
+ * An IP address or localhost skips the lookup.
51
+ */
52
+ export async function preflight(url, { lookup = dnsLookup, fetcher = fetch, timeoutMs = 10_000 } = {}) {
53
+ let host;
54
+ try { host = new URL(url).hostname; } catch { return { ok: false, kind: 'http', verdict: 'FAIL', why: `${url} is not an address` }; }
55
+ const bare = host.replace(/^\[|\]$/g, '');
56
+ if (!isIP(bare) && bare !== 'localhost' && !bare.endsWith('.localhost')) {
57
+ try { await lookup(bare); } catch (error) { return { ok: false, host, ...classifyNetError(error, host) }; }
58
+ }
59
+ try {
60
+ const r = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
61
+ if (r.ok) return { ok: true, host };
62
+ return { ok: false, host, kind: 'http', verdict: 'FAIL', why: `${url} answered ${r.status}: start the site (npm run dev, as a background task that outlives this command) or check the address and the game id` };
63
+ } catch (error) {
64
+ // This computer's own address has no name to resolve and no network in between: whatever went wrong, the local
65
+ // site is not answering, and "try the local site instead" would be no help at all.
66
+ if (/^(localhost|.*\.localhost|127\..*|::1|0\.0\.0\.0)$/.test(bare)) return { ok: false, host, kind: 'refused', verdict: 'FAIL', why: `${url} does not answer (${errorCodes(error)[0] ?? error?.cause?.message ?? error?.message ?? 'no reason given'}): start the site (npm run dev, as a background task that outlives this command) or check the address` };
67
+ return { ok: false, host, ...classifyNetError(error, host) };
68
+ }
69
+ }