@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/README.md CHANGED
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
10
10
  and the person approves Cloudflare once in their browser.
11
11
 
12
12
  ```sh
13
- npx -y --package=https://homie.rocks/npm/homie-studio-0.2.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.3.0.tgz homie-studio new ./night-owls --name "Night Owls"
14
14
  cd night-owls && npm install
15
15
  npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
@@ -19,6 +19,15 @@ npx homie-studio deploy # the studio's own Cloudf
19
19
  npx homie-studio publish # the homie.rocks directory
20
20
  ```
21
21
 
22
+ An existing single-player web game becomes a multiplayer one with the port toolkit
23
+ (the plugin's `port` skill drives it):
24
+
25
+ ```sh
26
+ npx homie-studio port plan ../my-game # grade it: easy, medium, hard or not a fit, and why
27
+ npx homie-studio port import ../my-game --id my-game # bring it into games/ (static, bundle or its own build)
28
+ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner tests, real touch, host kill, late joiner, a round
29
+ ```
30
+
22
31
  | Path | What it is |
23
32
  | --- | --- |
24
33
  | `bin/homie-studio.mjs` | The CLI. |
@@ -26,7 +35,10 @@ npx homie-studio publish # the homie.rocks directo
26
35
  | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
27
36
  | `lib/cloudflare.mjs` | `deploy`: Wrangler, D1, R2, migrations, the directory claim; refuses resources it did not create. |
28
37
  | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
29
- | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects. |
38
+ | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
39
+ | `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
40
+ | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
41
+ | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`). |
30
42
  | `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
31
43
  | `starters/gem-rush/` | The reference multiplayer starter. |
32
44
 
@@ -11,6 +11,9 @@
11
11
  * homie-studio build [<id>]
12
12
  * homie-studio dev [--port 8787]
13
13
  * homie-studio check <id> [--url <site>] [--shots <dir>]
14
+ * homie-studio port plan <game folder>
15
+ * homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
16
+ * homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
14
17
  * homie-studio deploy
15
18
  * homie-studio publish
16
19
  * homie-studio media put <file> [--as <key>]
@@ -25,19 +28,22 @@ import { build } from '../lib/build.mjs';
25
28
  import { check } from '../lib/check.mjs';
26
29
  import { deploy, whoami, wranglerBin } from '../lib/cloudflare.mjs';
27
30
  import { publish } from '../lib/directory.mjs';
31
+ import { importPort, planPort } from '../lib/port.mjs';
32
+ import { portCheck } from '../lib/port-check.mjs';
28
33
  import { newStudio } from '../lib/scaffold.mjs';
29
34
  import { listGames, newGame, readStudio, remixGame, requireStudio, starters, writeStudio } from '../lib/studio.mjs';
30
35
  import { STUDIO_VERSION } from '../lib/version.mjs';
31
36
 
32
37
  const argv = process.argv.slice(2);
33
38
  const flags = new Map();
39
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install'];
34
40
  const positional = [];
35
41
  for (let i = 0; i < argv.length; i++) {
36
42
  const a = argv[i];
37
43
  if (a.startsWith('--')) {
38
44
  const [k, v] = a.slice(2).split(/=(.*)/s, 2);
39
45
  if (v !== undefined) flags.set(k, v);
40
- else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && !['json', 'yes', 'detach', 'no-install'].includes(k)) flags.set(k, argv[++i]);
46
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && !BOOL_FLAGS.includes(k)) flags.set(k, argv[++i]);
41
47
  else flags.set(k, true);
42
48
  } else positional.push(a);
43
49
  }
@@ -46,7 +52,8 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
46
52
 
47
53
  function print(result) {
48
54
  if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
49
- if (result.ok === false) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
55
+ if (result.ok === false && result.command !== 'port check') { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
56
+ if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
50
57
  const lines = [];
51
58
  switch (result.command) {
52
59
  case 'new':
@@ -68,6 +75,22 @@ function print(result) {
68
75
  case 'publish':
69
76
  lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`));
70
77
  break;
