@homie-rocks/studio 0.19.1 → 0.20.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 +927 -0
- package/README.md +49 -3
- package/bin/homie-studio.mjs +57 -2
- 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/changelog.mjs +204 -0
- package/lib/jobs.mjs +3 -2
- package/lib/lab-check.mjs +212 -0
- package/lib/lab.mjs +546 -0
- package/lib/mcp-tools.mjs +72 -6
- package/lib/mcp.mjs +4 -2
- package/lib/scaffold.mjs +7 -1
- package/lib/studio.mjs +14 -0
- package/lib/template-history.json +74 -0
- package/lib/upgrade.mjs +9 -1
- package/mcp/ui/card.css +2 -0
- package/mcp/ui/lab.js +67 -0
- package/mcp/ui/studio.js +9 -1
- package/package.json +7 -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/lab/take.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A take: one short, repeatable piece of play the Game Lab shows in both builds (games/<id>/lab.json). The same file
|
|
3
|
+
* is read by the lab page (served as it is) and by `homie-studio lab` in Node.
|
|
4
|
+
*
|
|
5
|
+
* {
|
|
6
|
+
* "v": 1,
|
|
7
|
+
* "default": "knock",
|
|
8
|
+
* "takes": {
|
|
9
|
+
* "knock": {
|
|
10
|
+
* "note": "Walk right into Rook and bump him",
|
|
11
|
+
* "seconds": 3, // how long the take is
|
|
12
|
+
* "seed": 7, // the world's dice (Math.random) in both builds
|
|
13
|
+
* "device": "desk", // desk (1280x800) or phone (390x844)
|
|
14
|
+
* "fps": 60, // 60, 30, 15 or 12 frames a second
|
|
15
|
+
* "stage": "dummy", // a scene the game sets up for this mechanic (lab.stage; optional)
|
|
16
|
+
* "view": "close", // one of the game's lab.camera presets (optional)
|
|
17
|
+
* "overlays": ["onion"], // overlays on at the start (optional)
|
|
18
|
+
* "track": "speed", // the graph shown first (optional)
|
|
19
|
+
* "storage": { "key": "value" }, // localStorage at the start (a saved hero, a setting)
|
|
20
|
+
* "inputs": [
|
|
21
|
+
* { "at": 0.1, "key": "ArrowRight", "hold": 0.6 }, // pressed at 0.1 s, held 0.6 s
|
|
22
|
+
* { "at": 0.9, "key": "Space" }, // a tap
|
|
23
|
+
* { "at": 1.0, "down": "KeyW" }, { "at": 1.4, "up": "KeyW" },
|
|
24
|
+
* { "at": 1.5, "pointer": "down", "x": 120, "y": 600, "id": 2, "pt": "touch" }
|
|
25
|
+
* ]
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* Times are seconds from the take's start; x and y are CSS pixels of the game's page at the take's device size.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const DEVICES = Object.freeze({
|
|
34
|
+
desk: { w: 1280, h: 800, dpr: 1, label: 'Desk' },
|
|
35
|
+
phone: { w: 390, h: 844, dpr: 2, label: 'Phone' },
|
|
36
|
+
});
|
|
37
|
+
export const FPS = Object.freeze([60, 30, 15, 12]);
|
|
38
|
+
export const SCALES = Object.freeze([1, 0.5, 0.25, 0.1]);
|
|
39
|
+
export const LIMITS = Object.freeze({ seconds: 60, inputs: 5000, takes: 24, name: 32 });
|
|
40
|
+
export const TAKE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
41
|
+
|
|
42
|
+
const num = (v, d) => (Number.isFinite(Number(v)) ? Number(v) : d);
|
|
43
|
+
const r4 = (v) => Math.round(v * 10000) / 10000;
|
|
44
|
+
|
|
45
|
+
/** What is wrong with a take, in words (empty: nothing). */
|
|
46
|
+
export function checkTake(take, name = 'take') {
|
|
47
|
+
const out = [];
|
|
48
|
+
if (!take || typeof take !== 'object' || Array.isArray(take)) return [`${name} is not an object`];
|
|
49
|
+
const s = Number(take.seconds);
|
|
50
|
+
if (!(s > 0 && s <= LIMITS.seconds)) out.push(`${name}.seconds must be between 0 and ${LIMITS.seconds}`);
|
|
51
|
+
if (take.fps !== undefined && !FPS.includes(Number(take.fps))) out.push(`${name}.fps must be one of ${FPS.join(', ')}`);
|
|
52
|
+
if (take.device !== undefined && !Object.hasOwn(DEVICES, take.device)) out.push(`${name}.device must be desk or phone`);
|
|
53
|
+
if (take.seed !== undefined && !(Number.isInteger(Number(take.seed)) && Number(take.seed) >= 0)) out.push(`${name}.seed must be a whole number`);
|
|
54
|
+
if (take.stage !== undefined && !(typeof take.stage === 'string' && /^[a-z0-9][a-z0-9-]{0,31}$/.test(take.stage))) out.push(`${name}.stage is a short lowercase name`);
|
|
55
|
+
if (take.storage !== undefined && (typeof take.storage !== 'object' || Array.isArray(take.storage) || take.storage === null)) out.push(`${name}.storage must be an object of keys`);
|
|
56
|
+
const inputs = take.inputs ?? [];
|
|
57
|
+
if (!Array.isArray(inputs)) out.push(`${name}.inputs must be a list`);
|
|
58
|
+
else {
|
|
59
|
+
if (inputs.length > LIMITS.inputs) out.push(`${name} has ${inputs.length} inputs (at most ${LIMITS.inputs})`);
|
|
60
|
+
inputs.forEach((row, i) => {
|
|
61
|
+
const at = Number(row?.at);
|
|
62
|
+
if (!(at >= 0)) { out.push(`${name}.inputs[${i}].at must be seconds from the start`); return; }
|
|
63
|
+
const kinds = ['key', 'down', 'up', 'pointer'].filter((k) => row[k] !== undefined);
|
|
64
|
+
if (kinds.length !== 1) out.push(`${name}.inputs[${i}] needs exactly one of key, down, up or pointer`);
|
|
65
|
+
else if (kinds[0] === 'pointer') {
|
|
66
|
+
if (!['down', 'move', 'up', 'cancel'].includes(row.pointer)) out.push(`${name}.inputs[${i}].pointer is down, move, up or cancel`);
|
|
67
|
+
if (!Number.isFinite(Number(row.x)) || !Number.isFinite(Number(row.y))) out.push(`${name}.inputs[${i}] needs x and y`);
|
|
68
|
+
} else if (typeof row[kinds[0]] !== 'string' || !/^[A-Za-z0-9]{1,24}$/.test(row[kinds[0]])) out.push(`${name}.inputs[${i}].${kinds[0]} is a KeyboardEvent code such as ArrowRight, KeyW or Space`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every take of a lab file, checked: { takes, default, problems }. */
|
|
75
|
+
export function readLabFile(json) {
|
|
76
|
+
const problems = [];
|
|
77
|
+
const takes = {};
|
|
78
|
+
if (!json || typeof json !== 'object') return { takes, default: null, problems: ['lab.json is not an object'] };
|
|
79
|
+
const all = json.takes && typeof json.takes === 'object' ? json.takes : {};
|
|
80
|
+
for (const [name, take] of Object.entries(all).slice(0, LIMITS.takes)) {
|
|
81
|
+
if (!TAKE_NAME.test(name)) { problems.push(`take "${name}": a name is lowercase letters, digits and hyphens`); continue; }
|
|
82
|
+
const p = checkTake(take, name);
|
|
83
|
+
if (p.length) problems.push(...p); else takes[name] = take;
|
|
84
|
+
}
|
|
85
|
+
const names = Object.keys(takes);
|
|
86
|
+
return { takes, default: names.includes(json.default) ? json.default : names[0] ?? null, problems };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** How many frames a take has at its frame rate. */
|
|
90
|
+
export function framesOf(take, fps = take?.fps ?? 60) { return Math.max(1, Math.round(num(take?.seconds, 3) * fps)); }
|
|
91
|
+
|
|
92
|
+
/** A take as the harness plays it: inputs as events in ms, in order. */
|
|
93
|
+
export function expandTake(take, { fps } = {}) {
|
|
94
|
+
const f = FPS.includes(Number(fps)) ? Number(fps) : FPS.includes(Number(take.fps)) ? Number(take.fps) : 60;
|
|
95
|
+
const events = [];
|
|
96
|
+
for (const row of take.inputs ?? []) {
|
|
97
|
+
const at = num(row.at, 0) * 1000;
|
|
98
|
+
if (row.key !== undefined) {
|
|
99
|
+
events.push({ at, type: 'key', code: row.key, down: true });
|
|
100
|
+
events.push({ at: at + Math.max(1000 / f, num(row.hold, 0.05) * 1000), type: 'key', code: row.key, down: false });
|
|
101
|
+
} else if (row.down !== undefined) events.push({ at, type: 'key', code: row.down, down: true });
|
|
102
|
+
else if (row.up !== undefined) events.push({ at, type: 'key', code: row.up, down: false });
|
|
103
|
+
else if (row.pointer !== undefined) events.push({ at, type: 'pointer', ev: row.pointer, x: num(row.x, 0), y: num(row.y, 0), id: Math.max(1, Math.round(num(row.id, 1))), pt: row.pt === 'mouse' || row.pt === 'pen' ? row.pt : 'touch' });
|
|
104
|
+
}
|
|
105
|
+
// Stable: two events at one time keep the file's order.
|
|
106
|
+
const order = events.map((e, i) => [e, i]).sort((a, b) => a[0].at - b[0].at || a[1] - b[1]).map(([e]) => e);
|
|
107
|
+
return {
|
|
108
|
+
fps: f,
|
|
109
|
+
frames: framesOf(take, f),
|
|
110
|
+
seed: Number.isInteger(Number(take.seed)) ? Number(take.seed) : 1,
|
|
111
|
+
device: Object.hasOwn(DEVICES, take.device) ? take.device : 'desk',
|
|
112
|
+
stage: typeof take.stage === 'string' ? take.stage : null,
|
|
113
|
+
view: typeof take.view === 'string' ? take.view : null,
|
|
114
|
+
overlays: Array.isArray(take.overlays) ? take.overlays.map(String) : [],
|
|
115
|
+
track: typeof take.track === 'string' ? take.track : null,
|
|
116
|
+
storage: take.storage && typeof take.storage === 'object' ? take.storage : null,
|
|
117
|
+
inputs: order,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What the harness recorded (events in ms) as a take's inputs (seconds): a key pressed and let go becomes one row
|
|
123
|
+
* with its hold; anything else stays an edge.
|
|
124
|
+
*/
|
|
125
|
+
export function inputsFromRecording(events) {
|
|
126
|
+
const rows = [];
|
|
127
|
+
const open = new Map();
|
|
128
|
+
for (const e of events) {
|
|
129
|
+
const at = r4(num(e.at, 0) / 1000);
|
|
130
|
+
if (e.type === 'key' && e.down) { const row = { at, down: e.code }; open.set(e.code, row); rows.push(row); }
|
|
131
|
+
else if (e.type === 'key') {
|
|
132
|
+
const d = open.get(e.code);
|
|
133
|
+
if (d) { open.delete(e.code); delete d.down; d.key = e.code; d.hold = r4(at - d.at); } else rows.push({ at, up: e.code });
|
|
134
|
+
} else if (e.type === 'pointer') rows.push({ at, pointer: e.ev, x: e.x, y: e.y, id: e.id, pt: e.pt });
|
|
135
|
+
}
|
|
136
|
+
return rows.map((r) => (r.key !== undefined ? { at: r.at, key: r.key, hold: r.hold } : r));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** lab.json as text: readable, one input to a line. */
|
|
140
|
+
export function formatLabFile(json) {
|
|
141
|
+
const out = ['{', ` "v": 1,`, ` "default": ${JSON.stringify(json.default ?? null)},`, ' "takes": {'];
|
|
142
|
+
const names = Object.keys(json.takes ?? {});
|
|
143
|
+
names.forEach((name, i) => {
|
|
144
|
+
const t = json.takes[name];
|
|
145
|
+
const keys = Object.keys(t).filter((k) => k !== 'inputs');
|
|
146
|
+
out.push(` ${JSON.stringify(name)}: {`);
|
|
147
|
+
const lines = keys.map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(t[k])}`);
|
|
148
|
+
const inputs = Array.isArray(t.inputs) ? t.inputs : [];
|
|
149
|
+
lines.push(` "inputs": [${inputs.length ? `\n${inputs.map((r) => ` ${JSON.stringify(r)}`).join(',\n')}\n ` : ''}]`);
|
|
150
|
+
out.push(lines.join(',\n'));
|
|
151
|
+
out.push(` }${i < names.length - 1 ? ',' : ''}`);
|
|
152
|
+
});
|
|
153
|
+
out.push(' }', '}');
|
|
154
|
+
return `${out.join('\n')}\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** tunables.json as text: one tunable to a line, so a kept change is a one-line diff. */
|
|
158
|
+
export function formatTunables(spec) {
|
|
159
|
+
const names = Object.keys(spec ?? {});
|
|
160
|
+
if (!names.length) return '{}\n';
|
|
161
|
+
const one = (s) => (s && typeof s === 'object' && !Array.isArray(s) ? `{ ${Object.entries(s).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ')} }` : JSON.stringify(s));
|
|
162
|
+
return `{\n${names.map((k, i) => ` ${JSON.stringify(k)}: ${one(spec[k])}${i < names.length - 1 ? ',' : ''}`).join('\n')}\n}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A tunable's value (a bare number, or { value }). */
|
|
166
|
+
export function tunableValue(s) { return typeof s === 'number' ? s : num(s?.value, NaN); }
|
|
167
|
+
|
|
168
|
+
/** tunables.json with new values (by name): what is unknown or not a number is refused, ranges are kept. */
|
|
169
|
+
export function setTunables(spec, values) {
|
|
170
|
+
const next = JSON.parse(JSON.stringify(spec ?? {}));
|
|
171
|
+
const changed = [];
|
|
172
|
+
const refused = [];
|
|
173
|
+
for (const [k, raw] of Object.entries(values ?? {})) {
|
|
174
|
+
const v = Number(raw);
|
|
175
|
+
if (!Object.hasOwn(next, k)) { refused.push(`${k}: not in tunables.json`); continue; }
|
|
176
|
+
if (!Number.isFinite(v)) { refused.push(`${k}: ${JSON.stringify(raw)} is not a number`); continue; }
|
|
177
|
+
const s = next[k];
|
|
178
|
+
const before = tunableValue(s);
|
|
179
|
+
if (typeof s === 'number') next[k] = v;
|
|
180
|
+
else {
|
|
181
|
+
s.value = v;
|
|
182
|
+
// A value past the slider's end moves the end: the file says what was kept, and the slider still reaches it.
|
|
183
|
+
if (Number.isFinite(s.min) && v < s.min) s.min = v;
|
|
184
|
+
if (Number.isFinite(s.max) && v > s.max) s.max = v;
|
|
185
|
+
}
|
|
186
|
+
if (before !== v) changed.push({ name: k, from: before, to: v });
|
|
187
|
+
}
|
|
188
|
+
return { spec: next, changed, refused };
|
|
189
|
+
}
|
package/lib/build.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import { vocabularyOf } from '../worker/brain.mjs';
|
|
|
46
46
|
|
|
47
47
|
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
48
48
|
/** Never copied into a static game's served folder. */
|
|
49
|
-
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md']);
|
|
49
|
+
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json']);
|
|
50
50
|
const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
|
|
51
51
|
|
|
52
52
|
/** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
|
|
@@ -222,10 +222,66 @@ function keepMap(root, id, out, metafile) {
|
|
|
222
222
|
writeFileSync(join(dir, 'main.js.sha256'), `${createHash('sha256').update(readFileSync(join(out, 'assets', 'main.js'))).digest('hex')}\n`);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
/** esbuild, as the studio has it installed (the version its package.json pins). */
|
|
226
|
+
export async function studioEsbuild(root) {
|
|
226
227
|
const require = createRequire(join(root, 'package.json'));
|
|
227
|
-
|
|
228
|
-
|
|
228
|
+
try { return require('esbuild'); } catch { return import('esbuild'); }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* One game's own files into `out` (emptied first): its bundle, static copy or own build's output, its index.html and
|
|
233
|
+
* its public/ folder. What `build` serves at /games/<id>/, and what the Game Lab (lib/lab.mjs) builds New and Today
|
|
234
|
+
* with. `sourcemap` is esbuild's (the lab keeps a linked map beside its builds); `maps` keeps the site build's map in
|
|
235
|
+
* .studio/maps/<id>/. Returns { mode, warnings, metafile } (metafile: the bundle's inputs, null for other modes).
|
|
236
|
+
*/
|
|
237
|
+
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {} } = {}) {
|
|
238
|
+
rmSync(out, { recursive: true, force: true });
|
|
239
|
+
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
240
|
+
const mode = g.build?.mode ?? 'bundle';
|
|
241
|
+
let warnings = 0;
|
|
242
|
+
let metafile = null;
|
|
243
|
+
const bundle = async (entryRel) => {
|
|
244
|
+
const entry = join(g.dir, entryRel);
|
|
245
|
+
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
246
|
+
const result = await esbuild.build({
|
|
247
|
+
entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? (maps ? 'external' : false),
|
|
248
|
+
outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
|
|
249
|
+
loader: LOADERS, assetNames: '[name]-[hash]',
|
|
250
|
+
}).catch((error) => {
|
|
251
|
+
const first = error.errors?.[0];
|
|
252
|
+
throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
|
|
253
|
+
});
|
|
254
|
+
warnings += result.warnings.length;
|
|
255
|
+
metafile = result.metafile;
|
|
256
|
+
if (maps) keepMap(root, g.id, out, result.metafile);
|
|
257
|
+
};
|
|
258
|
+
if (mode === 'static') {
|
|
259
|
+
copyStatic(g.dir, out);
|
|
260
|
+
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
261
|
+
if (g.entry) await bundle(g.entry);
|
|
262
|
+
const html = readFileSync(join(out, 'index.html'), 'utf8');
|
|
263
|
+
if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
|
|
264
|
+
} else if (mode === 'command') {
|
|
265
|
+
const command = String(g.build.command ?? 'npm run build');
|
|
266
|
+
const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
267
|
+
if (res.status !== 0) throw new Error(`games/${g.id}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
|
|
268
|
+
const built = join(g.dir, String(g.build.out ?? 'dist'));
|
|
269
|
+
if (!existsSync(join(built, 'index.html'))) throw new Error(`games/${g.id}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
|
|
270
|
+
cpSync(built, out, { recursive: true });
|
|
271
|
+
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
272
|
+
} else {
|
|
273
|
+
await bundle(g.entry ?? 'src/main.ts');
|
|
274
|
+
const html = join(g.dir, 'index.html');
|
|
275
|
+
if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
|
|
276
|
+
writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
|
|
277
|
+
}
|
|
278
|
+
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
279
|
+
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
280
|
+
return { mode, warnings, metafile };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false } = {}) {
|
|
284
|
+
const esbuild = await studioEsbuild(root);
|
|
229
285
|
const studio = readStudio(root);
|
|
230
286
|
const dist = join(root, 'site', 'dist');
|
|
231
287
|
const games = listGames(root).filter((g) => !only || g.id === only);
|
|
@@ -236,47 +292,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
236
292
|
const cache = {};
|
|
237
293
|
for (const g of games) {
|
|
238
294
|
const out = join(dist, 'games', g.id);
|
|
239
|
-
rmSync(out, { recursive: true, force: true });
|
|
240
|
-
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
241
|
-
const mode = g.build?.mode ?? 'bundle';
|
|
242
295
|
const started = Date.now();
|
|
243
|
-
|
|
244
|
-
const bundle = async (entryRel) => {
|
|
245
|
-
const entry = join(g.dir, entryRel);
|
|
246
|
-
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
247
|
-
const result = await esbuild.build({
|
|
248
|
-
entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: maps ? 'external' : false,
|
|
249
|
-
outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
|
|
250
|
-
loader: LOADERS, assetNames: '[name]-[hash]',
|
|
251
|
-
}).catch((error) => {
|
|
252
|
-
const first = error.errors?.[0];
|
|
253
|
-
throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
|
|
254
|
-
});
|
|
255
|
-
warnings += result.warnings.length;
|
|
256
|
-
if (maps) keepMap(root, g.id, out, result.metafile);
|
|
257
|
-
};
|
|
258
|
-
if (mode === 'static') {
|
|
259
|
-
copyStatic(g.dir, out);
|
|
260
|
-
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
261
|
-
if (g.entry) await bundle(g.entry);
|
|
262
|
-
const html = readFileSync(join(out, 'index.html'), 'utf8');
|
|
263
|
-
if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
|
|
264
|
-
} else if (mode === 'command') {
|
|
265
|
-
const command = String(g.build.command ?? 'npm run build');
|
|
266
|
-
const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
267
|
-
if (res.status !== 0) throw new Error(`games/${g.id}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
|
|
268
|
-
const built = join(g.dir, String(g.build.out ?? 'dist'));
|
|
269
|
-
if (!existsSync(join(built, 'index.html'))) throw new Error(`games/${g.id}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
|
|
270
|
-
cpSync(built, out, { recursive: true });
|
|
271
|
-
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
272
|
-
} else {
|
|
273
|
-
await bundle(g.entry ?? 'src/main.ts');
|
|
274
|
-
const html = join(g.dir, 'index.html');
|
|
275
|
-
if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
|
|
276
|
-
writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
|
|
277
|
-
}
|
|
278
|
-
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
279
|
-
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
296
|
+
const { mode, warnings } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log });
|
|
280
297
|
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
281
298
|
vocabFor(g, out);
|
|
282
299
|
// The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHANGELOG.md: what changed in each @homie-rocks/studio version, and the Homie plugin's version beside it.
|
|
3
|
+
*
|
|
4
|
+
* The repository keeps it at its root; the package carries an identical copy (packages/studio/CHANGELOG.md, in
|
|
5
|
+
* `files`, kept the same by `node scripts/changelog.mjs --sync`), so an installed toolkit knows its own history with
|
|
6
|
+
* no network. Read by:
|
|
7
|
+
* - `homie-studio upgrade`: "What's new since <the pinned version>", from the NEW version's own copy (the one
|
|
8
|
+
* `npx -y @homie-rocks/studio@<new> upgrade` runs);
|
|
9
|
+
* - the studio card (lib/mcp-tools.mjs), when a studio pins an older toolkit than the one answering;
|
|
10
|
+
* - scripts/changelog.mjs: CI's check, and the GitHub release notes the publish workflow writes.
|
|
11
|
+
*
|
|
12
|
+
* The shape (Keep a Changelog, https://keepachangelog.com/en/1.1.0/), newest first:
|
|
13
|
+
*
|
|
14
|
+
* ## [0.19.2] - 2026-10-02 one section per studio version
|
|
15
|
+
*
|
|
16
|
+
* **Plugin 0.20.2** · [#29](…) · [release-…](…) the meta line: the plugin's version (", then 0.20.3" when
|
|
17
|
+
* the plugin had a release of its own after it), the pull
|
|
18
|
+
* requests and the tag
|
|
19
|
+
* One sentence: what this version is. the summary "What's new" prints
|
|
20
|
+
*
|
|
21
|
+
* ### Added | ### Changed | ### Fixed | ### Upgrade notes bullets ("- " and lines indented under them)
|
|
22
|
+
*
|
|
23
|
+
* [0.19.2]: https://… link definitions, at the end of the file
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
export const CHANGELOG_FILE = fileURLToPath(new URL('../CHANGELOG.md', import.meta.url));
|
|
29
|
+
export const GROUPS = Object.freeze(['Added', 'Changed', 'Fixed', 'Upgrade notes']);
|
|
30
|
+
export const REPO_URL = 'https://github.com/homie-rocks/homie';
|
|
31
|
+
|
|
32
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
33
|
+
const HEADING = /^## \[?(\d+\.\d+\.\d+)\]?(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?\s*$/;
|
|
34
|
+
const LINK_DEF = /^\[[^\]]+\]:\s+\S+/;
|
|
35
|
+
|
|
36
|
+
/** -1, 0 or 1, as a sorts before, with or after b (x.y.z only; anything else sorts first). */
|
|
37
|
+
export function compareVersions(a, b) {
|
|
38
|
+
const x = VERSION.test(String(a)) ? String(a).split('.').map(Number) : [-1, -1, -1];
|
|
39
|
+
const y = VERSION.test(String(b)) ? String(b).split('.').map(Number) : [-1, -1, -1];
|
|
40
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Markdown as one line of plain words: links keep their text, code and bold lose their marks. */
|
|
45
|
+
export function plain(markdown) {
|
|
46
|
+
return String(markdown ?? '')
|
|
47
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
48
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
49
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The paragraphs and bullets of a run of lines: paragraphs split on blank lines, a bullet takes the lines indented under it. */
|
|
55
|
+
function blocks(lines) {
|
|
56
|
+
const out = [];
|
|
57
|
+
let cur = null;
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (!line.trim()) { cur = null; continue; }
|
|
60
|
+
if (/^- /.test(line)) { cur = { kind: 'bullet', text: line.slice(2).trim() }; out.push(cur); continue; }
|
|
61
|
+
if (cur && (cur.kind === 'paragraph' || /^\s+\S/.test(line))) { cur.text += ` ${line.trim()}`; continue; }
|
|
62
|
+
cur = { kind: 'paragraph', text: line.trim() }; out.push(cur);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The file as its sections. Each: { version, date, plugin, plugins, meta, summary, notes, groups, body, line }.
|
|
69
|
+
* `body` is the section's own Markdown (no heading, no link definitions), what a release's notes are made of.
|
|
70
|
+
* `problems` lists what does not follow the shape (CI fails on them); a heading that is not a version (an
|
|
71
|
+
* "Unreleased" one, say) ends the section before it and is otherwise left alone.
|
|
72
|
+
*/
|
|
73
|
+
export function parseChangelog(text) {
|
|
74
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
75
|
+
const versions = [];
|
|
76
|
+
const problems = [];
|
|
77
|
+
let cur = null;
|
|
78
|
+
const close = () => {
|
|
79
|
+
if (!cur) return;
|
|
80
|
+
while (cur.lines.length && !cur.lines[cur.lines.length - 1].trim()) cur.lines.pop();
|
|
81
|
+
const { lines: own, ...section } = cur;
|
|
82
|
+
section.body = own.join('\n').replace(/^\n+/, '');
|
|
83
|
+
// Before the first ### heading: the meta line, the summary and any notes; then the groups.
|
|
84
|
+
const groups = {};
|
|
85
|
+
let head = [];
|
|
86
|
+
let group = null;
|
|
87
|
+
for (const line of own) {
|
|
88
|
+
const g = /^### (.+?)\s*$/.exec(line);
|
|
89
|
+
if (g) { group = g[1]; groups[group] = []; continue; }
|
|
90
|
+
if (group === null) head.push(line); else groups[group].push(line);
|
|
91
|
+
}
|
|
92
|
+
const paras = blocks(head).map((b) => b.text);
|
|
93
|
+
const metaAt = paras.findIndex((p) => /^\*\*Plugin /.test(p));
|
|
94
|
+
section.meta = metaAt >= 0 ? paras[metaAt] : null;
|
|
95
|
+
const rest = paras.filter((_, i) => i !== metaAt);
|
|
96
|
+
section.summary = rest[0] ?? null;
|
|
97
|
+
section.notes = rest.slice(1);
|
|
98
|
+
section.plugins = section.meta ? [...(/^\*\*Plugin ([^*]+)\*\*/.exec(section.meta)?.[1] ?? '').matchAll(/\d+\.\d+\.\d+/g)].map((m) => m[0]) : [];
|
|
99
|
+
section.plugin = section.plugins.at(-1) ?? null;
|
|
100
|
+
section.groups = Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, blocks(v).filter((b) => b.kind === 'bullet').map((b) => b.text)]));
|
|
101
|
+
const where = `CHANGELOG.md line ${section.line} (## [${section.version}])`;
|
|
102
|
+
if (!section.date) problems.push(`${where}: the heading has no date; write it as "## [${section.version}] - YYYY-MM-DD"`);
|
|
103
|
+
if (!section.meta) problems.push(`${where}: no "**Plugin x.y.z** · …" line under the heading`);
|
|
104
|
+
else if (!section.plugin) problems.push(`${where}: the Plugin line names no version`);
|
|
105
|
+
if (!section.summary) problems.push(`${where}: no one-sentence summary under the Plugin line`);
|
|
106
|
+
for (const k of Object.keys(section.groups)) if (!GROUPS.includes(k)) problems.push(`${where}: "### ${k}" is not one of ${GROUPS.map((g) => `"### ${g}"`).join(', ')}`);
|
|
107
|
+
if (!Object.values(section.groups).some((b) => b.length)) problems.push(`${where}: no "- " entries under ${GROUPS.map((g) => `"### ${g}"`).join(', ')}`);
|
|
108
|
+
versions.push(section);
|
|
109
|
+
cur = null;
|
|
110
|
+
};
|
|
111
|
+
lines.forEach((line, i) => {
|
|
112
|
+
if (/^## /.test(line)) {
|
|
113
|
+
close();
|
|
114
|
+
const m = HEADING.exec(line);
|
|
115
|
+
if (m) cur = { version: m[1], date: m[2] ?? null, heading: line, line: i + 1, lines: [] };
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (cur && LINK_DEF.test(line)) { close(); return; }
|
|
119
|
+
if (cur) cur.lines.push(line);
|
|
120
|
+
});
|
|
121
|
+
close();
|
|
122
|
+
for (let i = 1; i < versions.length; i++) {
|
|
123
|
+
if (compareVersions(versions[i - 1].version, versions[i].version) <= 0) problems.push(`CHANGELOG.md line ${versions[i].line}: ${versions[i].version} comes after ${versions[i - 1].version}; newest first, each version once`);
|
|
124
|
+
}
|
|
125
|
+
return { versions, problems };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The package's own CHANGELOG.md, parsed; null when it is missing (a checkout from before 0.19.2) or unreadable. */
|
|
129
|
+
export function readChangelog(file = CHANGELOG_FILE) {
|
|
130
|
+
try { return { file, ...parseChangelog(readFileSync(file, 'utf8')) }; } catch { return null; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One version's section, or null. */
|
|
134
|
+
export function sectionOf(changelog, version) {
|
|
135
|
+
return changelog?.versions?.find((s) => s.version === version) ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What changed after `from` up to and including `to`, newest first: each version's summary and its upgrade notes
|
|
140
|
+
* (what the person may have to do). null when there is nothing to say (no pin known, no changelog, or not older).
|
|
141
|
+
*/
|
|
142
|
+
export function whatsNew(from, to, { changelog = readChangelog() } = {}) {
|
|
143
|
+
if (!VERSION.test(String(from ?? '')) || !VERSION.test(String(to ?? '')) || compareVersions(from, to) >= 0 || !changelog) return null;
|
|
144
|
+
const versions = changelog.versions
|
|
145
|
+
.filter((s) => compareVersions(s.version, from) > 0 && compareVersions(s.version, to) <= 0)
|
|
146
|
+
.map((s) => ({ version: s.version, date: s.date, plugin: s.plugin, summary: plain(s.summary), upgrade: (s.groups['Upgrade notes'] ?? []).map(plain) }));
|
|
147
|
+
return versions.length ? { from, to, versions } : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* whatsNew as a few lines for a terminal or a card's text: one line per version (the newest `maxVersions`), then the
|
|
152
|
+
* upgrade notes of every version in the range (the first `maxNotes`), each cut to `width`.
|
|
153
|
+
*/
|
|
154
|
+
export function whatsNewLines(news, { width = 110, maxVersions = 8, maxNotes = 8, source = null } = {}) {
|
|
155
|
+
if (!news?.versions?.length) return [];
|
|
156
|
+
const cut = (s, n) => (s.length > n ? `${s.slice(0, n - 1).replace(/\s+\S*$/, '')}…` : s);
|
|
157
|
+
const shown = news.versions.slice(0, maxVersions);
|
|
158
|
+
const pad = Math.max(...news.versions.map((v) => v.version.length));
|
|
159
|
+
const lines = [`What's new since ${news.from}${source ? ` (${source})` : ''}:`];
|
|
160
|
+
for (const v of shown) lines.push(` ${v.version.padEnd(pad)} ${cut(v.summary ?? '', width - pad - 4)}`);
|
|
161
|
+
const more = news.versions.length - shown.length;
|
|
162
|
+
if (more) lines.push(` … and ${more} earlier version${more === 1 ? '' : 's'} (CHANGELOG.md has every one)`);
|
|
163
|
+
const notes = news.versions.flatMap((v) => v.upgrade.map((u) => [v.version, u]));
|
|
164
|
+
if (notes.length) {
|
|
165
|
+
lines.push('Upgrade notes (anything to do yourself):');
|
|
166
|
+
for (const [v, u] of notes.slice(0, maxNotes)) lines.push(` ${v.padEnd(pad)} ${cut(u, width - pad - 4)}`);
|
|
167
|
+
if (notes.length > maxNotes) lines.push(` … and ${notes.length - maxNotes} more (--json has every one)`);
|
|
168
|
+
}
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Markdown with each paragraph and each "- " entry on one line. CHANGELOG.md wraps them at 120 columns, which a
|
|
174
|
+
* file ignores; GitHub's release notes render every line end as a break, so a wrapped entry would break mid-sentence.
|
|
175
|
+
*/
|
|
176
|
+
export function unwrap(markdown) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const line of String(markdown ?? '').split('\n')) {
|
|
179
|
+
const prev = out.length ? out[out.length - 1] : '';
|
|
180
|
+
const starts = !line.trim() || /^(#{1,6} |- |\* |\d+\. |---\s*$|\|)/.test(line.trim()) && !/^\s+/.test(line);
|
|
181
|
+
const continues = prev.trim() && !/^(#{1,6} |---\s*$|\|)/.test(prev.trim());
|
|
182
|
+
if (!starts && continues) out[out.length - 1] = `${prev.replace(/\s+$/, '')} ${line.trim()}`;
|
|
183
|
+
else out.push(line);
|
|
184
|
+
}
|
|
185
|
+
return out.join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A GitHub release's notes: the version's section as it is in CHANGELOG.md (each paragraph and entry on one line),
|
|
190
|
+
* then the Desktop extension's line (its .mcpb is attached to the release) and where every version is.
|
|
191
|
+
*/
|
|
192
|
+
export function releaseNotes(changelog, version, { tag = null, repo = REPO_URL } = {}) {
|
|
193
|
+
const s = sectionOf(changelog, version);
|
|
194
|
+
if (!s) return null;
|
|
195
|
+
return [
|
|
196
|
+
unwrap(s.body),
|
|
197
|
+
'',
|
|
198
|
+
'---',
|
|
199
|
+
'',
|
|
200
|
+
`Homie for Claude Desktop ${version}: [desktop/README.md](${repo}/blob/${tag ?? 'main'}/desktop/README.md) says how to install it (\`homie-studio-${version}.mcpb\` below).`,
|
|
201
|
+
`Every version: [CHANGELOG.md](${repo}/blob/main/CHANGELOG.md).`,
|
|
202
|
+
'',
|
|
203
|
+
].join('\n');
|
|
204
|
+
}
|
package/lib/jobs.mjs
CHANGED
|
@@ -159,10 +159,11 @@ export function jobView(job, { lines = 12 } = {}) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/** Run the studio's pinned CLI as a job: `homie-studio <args> --json`. */
|
|
162
|
-
export function cliJob(root, label, args, opts = {}) {
|
|
162
|
+
export function cliJob(root, label, args, { cli = null, ...opts } = {}) {
|
|
163
163
|
const node = findNode();
|
|
164
164
|
if (!node) throw new Error('no Node.js 22 or newer on this computer: install it from https://nodejs.org/en/download (the LTS), then ask again');
|
|
165
|
-
|
|
165
|
+
// `cli`: another homie-studio than the studio's pinned one (this package's, for an upgrade to it).
|
|
166
|
+
return startJob({ root, label, cmd: node.bin, args: [cli ?? cliOf(root), ...args, '--json'], json: true, ...opts });
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
/** `npm install` in a studio (its pinned toolkit and Wrangler). */
|