@homie-rocks/studio 0.2.0 → 0.3.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
@@ -80,6 +80,10 @@ studio's pinned copy, never a registry lookup of the bare name.
80
80
 
81
81
  - \`npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"\` — a new game from a
82
82
  multiplayer starter (one live public room from its first build, bots fill seats).
83
+ - \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
84
+ how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
85
+ owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
86
+ plugin's \`port\` skill does the whole job.
83
87
  - \`npm run build\` — bundle every game into \`site/dist\`.
84
88
  - \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
85
89
  Durable Object, D1): open the printed address in two browsers and they share a room.
@@ -153,6 +157,8 @@ site/.wrangler/
153
157
  .dev.vars
154
158
  .env
155
159
  *.log
160
+ # Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
161
+ games/*/.port/
156
162
  # Large media lives in this studio's R2; the manifests beside it are committed.
157
163
  music/**/*.wav
158
164
  music/**/*.mp3
@@ -254,6 +254,17 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
254
254
  readonly spectator: boolean;
255
255
  readonly offline: boolean;
256
256
  readonly connected: boolean;
257
+ /** Why the client stopped for good (a refusal reconnecting would repeat: 'replaced', 'room-full', ...), else null. */
258
+ readonly closedWhy: string | null;
259
+ /** How long the socket has been down while it reconnects (ms; 0 while connected). */
260
+ readonly downMs: number;
261
+ /**
262
+ * Host: hand the room to another browser — a checkpoint, then the protocol's `yield` (the same road a hidden
263
+ * tab takes). The relay elects the best other candidate, or keeps me host when nobody else can host.
264
+ * Use it when a big screen or a struggling phone should give the room to a seated computer (never in a
265
+ * host's first 30 s, at most once a minute); false when not sent.
266
+ */
267
+ handOff(): boolean;
257
268
  /** Seated replica: true while my browser moves my body; false while the host does (host movement, or taken). */
258
269
  readonly owned: boolean;
259
270
  readonly id: string | null;
@@ -476,8 +487,26 @@ export class Roster {
476
487
  /* --------------------------------------------------------------- the client */
477
488
 
478
489
  const LADDER = [250, 500, 1000, 2000, 4000];
479
- /** Relay errors after which reconnecting would only repeat the refusal. */
480
- const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'flood']);
490
+ /**
491
+ * Relay errors after which reconnecting would only repeat the refusal. A 'flood' kick is NOT one: a replica's
492
+ * inputs bunch up behind a stall it did not cause (a host migration, a relay or network hiccup that delivers a
493
+ * second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
494
+ * After one it comes back (its seat token keeps its body), sending at the slow rate below.
495
+ */
496
+ const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many']);
497
+ /**
498
+ * Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
499
+ * the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
500
+ * stall anywhere between) still fit under the cap.
501
+ */
502
+ const IN_CAP_PER_S = 32;
503
+ /** Longest a page with a live socket listens for the relay's welcome before it plays alone (ms of time it could listen). */
504
+ const CONNECT_OPEN_MAX_MS = 10_000;
505
+ /** After the relay reports dropped inputs (or kicked this browser for them): the cap for the next while. */
506
+ const IN_SLOW_CAP_PER_S = 15;
507
+ const IN_SLOW_MS = 8000;
508
+ /** A socket with this much still unsent does not queue another input frame (the newest one waits instead). */
509
+ const IN_BACKLOG_BYTES = 2048;
481
510
 
482
511
  function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
483
512
  if (!held) return [];
@@ -606,6 +635,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
606
635
  let prevHeld = new Set<string>();
607
636
  let pendingPresses: Record<string, number> = {};
608
637
  let inputTimer: ReturnType<typeof setTimeout> | null = null;
638
+ /** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
639
+ const inSent: number[] = [];
640
+ let inSlowUntil = 0;
641
+ /** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
642
+ let closedWhy: string | null = null;
643
+ /** Wall time the socket went down (0 while connected): how long a reconnect has taken. */
644
+ let downSince = 0;
609
645
  const sentHist: { q: number; a: A; h: string[]; at: number; r: number }[] = [];
610
646
  const inputs = new Map<number, InputFrame<A>>();
611
647
  const presses = new Map<number, Record<string, number>>();
@@ -798,12 +834,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
798
834
  }
799
835
  case 'error': {
800
836
  const code = String(m['code'] ?? '');
837
+ // Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
838
+ if ((code === 'rate' && m['of'] === 'in') || code === 'flood') inSlowUntil = wall() + IN_SLOW_MS;
801
839
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
802
840
  console.warn('[netplay] relay refused:', code, m['message']);
803
841
  // The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
804
842
  // would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
805
843
  if (FINAL_ERRORS.has(code)) {
806
844
  closed = true;
845
+ closedWhy = code;
807
846
  post?.({ what: 'closed', why: code });
808
847
  }
809
848
  return;
@@ -846,6 +885,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
846
885
  if (next === 'host' && !snap && !continuing) snap = localSnap();
847
886
  offline = false;
848
887
  connected = true;
888
+ downSince = 0;
849
889
  attempt = 0;
850
890
  emit('status', true);
851
891
  post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
@@ -897,10 +937,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
897
937
  if (ages.length > 40) ages.shift();
898
938
  const sorted = [...ages].sort((x, y) => x - y);
899
939
  ageP90 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9))] as number;
940
+ // The 95th percentile and 1.4 intervals, not the 90th and 1.2. A phone host's frame
941
+ // quantisation (snapshots at 50/67 ms) plus 30-50 ms relay jitter held the picture on 5-8 % of a desktop
942
+ // replica's frames in the contract e2e (interpolation-not-starved); this buys ~20 ms of delay for it.
943
+ const ageP95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))] as number;
900
944
  // Render far enough back that the next snapshot is normally here before we need it: its age, plus one
901
945
  // interval. Rise up to 6 ms a snapshot (a hold is visible); fall 6% of the excess a snapshot, at least 3 ms
902
946
  // (boot-time jank clears in about a second, as a brief time-warp nobody sees).
903
- const target = Math.max(50, Math.min(400, ageP90 + iv * 1.2 + 4));
947
+ const target = Math.max(50, Math.min(400, ageP95 + iv * 1.4 + 6));
904
948
  if (!delaySmooth || ages.length <= 3) delaySmooth = target;
905
949
  else if (target > delaySmooth) delaySmooth += Math.min(6, target - delaySmooth);
906
950
  else delaySmooth -= Math.min(delaySmooth - target, Math.max(3, (delaySmooth - target) * 0.06));
@@ -1010,9 +1054,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1010
1054
  raw({ t: 'ckpt', k: c.k, st: c.st, d: c.d, c: c.c });
1011
1055
  }
1012
1056
 
1057
+ /** How long until one more input frame fits the rolling-second cap (0: now). */
1058
+ function inputBudgetWait(): number {
1059
+ const t = wall();
1060
+ while (inSent.length && (inSent[0] as number) <= t - 1000) inSent.shift();
1061
+ const cap = t < inSlowUntil ? IN_SLOW_CAP_PER_S : IN_CAP_PER_S;
1062
+ if (inSent.length < cap) return 0;
1063
+ return Math.max(1, (inSent[inSent.length - cap] as number) + 1000 - t);
1064
+ }
1065
+
1013
1066
  function flushInput(): void {
1014
1067
  inputTimer = null;
1015
1068
  if (role === 'host' || seat === null || offline || !connected || pendingA === undefined) return;
1069
+ // Never a burst: over the cap, or with the last frames still unsent on the socket, the newest frame waits
1070
+ // (it replaces whatever was pending, so nothing stale is ever flushed after a stall).
1071
+ const budget = inputBudgetWait();
1072
+ const backlog = (ws?.bufferedAmount ?? 0) > IN_BACKLOG_BYTES;
1073
+ if (budget > 0 || backlog) { inputTimer = setTimeout(flushInput, backlog ? Math.max(budget, 30) : budget); return; }
1074
+ inSent.push(wall());
1016
1075
  inSeq += 1;
1017
1076
  const msg: Record<string, unknown> = { t: 'in', q: inSeq, a: pendingA, h: pendingHeld, r: mine.rs };
1018
1077
  if (Object.keys(pendingPresses).length) msg['p'] = pendingPresses;
@@ -1026,7 +1085,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1026
1085
 
1027
1086
  function scheduleInput(urgent: boolean): void {
1028
1087
  const since = wall() - lastInputSentAt;
1029
- const wait = urgent ? Math.max(0, 16 - since) : Math.max(0, inputMs - since);
1088
+ const wait = Math.max(inputBudgetWait(), urgent ? Math.max(0, 16 - since) : Math.max(0, inputMs - since));
1030
1089
  if (wait <= 0) { if (inputTimer) { clearTimeout(inputTimer); inputTimer = null; } flushInput(); return; }
1031
1090
  if (inputTimer) return;
1032
1091
  inputTimer = setTimeout(flushInput, wait);
@@ -1071,6 +1130,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1071
1130
  if (why !== 'closed') { try { sock.close(4000, why); } catch { /* gone */ } }
1072
1131
  const was = connected;
1073
1132
  connected = false;
1133
+ if (was) downSince = wall();
1074
1134
  if (was) emit('status', false);
1075
1135
  notifyStats(true);
1076
1136
  retry();
@@ -1106,8 +1166,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1106
1166
  } else {
1107
1167
  post?.({ what: 'attached', v: NETPLAY_VERSION });
1108
1168
  open();
1109
- // A relay that never answers must not leave a game on a black screen.
1110
- connectTimer = setTimeout(() => { if (!roleKnown) goOfflineHost('relay-timeout'); }, connectTimeoutMs);
1169
+ // A relay that never answers must not leave a game on a black screen. But the wait is counted only while
1170
+ // this page is able to listen: a phone compiling shaders under load is blocked for seconds at a time, and
1171
+ // neither its socket's open nor the relay's welcome can be handled meanwhile. Giving up on the wall clock
1172
+ // started a private fight 1 at every loaded boot and swapped it for the room's a moment later. A socket
1173
+ // that is connecting or open gets CONNECT_OPEN_MAX_MS of that listening time; one that failed outright
1174
+ // (ws is null between retries) gets connectTimeoutMs.
1175
+ let listenedMs = 0;
1176
+ let lastCheckAt = wall();
1177
+ const connectCheck = (): void => {
1178
+ if (roleKnown) return;
1179
+ const t = wall();
1180
+ listenedMs += Math.min(t - lastCheckAt, 600);
1181
+ lastCheckAt = t;
1182
+ const live = Boolean(ws && (ws.readyState === 0 || ws.readyState === 1));
1183
+ if (listenedMs < (live ? CONNECT_OPEN_MAX_MS : connectTimeoutMs)) { connectTimer = setTimeout(connectCheck, 250); return; }
1184
+ goOfflineHost('relay-timeout');
1185
+ };
1186
+ connectTimer = setTimeout(connectCheck, 250);
1111
1187
  pingTimer = setInterval(ping, 2000);
1112
1188
  ckptTimer = setInterval(sendCheckpoint, checkpointMs);
1113
1189
  statsTimer = setInterval(() => notifyStats(true), 500);
@@ -1133,6 +1209,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1133
1209
  get spectator() { return seat === null && !offline; },
1134
1210
  get offline() { return offline; },
1135
1211
  get connected() { return connected; },
1212
+ get closedWhy() { return closedWhy; },
1213
+ get downMs() { return connected || !downSince ? 0 : wall() - downSince; },
1136
1214
  get owned() { return role === 'host' || offline || seat === null ? true : mine.own; },
1137
1215
  get id() { return id; },
1138
1216
  get name() { return name; },
@@ -1161,8 +1239,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1161
1239
  const c = ctlWire();
1162
1240
  const text = JSON.stringify(c.length ? { t: 'snap', k: tick, st, d, c } : { t: 'snap', k: tick, st, d });
1163
1241
  if (!ws || ws.readyState !== 1) return false;
1164
- // A congested socket drops a snapshot rather than queueing a stale one.
1165
- if ((ws.bufferedAmount ?? 0) > 256 * 1024) return false;
1242
+ // A congested socket drops a snapshot rather than queueing a stale one. At 3 KB (about six
1243
+ // snapshots), not 256 KB. A starved network thread on a loaded phone held a second of snapshots and then
1244
+ // flushed them at once; the relay counted 30+ in a second, dropped them, and called the host stalled.
1245
+ if ((ws.bufferedAmount ?? 0) > 3 * 1024) return false;
1166
1246
  try { ws.send(text); } catch { return false; }
1167
1247
  countBytes(bytesOut, text.length);
1168
1248
  lastSnapSentAt = wall();
@@ -1173,6 +1253,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1173
1253
  return true;
1174
1254
  },
1175
1255
  checkpointNow: sendCheckpoint,
1256
+ handOff() {
1257
+ if (role !== 'host' || offline || !connected || peers.size < 2) return false;
1258
+ sendCheckpoint();
1259
+ return raw({ t: 'yield' });
1260
+ },
1176
1261
  round(r) {
1177
1262
  roundInfo = r;
1178
1263
  post?.({ what: 'round', round: r });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.2.0",
4
- "description": "A game studio on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, the site Worker with public rooms (Table/Lobby Durable Objects, D1, R2), deploy, and the homie.rocks directory.",
3
+ "version": "0.3.0",
4
+ "description": "A game studio on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects, D1, R2), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "homepage": "https://homie.rocks/studio/",
@@ -18,6 +18,14 @@
18
18
  "types": "./netplay/netplay.ts",
19
19
  "default": "./netplay/netplay.ts"
20
20
  },
21
+ "./port": {
22
+ "types": "./port/index.ts",
23
+ "default": "./port/index.ts"
24
+ },
25
+ "./port/early": {
26
+ "types": "./port/early.ts",
27
+ "default": "./port/early.ts"
28
+ },
21
29
  "./worker": "./worker/index.mjs",
22
30
  "./package.json": "./package.json"
23
31
  },
@@ -25,6 +33,7 @@
25
33
  "bin/",
26
34
  "lib/",
27
35
  "netplay/",
36
+ "port/",
28
37
  "worker/",
29
38
  "starters/",
30
39
  "README.md",
package/port/audio.ts ADDED
@@ -0,0 +1,96 @@
1
+ /*
2
+ * Sound that starts on the first touch, with no "tap for sound" screen.
3
+ *
4
+ * Browsers start every AudioContext suspended and refuse media.play() until a
5
+ * person has touched the page. A single-player game usually handled that with
6
+ * a title screen; a multiplayer port drops the player straight into a live
7
+ * round, so the unlock has to ride the first real input instead:
8
+ *
9
+ * - every AudioContext the game makes is remembered (the constructor is
10
+ * wrapped, so install this BEFORE the game's code runs);
11
+ * - on pointerdown / pointerup / touchstart / touchend / keydown / click
12
+ * (capture phase, so a game that stops propagation still unlocks), every
13
+ * suspended context is resumed inside the gesture and plays one silent
14
+ * sample (iOS needs a sound started inside the gesture);
15
+ * - a media element whose play() was refused is played again on that gesture;
16
+ * - iOS's silent switch mutes Web Audio unless the audio session says
17
+ * "playback".
18
+ * A held stick only counts as a gesture when the finger lifts (browsers grant
19
+ * activation on touchend), so the first tap or the first release is when sound
20
+ * begins. Say so nowhere: it just works.
21
+ */
22
+
23
+ type Ctx = AudioContext;
24
+ const contexts = new Set<Ctx>();
25
+ const refused = new Set<HTMLMediaElement>();
26
+ let installed = false;
27
+ let unlockedAt = 0;
28
+ let gestures = 0;
29
+
30
+ function silentTick(ctx: Ctx): void {
31
+ try {
32
+ const b = ctx.createBuffer(1, 1, 22050);
33
+ const s = ctx.createBufferSource();
34
+ s.buffer = b;
35
+ s.connect(ctx.destination);
36
+ s.start(0);
37
+ } catch { /* a closed context */ }
38
+ }
39
+
40
+ function onGesture(): void {
41
+ gestures += 1;
42
+ for (const ctx of contexts) {
43
+ if (ctx.state === 'closed') { contexts.delete(ctx); continue; }
44
+ if (ctx.state !== 'running') { void ctx.resume().catch(() => {}); silentTick(ctx); }
45
+ }
46
+ for (const el of [...refused]) {
47
+ refused.delete(el);
48
+ const p = realPlay?.call(el);
49
+ if (p && typeof p.catch === 'function') p.catch(() => { refused.add(el); });
50
+ }
51
+ if (!unlockedAt && [...contexts].some((c) => c.state === 'running')) unlockedAt = performance.now();
52
+ }
53
+
54
+ let realPlay: (() => Promise<void>) | null = null;
55
+
56
+ /** Wrap AudioContext and media play() so the first gesture unlocks everything. Idempotent. */
57
+ export function installAudioUnlock(): void {
58
+ if (installed || typeof window === 'undefined') return;
59
+ installed = true;
60
+ const w = window as unknown as Record<string, unknown>;
61
+ for (const name of ['AudioContext', 'webkitAudioContext']) {
62
+ const Real = w[name] as (new (...a: unknown[]) => Ctx) | undefined;
63
+ if (typeof Real !== 'function') continue;
64
+ // A subclass keeps instanceof, static members and every method; it only remembers the instance.
65
+ const Tracked = class extends (Real as new (...a: unknown[]) => Ctx) {
66
+ constructor(...args: unknown[]) { super(...args); contexts.add(this as unknown as Ctx); }
67
+ };
68
+ try { Object.defineProperty(window, name, { value: Tracked, configurable: true, writable: true }); } catch { /* keep the real one */ }
69
+ }
70
+ try {
71
+ const session = (navigator as unknown as { audioSession?: { type: string } }).audioSession;
72
+ if (session) session.type = 'playback';
73
+ } catch { /* not iOS */ }
74
+ if (typeof HTMLMediaElement !== 'undefined') {
75
+ realPlay = HTMLMediaElement.prototype.play;
76
+ const play = realPlay;
77
+ HTMLMediaElement.prototype.play = function patchedPlay(this: HTMLMediaElement): Promise<void> {
78
+ const p = play.call(this);
79
+ if (!p || typeof p.catch !== 'function') return p;
80
+ // Refused for want of a gesture: it plays on the first one. Any other failure is the game's to see.
81
+ return p.catch((e: unknown) => {
82
+ if (e && (e as { name?: string }).name === 'NotAllowedError') { refused.add(this); return undefined; }
83
+ throw e;
84
+ }) as Promise<void>;
85
+ };
86
+ }
87
+ for (const ev of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'mousedown', 'keydown', 'click']) {
88
+ window.addEventListener(ev, onGesture, { capture: true, passive: true });
89
+ }
90
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') for (const c of contexts) if (c.state === 'suspended' && gestures) void c.resume().catch(() => {}); });
91
+ }
92
+
93
+ /** What the check harness reads: each context's state, and when the first one started running. */
94
+ export function audioReport(): { contexts: string[]; refusedMedia: number; gestures: number; unlockedAtMs: number | null } {
95
+ return { contexts: [...contexts].map((c) => c.state), refusedMedia: refused.size, gestures, unlockedAtMs: unlockedAt || null };
96
+ }