@homie-rocks/studio 0.2.0 → 0.5.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/lib/port.mjs ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * `homie-studio port plan <folder>` and `homie-studio port import <folder> --id <id>`:
3
+ * the first two steps of making an existing single-player web game multiplayer.
4
+ *
5
+ * plan reads the game without running it (loop, input, state, camera, physics,
6
+ * storage, audio, network, size) and drafts a port plan: a difficulty
7
+ * grade with its reasons, the netplay movement mode, the build mode, the
8
+ * check view, and every risk the sandboxed play frame will hit. The agent
9
+ * then READS the code and writes the final PORT.md; the draft is a start,
10
+ * never the verdict.
11
+ * import copies the game into games/<id>/ untouched except for two lines (the
12
+ * port toolkit first in <head>, a phone-safe viewport), writes game.json,
13
+ * and keeps the licence beside it.
14
+ */
15
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
16
+ import { basename, extname, join, relative, resolve } from 'node:path';
17
+ import { GAME_ID } from './studio.mjs';
18
+
19
+ const SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', '.cache', '.parcel-cache', '.vite', 'coverage']);
20
+ const CODE = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.svelte']);
21
+ const TEXT = new Set([...CODE, '.html', '.htm', '.css', '.json', '.md', '.txt', '.glsl', '.frag', '.vert', '.tmx', '.xml', '.svg']);
22
+ const MAX_READ = 2 * 1024 * 1024;
23
+
24
+ function walk(dir, rel = '', out = []) {
25
+ for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
26
+ if (SKIP.has(e.name)) continue;
27
+ const p = rel ? `${rel}/${e.name}` : e.name;
28
+ if (e.isDirectory()) walk(dir, p, out);
29
+ else if (e.isFile()) out.push({ path: p, bytes: statSync(join(dir, p)).size, ext: extname(e.name).toLowerCase() });
30
+ }
31
+ return out;
32
+ }
33
+
34
+ const count = (text, re) => (text.match(re) ?? []).length;
35
+
36
+ /** A licence file's SPDX-ish kind, from its words. */
37
+ export function licenceOf(dir) {
38
+ const names = readdirSync(dir).filter((n) => /^(licen[cs]e|copying)(\.|$)/i.test(n));
39
+ if (!names.length) {
40
+ const pkg = existsSync(join(dir, 'package.json')) ? JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) : null;
41
+ return { file: null, kind: pkg?.license ? `${pkg.license} (package.json only)` : 'none', permissive: false };
42
+ }
43
+ const text = readFileSync(join(dir, names[0]), 'utf8');
44
+ let kind = 'unknown';
45
+ if (/Permission is hereby granted, free of charge/i.test(text)) kind = 'MIT';
46
+ else if (/Apache License[\s\S]{0,40}Version 2\.0/i.test(text)) kind = 'Apache-2.0';
47
+ else if (/Redistribution and use in source and binary forms/i.test(text)) kind = /Neither the name/i.test(text) ? 'BSD-3-Clause' : 'BSD-2-Clause';
48
+ else if (/Permission to use, copy, modify, and\/or distribute/i.test(text)) kind = 'ISC';
49
+ else if (/GNU AFFERO GENERAL PUBLIC LICENSE/i.test(text)) kind = 'AGPL';
50
+ else if (/GNU LESSER GENERAL PUBLIC LICENSE/i.test(text)) kind = 'LGPL';
51
+ else if (/GNU GENERAL PUBLIC LICENSE/i.test(text)) kind = 'GPL';
52
+ else if (/Mozilla Public License/i.test(text)) kind = 'MPL-2.0';
53
+ else if (/This is free and unencumbered software released into the public domain/i.test(text)) kind = 'Unlicense';
54
+ else if (/Creative Commons/i.test(text)) kind = 'Creative Commons';
55
+ return { file: names[0], kind, permissive: ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'BSD-2-Clause', 'ISC', 'Unlicense'].includes(kind) };
56
+ }
57
+
58
+ /** Read a game folder and draft its port plan. */
59
+ export function planPort(folder) {
60
+ const dir = resolve(folder ?? '.');
61
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`${dir} is not a folder`);
62
+ const files = walk(dir);
63
+ const texts = [];
64
+ let read = 0;
65
+ for (const f of files) {
66
+ if (!TEXT.has(f.ext) || f.bytes > 1024 * 1024 || read > MAX_READ) continue;
67
+ // Vendored libraries (three.module.js, jquery…) are not the game: counted, not analysed.
68
+ const vendored = /(^|\/)(vendor|libs?|third[-_]?party|build|jsm|addons|node_modules)\//i.test(f.path) || /(^|\/)(GLTFLoader|OBJLoader|FBXLoader|OrbitControls|PointerLockControls|BufferGeometryUtils|Octree|Capsule|OctreeHelper|EffectComposer|RenderPass|UnrealBloomPass)\.js$/.test(f.path) || /(\.min\.js|three(\.module)?\.js|three\.core\.js|jquery[-.\d]*\.js|phaser[.\w-]*\.js|pixi[.\w-]*\.js|matter[.\w-]*\.js|cannon[.\w-]*\.js|howler[.\w-]*\.js|lil-gui[.\w-]*\.js|stats[.\w-]*\.js|typeface\.js)$/i.test(f.path);
69
+ const text = readFileSync(join(dir, f.path), 'utf8');
70
+ read += text.length;
71
+ texts.push({ ...f, text, vendored });
72
+ }
73
+ const game = texts.filter((t) => !t.vendored && (CODE.has(t.ext) || t.ext === '.html' || t.ext === '.htm'));
74
+ const all = game.map((t) => t.text).join('\n');
75
+ const everything = texts.map((t) => t.text).join('\n');
76
+ const loc = game.filter((t) => CODE.has(t.ext)).reduce((n, t) => n + t.text.split('\n').length, 0)
77
+ + game.filter((t) => t.ext === '.html').reduce((n, t) => n + (t.text.match(/<script(?![^>]*src)[^>]*>[\s\S]*?<\/script>/gi) ?? []).join('\n').split('\n').length, 0);
78
+ const totalBytes = files.reduce((n, f) => n + f.bytes, 0);
79
+ const pkg = existsSync(join(dir, 'package.json')) ? JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) : null;
80
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
81
+ const html = files.filter((f) => f.ext === '.html' || f.ext === '.htm').map((f) => f.path);
82
+ const index = html.find((p) => /(^|\/)index\.html?$/i.test(p) && !p.includes('/')) ?? html.find((p) => /index\.html?$/i.test(p)) ?? html[0] ?? null;
83
+ const indexText = index ? readFileSync(join(dir, index), 'utf8') : '';
84
+ const scripts = [...indexText.matchAll(/<script\b([^>]*)>/gi)].map((m) => ({ src: /src=["']([^"']+)["']/i.exec(m[1])?.[1] ?? null, module: /type=["']module["']/i.test(m[1]) }));
85
+ const has = (re) => re.test(all) || re.test(everything);
86
+
87
+ const engine = [];
88
+ if (has(/from ['"]three['"]|THREE\.|three\.module\.js|three\.core\.js/)) engine.push('three.js');
89
+ if (has(/BABYLON\.|@babylonjs/)) engine.push('babylon.js');
90
+ if (has(/new Phaser\.Game|from ['"]phaser['"]/)) engine.push('phaser');
91
+ if (has(/PIXI\.|from ['"]pixi\.js['"]/)) engine.push('pixi.js');
92
+ if (has(/\bkaboom\(|from ['"]kaboom['"]|from ['"]kaplay['"]/)) engine.push('kaboom');
93
+ if (has(/\bengineInit\(|LittleJS/)) engine.push('littlejs');
94
+ if (has(/\bme\.game\b|melonJS/)) engine.push('melonjs');
95
+ if (/function setup\s*\(|function draw\s*\(/.test(all) && /createCanvas\(/.test(all)) engine.push('p5');
96
+ if (!engine.length && /getContext\(\s*['"]2d['"]/.test(all)) engine.push('canvas-2d');
97
+ if (!engine.length && /getContext\(\s*['"](webgl2?|experimental-webgl)['"]/.test(all)) engine.push('webgl');
98
+ if (!engine.length) engine.push('dom');
99
+ const threeD = engine.some((e) => ['three.js', 'babylon.js', 'webgl'].includes(e));
100
+
101
+ const physics = [];
102
+ for (const [re, name] of [[/matter-js|Matter\.Engine/, 'matter.js'], [/cannon(-es)?|CANNON\./, 'cannon'], [/@dimforge\/rapier|RAPIER\./, 'rapier'], [/planck|Box2D|box2d/, 'box2d/planck'], [/ammo\.js|Ammo\(/, 'ammo.js'], [/oimo/i, 'oimo'], [/\bOctree\b.*capsule|Capsule\b/i, 'three octree collisions']]) {
103
+ if (has(re)) physics.push(name);
104
+ }
105
+ const customPhysics = /gravity/i.test(all) && /(dy|vy|velocity|\.vel)\b/i.test(all);
106
+
107
+ const loop = {
108
+ raf: count(all, /requestAnim(ation)?Frame|setAnimationLoop|\bticker\.add|scene\.registerBeforeRender|engine\.runRenderLoop/g),
109
+ interval: count(all, /setInterval\s*\(/g),
110
+ timeoutLoop: count(all, /setTimeout\s*\(/g),
111
+ };
112
+ const input = {
113
+ keys: count(all, /['"]key(down|up|press)['"]|onkey(down|up|press)|\.key(down|up)\s*\(/gi),
114
+ keyCode: count(all, /keyCode|\.which\b/g),
115
+ mouse: count(all, /['"](mouse(down|up|move)|click|dblclick|wheel|contextmenu)['"]|onclick|\.click\s*\(/gi),
116
+ pointer: count(all, /['"]pointer(down|up|move|cancel)['"]/g),
117
+ touch: count(all, /['"]touch(start|move|end|cancel)['"]|ontouch/g),
118
+ pointerLock: count(all, /requestPointerLock/g),
119
+ gamepad: count(all, /getGamepads/g),
120
+ };
121
+ const camera = {
122
+ perspective: count(all, /PerspectiveCamera/g),
123
+ orthographic: count(all, /OrthographicCamera/g),
124
+ followsHeading: /camera\.(rotation\.y|quaternion)\s*[=.]|camera\.lookAt\([^)]*(dir|heading|forward)/i.test(all),
125
+ firstPerson: input.pointerLock > 0 || /first[-\s]?person|PointerLockControls|FirstPersonControls/i.test(all),
126
+ twoDScroll: /(camera|viewport|cam)\.(x|y)\s*[+\-]?=|ctx\.translate\(/.test(all),
127
+ };
128
+ const storage = { localStorage: count(all, /localStorage/g), sessionStorage: count(all, /sessionStorage/g), indexedDB: count(all, /indexedDB/g), cookie: count(all, /document\.cookie/g) };
129
+ const audio = { webAudio: count(all, /AudioContext/g), media: count(all, /new Audio\(|<audio|\.play\(\)/g), howler: count(everything, /Howl\b|howler/g), tone: count(all, /Tone\./g) };
130
+ const network = {
131
+ fetch: count(all, /\bfetch\s*\(/g), xhr: count(all, /XMLHttpRequest|\$\.(get|getJSON|ajax)\(/g), websocket: count(all, /new WebSocket\(/g),
132
+ absolutePaths: [...new Set([...(indexText + all).matchAll(/(?:src|href)=["'](\/[^/"'][^"']*)["']|fetch\(\s*["'](\/[^/"'][^"']*)["']/g)].map((m) => m[1] ?? m[2]))].slice(0, 12),
133
+ cdn: [...new Set([...indexText.matchAll(/(?:src|href)=["'](https?:\/\/[^"']+)["']/g)].map((m) => new URL(m[1]).host))],
134
+ };
135
+ const workers = count(all, /new (Shared)?Worker\(/g);
136
+ const wasm = files.filter((f) => f.ext === '.wasm').length + count(all, /WebAssembly\./g);
137
+ const dialogs = count(all, /\b(alert|confirm|prompt)\s*\(/g);
138
+ const random = count(all, /Math\.random\(/g);
139
+ const multiplayer = /socket\.io|peerjs|new RTCPeerConnection|colyseus|firebase/i.test(everything);
140
+ const homiePackage = existsSync(join(dir, 'homie.json'));
141
+ const houseApi = /@homie\/(tabletop|arcade)|\bhomie\.(ready|report|showPrivate|on\(|seats)|__homie\//.test(all);
142
+ // Real time: a body moved by time (dt, velocity, speed × delta, a game loop); turn-based: moves on input only.
143
+ const realtime = count(all, /\bdt\b|deltaTime|\bdelta\b|velocity|\.vel\b|\.vx\b|\.vy\b|\bvx\b|\bvy\b|speed\s*\*|mainLoop|gameLoop|getDelta\(|update\(\s*(dt|delta|step)/g);
144
+ const boardWords = count(all, /\b(turn|board|grid|tile|cell|move\(\s*(direction|dir)|swipe)s?\b/gi);
145
+ const turnBased = (loop.raf === 0 && loop.interval === 0) || (realtime <= 2 && boardWords >= 3);
146
+ const timing = /\b(rhythm|beat|bpm|frame[-\s]?perfect|metronome)\b/i.test(all);
147
+ const sideView = !threeD && /gravity/i.test(all) && /jump/i.test(all);
148
+ const ammo = /bullet|projectile|missile|shoot|fire/i.test(all);
149
+
150
+ // ---------------------------------------------------------------- grade
151
+ const reasons = [];
152
+ let grade = 'easy';
153
+ const up = (to, why) => { const order = ['easy', 'medium', 'hard', 'not a fit']; if (order.indexOf(to) > order.indexOf(grade)) grade = to; reasons.push(`${to}: ${why}`); };
154
+ if (!index) up('not a fit', 'no index.html: a studio game is a web page');
155
+ if (multiplayer) up('not a fit', 'it already has its own networking (socket.io/peer/firebase): port its rules instead, or keep it on its own server');
156
+ if (network.websocket) up('hard', 'it opens its own WebSocket: a server it depends on will not be there');
157
+ if (wasm && /SharedArrayBuffer/.test(all)) up('not a fit', 'WebAssembly threads need cross-origin isolation, which the sandboxed play frame cannot give');
158
+ if (totalBytes > 60 * 1024 * 1024) up('hard', `${Math.round(totalBytes / 1048576)} MB of files: every visitor downloads all of it (keep a game well under 30 MB)`);
159
+ if (timing) up('hard', 'precise timing (rhythm/beat): every screen needs the same clock; run the beat from the room clock (net.now())');
160
+ if (physics.length && !turnBased) up('hard', `fast physics (${physics.join(', ')}): the host simulates the world; each player owns only their own body`);
161
+ if (threeD && !turnBased) up(physics.length ? 'hard' : 'medium', `real-time 3D (${engine.join(', ')}): camera rules, screen-relative input and a stable yaw are needed`);
162
+ if (camera.followsHeading) up('medium', 'the camera turns with the character: it must stop (input on screen axes, yaw only on look input)');
163
+ if (camera.firstPerson) up('medium', 'first person with pointer lock: phones need a look-drag and a stick, and hits need the host');
164
+ if (!turnBased && !threeD) up(customPhysics ? 'medium' : 'easy', customPhysics ? 'real-time 2D with its own gravity/velocity: owner movement keeps jumps local, the host runs everything else' : 'real-time 2D movement: owner movement');
165
+ if (turnBased) reasons.push('easy: turn-based or grid rules: the host runs the rules from intents (host movement); or a race where everyone plays the same seed');
166
+ if (loc > 6000) up('hard', `${loc} lines of game code: huge state; find the few things other players must see`);
167
+ else if (loc > 2500) up('medium', `${loc} lines of game code`);
168
+ if (ammo && !turnBased) up('medium', 'projectiles and hits: the host decides every hit and carries projectiles in the snapshot');
169
+ if (workers) up('medium', 'it starts Web Workers: a sandboxed frame (opaque origin) cannot load a worker script by URL; inline it or drop it');
170
+ if (network.absolutePaths.length) up('medium', `absolute paths (${network.absolutePaths.slice(0, 3).join(', ')}): the game is served under /<id>/__game/, so every path must be relative`);
171
+ if (grade === 'easy' && !reasons.length) reasons.push('easy: a small game with simple state');
172
+
173
+ const movement = turnBased ? 'host' : 'owner';
174
+ const view = turnBased ? 'board' : camera.firstPerson ? 'first-person' : sideView ? 'side' : 'top';
175
+ const buildMode = pkg?.scripts?.build && Object.keys(deps).some((d) => /^(vite|webpack|parcel|rollup|esbuild|@sveltejs|next)/.test(d)) ? 'command'
176
+ : scripts.some((s) => s.module) || indexText.includes('importmap') ? 'static'
177
+ : 'static';
178
+
179
+ const risks = [];
180
+ if (storage.localStorage || storage.sessionStorage || storage.cookie) risks.push('storage: localStorage/cookies throw in the sandboxed frame; the port toolkit (homie-port.js / port/early) puts in-memory stand-ins first. High scores last for the visit.');
181
+ if (storage.indexedDB) risks.push('indexedDB is blocked in the sandboxed frame: guard every open() or drop it.');
182
+ if (dialogs) risks.push(`${dialogs} alert/confirm/prompt call(s): they block every browser in the room and the host's clock; replace with in-game text.`);
183
+ if (audio.webAudio || audio.media || audio.howler) risks.push('audio: starts suspended; the toolkit resumes it on the first touch/key. Remove any "click to start sound" gate that blocks play.');
184
+ if (input.keys && !input.touch && !input.pointer) risks.push('keyboard only: phones need the touch kit (a floating stick that presses the same keys, small buttons for actions).');
185
+ if (input.mouse && !input.touch) risks.push('mouse input: map it to touch (tap/drag) explicitly; do not rely on emulated mouse events on phones.');
186
+ if (input.pointer && /pointerId\s*[<>]=?\s*0|pointerId\s*===?\s*-1|=\s*-1\s*;?\s*\/\/.*(touch|pointer)/.test(all)) risks.push('a negative pointer id is used as "no finger": iOS Safari ids are negative half the time. Use null.');
187
+ if (loop.raf === 0 && loop.interval > 0) risks.push('the loop runs on setInterval: keep the rules on the host, and make rendering follow snapshots.');
188
+ if (random > 0 && !turnBased) risks.push(`${random} Math.random() call(s): randomness that changes the world must happen on the host only (replicas get the result in snapshots or keyed state).`);
189
+ if (network.cdn.length) risks.push(`loads from other hosts (${network.cdn.join(', ')}): copy those files into the game so it cannot break when a CDN does.`);
190
+ if (camera.perspective && !camera.firstPerson) risks.push('3D camera: verify the owner tests (hold 5 s: straight line, yaw < 10°; alternate 10 s: every press goes the pressed way).');
191
+ if (/antialias\s*:\s*true/.test(all) && threeD) risks.push('MSAA (antialias: true) drew a black world on iPhone WebKit in a past port; turn it off on WebKit or everywhere.');
192
+ if (ammo && !turnBased) risks.push('projectiles/hits: the host decides every hit (a shooter\'s claim may be checked against recent positions); projectiles ride the snapshot.');
193
+
194
+ if (houseApi) risks.push('it is written for a shared TV screen with phone pads (a Homie package): on the web every browser is its own screen and there is no pad. Keep its rules, drive them from netplay seats, keys and the touch kit, and give it a page of its own (index.html that boots the rules and draws them).');
195
+ if (houseApi) up('medium', 'written for a shared TV screen and phone pads: the rules are reusable, the seats and the screen are not');
196
+ const licence = licenceOf(dir);
197
+ if (!licence.file && homiePackage) { licence.kind = 'a Homie package (the house\'s own game; no licence file)'; licence.permissive = true; }
198
+ if (!licence.permissive) risks.push(`licence: ${licence.kind}${licence.file ? ` (${licence.file})` : ''} — publish only what its licence allows; with no licence, do not publish it.`);
199
+
200
+ return {
201
+ ok: true, command: 'port plan', folder: dir, grade, reasons,
202
+ facts: {
203
+ files: files.length, bytes: totalBytes, loc, index, scripts: scripts.slice(0, 12), engine, physics, customPhysics, loop, input, camera, storage, audio,
204
+ network, workers, wasm, dialogs, random, realtime, boardWords, turnBased, sideView, houseApi, package: pkg ? { name: pkg.name, scripts: Object.keys(pkg.scripts ?? {}), deps: Object.keys(deps).slice(0, 30) } : null,
205
+ },
206
+ recommend: { movement, view, build: buildMode },
207
+ risks, licence,
208
+ };
209
+ }
210
+
211
+ /** Copy a game into games/<id>/ as this studio's port of it. */
212
+ export function importPort(root, folder, id, { name, mode } = {}) {
213
+ if (!GAME_ID.test(String(id ?? ''))) throw new Error('give the port an id: --id <lowercase-id> (letters, digits, hyphens)');
214
+ const src = resolve(folder ?? '');
215
+ if (!existsSync(src) || !statSync(src).isDirectory()) throw new Error(`${src} is not a folder`);
216
+ const dest = join(root, 'games', id);
217
+ if (existsSync(dest)) throw new Error(`games/${id} already exists; pick another id`);
218
+ const rel = relative(src, dest);
219
+ if (!rel.startsWith('..') && rel !== '') throw new Error('the game folder cannot contain the studio');
220
+ const plan = planPort(src);
221
+ if (!plan.facts.index) throw new Error(`${src} has no index.html; nothing to port`);
222
+ const buildMode = mode ?? plan.recommend.build;
223
+ mkdirSync(dest, { recursive: true });
224
+ const copied = [];
225
+ const copy = (rel2 = '') => {
226
+ for (const e of readdirSync(join(src, rel2), { withFileTypes: true })) {
227
+ if (SKIP.has(e.name) || (buildMode !== 'command' && e.name === 'dist')) continue;
228
+ const p = rel2 ? `${rel2}/${e.name}` : e.name;
229
+ if (e.isDirectory()) { mkdirSync(join(dest, p), { recursive: true }); copy(p); }
230
+ else if (e.isFile()) { cpSync(join(src, p), join(dest, p)); copied.push(p); }
231
+ }
232
+ };
233
+ copy();
234
+ // A static game needs index.html at its root (the Worker serves games/<id>/index.html).
235
+ if (buildMode === 'static' && plan.facts.index !== 'index.html') throw new Error(`the game's page is ${plan.facts.index}; move it to the folder's root as index.html first`);
236
+ const title = /<title>([^<]{1,80})<\/title>/i.exec(readFileSync(join(src, plan.facts.index), 'utf8'))?.[1]?.trim();
237
+ const meta = {
238
+ id,
239
+ name: String(name ?? title ?? id).slice(0, 60),
240
+ blurb: '',
241
+ players: { min: 1, max: 8 },
242
+ roundSeconds: 120,
243
+ build: buildMode === 'command' ? { mode: 'command', command: 'npm run build', out: 'dist' } : { mode: buildMode },
244
+ netplay: { v: 1, public: true, movement: plan.recommend.movement },
245
+ port: { from: basename(src), licence: plan.licence.kind, licenceFile: plan.licence.file, grade: plan.grade, view: plan.recommend.view, at: new Date().toISOString() },
246
+ };
247
+ writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
248
+ const edits = [];
249
+ if (buildMode === 'static') {
250
+ const indexPath = join(dest, 'index.html');
251
+ let html = readFileSync(indexPath, 'utf8');
252
+ if (!/homie-port\.js/.test(html)) {
253
+ const tag = '<script src="./homie-port.js"></script>';
254
+ // First script in <head>, after a charset declaration if there is one.
255
+ if (/<meta[^>]+charset[^>]*>/i.test(html)) html = html.replace(/<meta[^>]+charset[^>]*>/i, (m) => `${m}\n${tag}`);
256
+ else html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, (m) => `${m}\n${tag}`) : `${tag}\n${html}`;
257
+ edits.push('index.html: loads ./homie-port.js first (sandbox shims, first-touch audio, window.HomiePort)');
258
+ }
259
+ const vp = 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover';
260
+ if (/<meta[^>]+name=["']viewport["'][^>]*>/i.test(html)) {
261
+ html = html.replace(/<meta[^>]+name=["']viewport["'][^>]*>/i, `<meta name="viewport" content="${vp}">`);
262
+ edits.push('index.html: phone-safe viewport (no pinch-zoom under a thumb)');
263
+ } else {
264
+ html = /<meta[^>]+charset[^>]*>/i.test(html) ? html.replace(/<meta[^>]+charset[^>]*>/i, (m) => `${m}\n<meta name="viewport" content="${vp}">`) : html.replace(/<head([^>]*)>/i, (m) => `${m}\n<meta name="viewport" content="${vp}">`);
265
+ edits.push('index.html: added a phone-safe viewport');
266
+ }
267
+ writeFileSync(indexPath, html);
268
+ }
269
+ return {
270
+ ok: true, command: 'port import', id, dir: dest, from: src, mode: buildMode, files: copied.length, edits, plan: { grade: plan.grade, movement: plan.recommend.movement, view: plan.recommend.view },
271
+ next: [
272
+ `Write games/${id}/PORT.md: the grade and why, the movement mode, what the host owns, what each browser owns, bots, touch, camera.`,
273
+ buildMode === 'static' ? 'Add the netplay layer as a script after the game\'s own (HomiePort.createRoom …), or an "entry" in game.json bundled to assets/main.js.' : 'Import \'@homie-rocks/studio/port/early\' first, then build the netplay layer with createRoom.',
274
+ `npm run dev (in the background), then: npx --no-install homie-studio port check ${id} --url http://127.0.0.1:8787`,
275
+ ],
276
+ };
277
+ }
package/lib/scaffold.mjs CHANGED
@@ -7,9 +7,12 @@
7
7
  * studio.json the studio's name, slug, and its Cloudflare resources
8
8
  * package.json @homie-rocks/studio and wrangler, pinned
9
9
  * games/<id>/ one folder per game (game.json, index.html, src/)
10
- * music/ videos/ manifests in the repo; the big files live in the studio's R2
10
+ * music/ videos/ manifests in the repo; the big files go to the studio's own storage
11
+ * (R2) once it is added with `homie-studio storage add`
11
12
  * posts/ words the studio publishes
12
- * site/ the studio's Worker (pages, rooms), its D1 migrations
13
+ * site/ the studio's Worker (pages, rooms), its D1 migrations (no R2
14
+ * binding until the studio adds storage: a free Cloudflare account
15
+ * without a payment method cannot use R2, and a new studio needs none)
13
16
  * .claude/skills/ skills only this studio uses
14
17
  *
15
18
  * It writes only into a folder that is new or empty, never guesses one, and
@@ -47,7 +50,8 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null }) {
47
50
  observability: { enabled: true },
48
51
  };
49
52
  return `// This studio's site on its own Cloudflare account (written by homie-studio; \`homie-studio deploy\`
50
- // fills in the D1 id, and drops the R2 binding when the account has no R2).
53
+ // fills in the D1 id). Everything here runs on Cloudflare's Workers Free plan. There is no R2 binding
54
+ // until \`homie-studio storage add\` gives the studio storage for large media.
51
55
  ${JSON.stringify(config, null, 2)}
52
56
  `;
53
57
  }
@@ -55,19 +59,19 @@ ${JSON.stringify(config, null, 2)}
55
59
  function agentsMd({ name, slug }) {
56
60
  return `# ${name}
57
61
 
58
- This folder is a game studio: **${name}** (\`${slug}\`). It is one repository.
62
+ This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
59
63
  Its games, music, videos and posts live here; its website and public game rooms
60
- run on the studio's **own Cloudflare account** (a Worker, D1, R2, and the
61
- Table/Lobby Durable Objects), built from \`@homie-rocks/studio\`, pinned in
62
- \`package.json\`. The homie.rocks directory lists its games; homie.rocks does
63
- not host them.
64
+ run on the studio's **own Cloudflare account** (one Worker, one D1 database and
65
+ the Table/Lobby Durable Objects, all on Cloudflare's free Workers plan), built
66
+ from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
67
+ directory lists its games; homie.rocks does not host them.
64
68
 
65
69
  ## Layout
66
70
 
67
71
  | Path | What it is |
68
72
  | --- | --- |
69
73
  | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
70
- | \`music/\`, \`videos/\` | \`manifest.json\` lists each file and its R2 key. Large files go to R2 (\`npx --no-install homie-studio media put <file>\`), never into git. |
74
+ | \`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>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
71
75
  | \`posts/\` | Markdown the studio publishes. |
72
76
  | \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
73
77
  | \`studio.json\` | The studio's name, slug and Cloudflare resource names. |
@@ -80,14 +84,27 @@ studio's pinned copy, never a registry lookup of the bare name.
80
84
 
81
85
  - \`npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"\` — a new game from a
82
86
  multiplayer starter (one live public room from its first build, bots fill seats).
87
+ - \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
88
+ how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
89
+ owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
90
+ plugin's \`port\` skill does the whole job.
83
91
  - \`npm run build\` — bundle every game into \`site/dist\`.
84
92
  - \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
85
93
  Durable Object, D1): open the printed address in two browsers and they share a room.
94
+ Stop it with \`npx --no-install homie-studio dev --stop\` (this studio's dev server only;
95
+ never \`pkill\` by name, which stops other projects' dev servers too).
86
96
  - \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
87
97
  land in the same room and finish a round. Run it before you say a game works.
88
- - \`npm run deploy\` — the site on this studio's Cloudflare (Workers, D1, R2).
98
+ - \`npx --no-install homie-studio deploy --plan\` — says what deploy will create on Cloudflare and what it
99
+ costs, and changes nothing. Tell the person before the first deploy.
100
+ - \`npm run deploy\` — the site on this studio's Cloudflare: one Worker, one D1 database, two
101
+ SQLite-backed Durable Objects, all on the free Workers plan (no payment method needed).
89
102
  If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
90
103
  their browser. It never overwrites a Worker or database this studio did not create.
104
+ - \`npx --no-install homie-studio storage add\` — only when the studio needs large media (songs,
105
+ videos): an R2 bucket for \`media put\`, served at \`/media/<key>\`. Cloudflare asks for a
106
+ payment method on the account before R2 works (its first 10 GB a month are free), so this
107
+ is a separate step the person agrees to; nothing else needs it.
91
108
  - \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
92
109
  (or call the Homie MCP tool \`studio_publish\`).
93
110
 
@@ -109,6 +126,12 @@ studio's pinned copy, never a registry lookup of the bare name.
109
126
  keychain. Never write a key, token or password into this repository.
110
127
  - Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
111
128
  - Nothing in this studio needs \`~/.homie\` or a Homie box.
129
+
130
+ ## Beta
131
+
132
+ Homie for studios is in beta. When something breaks, or a game you want to port does
133
+ not fit, open an issue at https://github.com/homie-rocks/homie/issues/new/choose (a bug,
134
+ a port request or a question). Leave keys, tokens and private addresses out of it.
112
135
  `;
113
136
  }
114
137
 
@@ -153,7 +176,12 @@ site/.wrangler/
153
176
  .dev.vars
154
177
  .env
155
178
  *.log
156
- # Large media lives in this studio's R2; the manifests beside it are committed.
179
+ # Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
180
+ games/*/.port/
181
+ # Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
182
+ # Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
183
+ music/**/work/
184
+ videos/**/work/
157
185
  music/**/*.wav
158
186
  music/**/*.mp3
159
187
  music/**/*.flac
@@ -168,7 +196,8 @@ export function studioFiles({ name, slug, homie }) {
168
196
  const studio = {
169
197
  name, slug,
170
198
  homie: { studio: STUDIO_VERSION, directory: homie },
171
- cloudflare: { worker, d1: `${slug}-db`, r2: `${slug}-media`, accountId: null, url: null, created: [] },
199
+ // r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
200
+ cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, url: null, created: [] },
172
201
  };
173
202
  const pkg = {
174
203
  name: `${slug}-studio`,
@@ -185,12 +214,12 @@ export function studioFiles({ name, slug, homie }) {
185
214
  'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
186
215
  '.gitignore': GITIGNORE,
187
216
  'games/README.md': 'One folder per game. Start one with `npx --no-install homie-studio game new <id> --from gem-rush`.\n',
188
- 'music/README.md': 'Songs and stems. `manifest.json` lists them; the files themselves go to R2 with `npx --no-install homie-studio media put`.\n',
189
- 'music/manifest.json': '{ "items": [] }\n',
190
- 'videos/README.md': 'Trailers and clips. `manifest.json` lists them; the files themselves go to R2 with `npx --no-install homie-studio media put`.\n',
191
- 'videos/manifest.json': '{ "items": [] }\n',
217
+ 'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
218
+ 'music/manifest.json': '{ "v": 1, "items": [] }\n',
219
+ 'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
220
+ 'videos/manifest.json': '{ "v": 1, "items": [] }\n',
192
221
  'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
193
- 'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1 and R2.
222
+ 'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
194
223
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
195
224
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';
196
225
  `,
@@ -248,7 +277,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
248
277
  'npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
249
278
  'npm run dev (then: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
250
279
  'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
251
- ] };
280
+ ], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
252
281
  }
253
282
 
254
283
  export const _test = { relative };
package/lib/studio.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
11
- const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios']);
11
+ const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts']);
12
12
 
13
13
  export function findStudio(from = process.cwd()) {
14
14
  let at = resolve(from);
package/media/MEDIA.md ADDED
@@ -0,0 +1,81 @@
1
+ # Studio media: music/ and videos/
2
+
3
+ A studio's songs, scores, loops, trailers, music videos and cutscenes live in the studio
4
+ repository as folders plus one manifest per kind. The manifests are the contract between
5
+ whatever made the media (the Homie plugin's `music` and `video` skills, or a person) and the
6
+ studio site, which turns every published entry into a page:
7
+
8
+ | Manifest | Page | Listed in |
9
+ | --- | --- | --- |
10
+ | `music/manifest.json` | `/music/<slug>/` (an audio player) | the home page, `/music/`, `/.well-known/homie-studio.json` `songs` |
11
+ | `videos/manifest.json` | `/videos/<slug>/` (a video player) | the home page, `/videos/`, `/.well-known/homie-studio.json` `videos` |
12
+
13
+ `homie-studio build` reads both manifests. `homie-studio media list` prints what the site will
14
+ show and why anything is left out.
15
+
16
+ ## An entry
17
+
18
+ ```json
19
+ {
20
+ "v": 1,
21
+ "items": [
22
+ {
23
+ "slug": "theme",
24
+ "kind": "song",
25
+ "title": "Night Owls Theme",
26
+ "blurb": "The studio's theme: a short sung hook over a driving synth line.",
27
+ "published": true,
28
+ "duration": 16.0,
29
+ "bpm": 120,
30
+ "key": "A minor",
31
+ "files": [
32
+ { "role": "audio", "path": "music/theme/theme.mp3", "type": "audio/mpeg" },
33
+ { "role": "cover", "path": "music/theme/cover.jpg", "type": "image/jpeg" },
34
+ { "role": "loop", "path": "music/theme/theme-loop.ogg", "type": "audio/ogg", "bars": 8 },
35
+ { "role": "master", "path": "music/theme/theme-master.wav", "type": "audio/wav", "public": false }
36
+ ],
37
+ "lyrics": "…",
38
+ "credits": "Music made with Eleven Music (ElevenLabs).",
39
+ "rights": { "provider": "elevenlabs", "plan": "creator", "commercial": true, "attribution": null },
40
+ "for": { "game": "crown-thief" },
41
+ "made": { "provider": "elevenlabs", "model": "music_v2", "at": "2026-09-30T02:00:00Z", "receipt": "music/theme/receipt.json" }
42
+ }
43
+ ]
44
+ }
45
+ ```
46
+
47
+ - The manifest's order is the site's order (the plugin's skills put a new entry first).
48
+ - `slug`: lowercase letters, digits and hyphens, up to 40; it is the page address and never changes
49
+ once published. `title` is required for a page; an entry without `slug` and `title` is kept in
50
+ the manifest but gets no page.
51
+ - `kind`: music is `song`, `score`, `loop`, `stem` or `sfx`; video is `trailer`, `music-video`,
52
+ `cutscene` or `clip`.
53
+ - `published`: only `true` entries get a page and a directory listing. Work in progress stays
54
+ `false`.
55
+ - `files[].role`: music uses `audio` (what the player plays), `cover`, `loop`, `stem`,
56
+ `master`; video uses `video` (the 16:9 cut the player plays), `vertical` (the 9:16 cut),
57
+ `poster`, `captions` (WebVTT), `master`. `"public": false` keeps a file off the site (masters,
58
+ raw captures). Loops and stems are offered for download on the song page, so another game can
59
+ use them.
60
+ - `rights` and `credits` are shown on the page, in plain words. Say what the provider's terms
61
+ say for the plan the file was made on; nothing on the site claims more.
62
+
63
+ ## Where the bytes come from
64
+
65
+ A studio needs no storage for songs and videos: the site serves them itself. For every public
66
+ file of a published entry, the build picks the first that exists:
67
+
68
+ 1. `key`: the file is in the studio's storage, an R2 bucket that `homie-studio storage add`
69
+ creates (Cloudflare asks for a payment method on the account before R2 works, so the person
70
+ agrees first) and `homie-studio media put <file>` uploads to; the site serves it at
71
+ `/media/<key>`, with byte ranges so phones can seek.
72
+ 2. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when
73
+ it is 25 MiB or smaller (Cloudflare's limit for one static file), and the Worker answers byte
74
+ ranges for it too. This is the default, and it needs nothing but a deploy.
75
+ 3. `url`: an absolute `https://` address somewhere else.
76
+
77
+ A file with none of these is left out and `media list` says so. Media never goes into git: the
78
+ studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`, `*.mov` and `*.webm` under
79
+ `music/` and `videos/` out of the repository, so without storage the site is deployed from the
80
+ computer that has the files. Keep deliveries under 25 MiB (the video skill's cuts do) or add
81
+ storage for bigger ones.