78
+ case 'port plan': {
79
+ const f = result.facts;
80
+ lines.push(`Port plan for ${result.folder}`, '', `Difficulty: ${result.grade.toUpperCase()}`, ...result.reasons.map((r) => ` - ${r}`), '',
81
+ `What it is: ${f.engine.join(', ')}; ${f.loc} lines of game code in ${f.files} files (${Math.round(f.bytes / 1024)} KB); ${f.turnBased ? 'turn-based / moves on input' : 'real time'}${f.physics.length ? `; physics: ${f.physics.join(', ')}` : ''}.`,
82
+ `Input: keys ${f.input.keys}, mouse ${f.input.mouse}, touch ${f.input.touch}, pointer ${f.input.pointer}${f.input.pointerLock ? ', pointer lock' : ''}.`,
83
+ `Recommended: movement "${result.recommend.movement}", check view "${result.recommend.view}", build "${result.recommend.build}".`,
84
+ `Licence: ${result.licence.kind}${result.licence.file ? ` (${result.licence.file})` : ''}.`, '', 'Risks:', ...(result.risks.length ? result.risks.map((r) => ` - ${r}`) : [' - none found by reading; the checks will say']));
85
+ break;
86
+ }
87
+ case 'port import':
88
+ lines.push(`games/${result.id} is a port of ${result.from} (${result.files} files, build "${result.mode}", draft grade ${result.plan.grade}).`, ...result.edits.map((e) => ` ${e}`), '', 'Next:', ...result.next.map((n) => ` - ${n}`));
89
+ break;
90
+ case 'port check':
91
+ lines.push(`${result.ok ? 'PASS' : 'NOT YET'}: ${result.game} at ${result.url} (${Math.round(result.totalMs / 1000)} s)`,
92
+ ...result.passed.map((p) => ` ok ${p}`), ...result.failed.map((f) => ` FAIL ${f}`), ...result.skipped.map((f) => ` skip ${f}`), '', `Receipt and screenshots: ${result.out}`);
93
+ break;
71
94
  case 'check':
72
95
  lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
73
96
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -89,7 +112,15 @@ async function main() {
89
112
  if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install') });
90
113
  if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
91
114
 
115
+ if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
92
116
  const root = requireStudio();
117
+ if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
118
+ if (cmd === 'port' && sub === 'check') {
119
+ const game = positional[2] ?? listGames(root)[0]?.id;
120
+ const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
121
+ if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
122
+ return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
123
+ }
93
124
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
94
125
  if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
95
126
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
@@ -159,7 +190,7 @@ try {
159
190
  if (result && result.command !== 'help') print(result);
160
191
  if (result?.ok === false) process.exitCode = 1;
161
192
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
162
- if (result?.command === 'check') process.exit(process.exitCode ?? 0);
193
+ if (result?.command === 'check' || result?.command === 'port check') process.exit(process.exitCode ?? 0);
163
194
  } catch (error) {
164
195
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
165
196
  process.exitCode = 1;
package/lib/build.mjs CHANGED
@@ -6,14 +6,51 @@
6
6
  * site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
7
7
  * site/dist/games/<id>/... everything in games/<id>/public/
8
8
  * site/dist/games.json { studio, games[] } from studio.json and game.json files
9
+ *
10
+ * game.json "build" picks how a game becomes files (a ported game keeps its own shape):
11
+ * (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
12
+ * { "mode": "static" } the folder copied as it is (plain <script> games), plus homie-port.js,
13
+ * the port toolkit as one classic script (window.HomiePort); an "entry"
14
+ * is bundled to assets/main.js as well
15
+ * { "mode": "command", "command": "npm run build", "out": "dist" }
16
+ * the game's own build (Vite, webpack…), then its output copied; base must be './'
9
17
  */
18
+ import { spawnSync } from 'node:child_process';
10
19
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
20
  import { createRequire } from 'node:module';
12
- import { join } from 'node:path';
13
- import { listGames, readStudio } from './studio.mjs';
21
+ import { join, relative } from 'node:path';
22
+ import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
14
23
  import { STUDIO_VERSION } from './version.mjs';
15
24
 
16
- const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler']);
25
+ const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
26
+ /** Never copied into a static game's served folder. */
27
+ const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md']);
28
+ 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' };
29
+
30
+ /** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
31
+ async function portScript(esbuild, root, cache) {
32
+ if (cache.text) return cache.text;
33
+ const entry = join(PACKAGE_ROOT, 'port', 'global.ts');
34
+ const res = await esbuild.build({ entryPoints: [entry], bundle: true, format: 'iife', target: 'es2020', minify: true, write: false, absWorkingDir: root, logLevel: 'silent' });
35
+ cache.text = `/* homie-port.js — @homie-rocks/studio port toolkit (window.HomiePort). Load it first in <head>. */\n${res.outputFiles[0].text}`;
36
+ return cache.text;
37
+ }
38
+
39
+ function copyStatic(from, to) {
40
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
41
+ if (STATIC_SKIP.has(entry.name)) continue;
42
+ const src = join(from, entry.name);
43
+ const dest = join(to, entry.name);
44
+ if (entry.isDirectory()) { mkdirSync(dest, { recursive: true }); copyStatic(src, dest); }
45
+ else if (entry.isFile()) cpSync(src, dest);
46
+ }
47
+ }
48
+
49
+ function dirBytes(dir) {
50
+ let n = 0;
51
+ for (const e of readdirSync(dir, { withFileTypes: true })) n += e.isDirectory() ? dirBytes(join(dir, e.name)) : statSync(join(dir, e.name)).size;
52
+ return n;
53
+ }
17
54
  const TEXT = /\.(ts|tsx|js|mjs|jsx|json|html|css|md|txt|svg|glsl|wgsl|frag|vert)$/i;
18
55
  /** Text files of a game folder, capped (2 MB total, 512 KB each): what `homie-studio game remix` takes. */
19
56
  export function sourceOf(dir, id) {
@@ -47,31 +84,55 @@ export async function build(root, { only = null, log = () => {} } = {}) {
47
84
  if (!only) rmSync(dist, { recursive: true, force: true });
48
85
  mkdirSync(dist, { recursive: true });
49
86
  const built = [];
87
+ const cache = {};
50
88
  for (const g of games) {
51
89
  const out = join(dist, 'games', g.id);
52
90
  rmSync(out, { recursive: true, force: true });
53
91
  mkdirSync(join(out, 'assets'), { recursive: true });
54
- const entry = join(g.dir, g.entry ?? 'src/main.ts');
55
- if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${g.entry ?? 'src/main.ts'} not found`);
92
+ const mode = g.build?.mode ?? 'bundle';
56
93
  const started = Date.now();
57
- const result = await esbuild.build({
58
- entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: false,
59
- outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
60
- loader: { '.png': 'file', '.jpg': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.glb': 'file', '.svg': 'file' },
61
- assetNames: '[name]-[hash]',
62
- }).catch((error) => {
63
- const first = error.errors?.[0];
64
- throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
65
- });
66
- const html = join(g.dir, 'index.html');
67
- if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
68
- writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
69
- if (existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
94
+ let warnings = 0;
95
+ const bundle = async (entryRel) => {
96
+ const entry = join(g.dir, entryRel);
97
+ if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
98
+ const result = await esbuild.build({
99
+ entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: false,
100
+ outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
101
+ loader: LOADERS, assetNames: '[name]-[hash]',
102
+ }).catch((error) => {
103
+ const first = error.errors?.[0];
104
+ throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
105
+ });
106
+ warnings += result.warnings.length;
107
+ };
108
+ if (mode === 'static') {
109
+ copyStatic(g.dir, out);
110
+ writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
111
+ if (g.entry) await bundle(g.entry);
112
+ const html = readFileSync(join(out, 'index.html'), 'utf8');
113
+ 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>`);
114
+ } else if (mode === 'command') {
115
+ const command = String(g.build.command ?? 'npm run build');
116
+ const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
117
+ if (res.status !== 0) throw new Error(`games/${g.id}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
118
+ const built = join(g.dir, String(g.build.out ?? 'dist'));
119
+ if (!existsSync(join(built, 'index.html'))) throw new Error(`games/${g.id}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
120
+ cpSync(built, out, { recursive: true });
121
+ writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
122
+ } else {
123
+ await bundle(g.entry ?? 'src/main.ts');
124
+ const html = join(g.dir, 'index.html');
125
+ if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
126
+ writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
127
+ }
128
+ if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
129
+ if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
70
130
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
71
131
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
72
- const bytes = statSync(join(out, 'assets', 'main.js')).size;
73
- built.push({ id: g.id, name: g.name, bytes, ms: Date.now() - started, warnings: result.warnings.length });
74
- log(`built ${g.id} (${Math.round(bytes / 1024)} KB)`);
132
+ const main = join(out, 'assets', 'main.js');
133
+ const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
134
+ built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings });
135
+ log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
75
136
  }
76
137
  const all = listGames(root);
77
138
  const catalogue = {
@@ -79,6 +140,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
79
140
  games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => ({
80
141
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: g.players ?? { min: 1, max: 8 },
81
142
  roundSeconds: g.roundSeconds ?? null, movement: g.netplay?.movement ?? null, cover: g.cover ?? null,
143
+ ...(g.screen ? { screen: g.screen } : {}),
82
144
  })),
83
145
  };
84
146
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);