@homie-rocks/studio 0.40.0 → 0.43.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +32 -9
  3. package/app-starters/welcome/app.json +8 -1
  4. package/app-starters/welcome/map/main.json +1 -0
  5. package/app-starters/welcome/src/move.ts +3 -0
  6. package/app-starters/welcome/src/rules.ts +21 -0
  7. package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
  8. package/bin/homie-studio.mjs +11 -3
  9. package/functions/FUNCTIONS.md +99 -0
  10. package/functions/functions.ts +13 -0
  11. package/lib/build.mjs +32 -12
  12. package/lib/cloudflare.mjs +9 -9
  13. package/lib/codex.mjs +1 -1
  14. package/lib/dev.mjs +2 -1
  15. package/lib/functions-build.mjs +36 -0
  16. package/lib/functions-cli.mjs +23 -0
  17. package/lib/mcp-tools.mjs +10 -7
  18. package/lib/mcp.mjs +2 -1
  19. package/lib/port-check.mjs +7 -4
  20. package/lib/port.mjs +2 -2
  21. package/lib/purchase-links.mjs +8 -0
  22. package/lib/rules-build.mjs +6 -6
  23. package/lib/rules-guard.mjs +9 -3
  24. package/lib/scaffold.mjs +58 -15
  25. package/lib/shop.mjs +1 -1
  26. package/lib/studio.mjs +3 -3
  27. package/lib/template-history.json +148 -1
  28. package/lib/tools-build.mjs +2 -0
  29. package/lib/trailer.mjs +7 -3
  30. package/lib/typecheck.mjs +10 -8
  31. package/lib/upgrade.mjs +2 -0
  32. package/lib/worker-build.mjs +8 -20
  33. package/lib/worker-config.mjs +28 -0
  34. package/netplay/NETPLAY.md +8 -0
  35. package/package.json +7 -2
  36. package/rules/host.ts +2 -2
  37. package/rules/view.ts +1 -0
  38. package/shop/SHOP.md +4 -3
  39. package/starters/coin-dash/src/move.ts +2 -0
  40. package/starters/coin-dash/src/rules.ts +2 -0
  41. package/starters/coin-dash/src/view.ts +2 -0
  42. package/starters/ember-vale/src/move.ts +2 -0
  43. package/starters/ember-vale/src/rules.ts +2 -0
  44. package/starters/ember-vale/src/view.ts +2 -0
  45. package/starters/gem-rush/src/move.ts +2 -0
  46. package/starters/gem-rush/src/rules.ts +2 -0
  47. package/starters/gem-rush/src/view.ts +2 -0
  48. package/starters/gem-rush-3d/src/move.ts +2 -0
  49. package/starters/gem-rush-3d/src/rules.ts +2 -0
  50. package/starters/gem-rush-3d/src/view.ts +2 -0
  51. package/starters/hero-rush-3d/src/move.ts +2 -0
  52. package/starters/hero-rush-3d/src/rules.ts +2 -0
  53. package/starters/hero-rush-3d/src/view.ts +2 -0
  54. package/tools/TOOLS.md +42 -2
  55. package/tools/tools.ts +3 -0
  56. package/worker/customer-mcp.mjs +77 -0
  57. package/worker/customer-resources.mjs +53 -0
  58. package/worker/customer-shop.mjs +54 -0
  59. package/worker/extensions.mjs +1 -0
  60. package/worker/functions-schema.mjs +55 -0
  61. package/worker/functions.mjs +93 -0
  62. package/worker/index.mjs +37 -2
  63. package/worker/machine-payments.mjs +1 -1
  64. package/worker/mcp-store.mjs +3 -2
  65. package/worker/mcp-tools.mjs +16 -4
  66. package/worker/mcp.mjs +31 -15
  67. package/worker/office-page.mjs +1 -1
  68. package/worker/players.mjs +5 -2
  69. package/worker/purchase-delivery.mjs +1 -0
  70. package/worker/purchase-hosted-readiness.mjs +0 -1
  71. package/worker/purchase-links.mjs +1 -1
  72. package/worker/purchase-machine.mjs +1 -0
  73. package/worker/purchase-office.mjs +1 -1
  74. package/worker/purchase-owner.mjs +0 -2
  75. package/worker/purchase-reconciliation.mjs +1 -0
  76. package/worker/purchase-routes.mjs +11 -17
  77. package/worker/purchase-store.mjs +1 -0
  78. package/worker/selling/runtime.mjs +4 -1
  79. package/worker/service-run.mjs +25 -0
  80. package/worker/service-terms.mjs +2 -0
  81. package/worker/shop-rules.mjs +9 -8
  82. package/worker/shop.mjs +22 -9
  83. package/worker/tool-webhook.mjs +1 -0
  84. package/worker/version.mjs +1 -1
package/lib/studio.mjs CHANGED
@@ -108,7 +108,7 @@ function listKind(root, kind) {
108
108
  /**
109
109
  * RULES ON THE SERVER (NETPLAY.md section 29). A game written as rules plus view says so in game.json with a `"room"`
110
110
  * object (where its rules run, its tick rate…; rules/rules.ts `roomSettings` checks them at build) and has a
111
- * src/rules.ts. Any other game is hosted by a player's browser, as before. The file alone decides nothing: games made
111
+ * src/rules.ts. Other games need the browser-hosted netplay contract checked by the build. The file alone decides nothing: games made
112
112
  * before this had their own src/rules.ts, with whatever they liked in it, and they build exactly as they did.
113
113
  */
114
114
  export const isRulesGame = (g) => g?.room != null && typeof g.room === 'object' && existsSync(join(g.dir, 'src', 'rules.ts'));
@@ -129,7 +129,7 @@ export function starters() {
129
129
  const dir = join(PACKAGE_ROOT, 'starters');
130
130
  return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
131
131
  const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
132
- // `example`: a starter that shows how something is written (coin-dash: rules on the server) and is not yet offered as the start of a new game.
132
+ // `example`: marks the smallest reference (coin-dash); all starters use server rules and can start a new game.
133
133
  return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds, ...(meta.example === true ? { example: true } : {}) };
134
134
  });
135
135
  }
@@ -309,7 +309,7 @@ export async function newApp(root, id, { name } = {}) {
309
309
  const meta = JSON.parse(readFileSync(file, 'utf8'));
310
310
  meta.id = id; meta.name = String(name ?? 'Welcome together').slice(0, 60);
311
311
  writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
312
- const main = join(dest, 'src/main.ts');
312
+ const main = join(dest, meta.entry ?? 'src/main.ts');
313
313
  writeFileSync(main, readFileSync(main, 'utf8').replaceAll("game: 'welcome'", `game: '${id}'`));
314
314
  return { ok: true, command: 'app new', id, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
315
315
  }
@@ -2199,6 +2199,68 @@
2199
2199
  }
2200
2200
  },
2201
2201
  "0.40.0": {
2202
+ "files": {
2203
+ "CLAUDE.md": "ddf2b2a0376202f6",
2204
+ "README.md": "684b3cca8001673c",
2205
+ "HANDOFF.md": "29337e2ace95b2e7",
2206
+ "games/README.md": "5ad32fe80c61f117",
2207
+ "music/README.md": "a3dc042dadd39df8",
2208
+ "videos/README.md": "16babc3437d5545b",
2209
+ "posts/README.md": "37841aae476bd540",
2210
+ "site/README.md": "732894273a32dc3f",
2211
+ "site/src/worker.mjs": [
2212
+ "34444a6ca4b14437",
2213
+ "fc90f3609cad18ba"
2214
+ ]
2215
+ },
2216
+ "sections": {
2217
+ "AGENTS.md": {
2218
+ "(intro)": "214aa46eb3a895d5",
2219
+ "## Layout": [
2220
+ "c18362ed28975d81",
2221
+ "f3a280b8c96cd068"
2222
+ ],
2223
+ "## Apps: one screen, roles and parts": [
2224
+ "4db0abe4cdfac09f",
2225
+ "be6f8fa45f93211a"
2226
+ ],
2227
+ "## Commands (all through the pinned CLI in node_modules)": [
2228
+ "a7f290579585368c",
2229
+ "c920ac84d44c872a"
2230
+ ],
2231
+ "## The site": "0cb8dd9a6f8a0096",
2232
+ "## Making games": [
2233
+ "2da7fa74c8884834",
2234
+ "9e44559de535d842"
2235
+ ],
2236
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2237
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2238
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2239
+ "## Servers and AI seats": [
2240
+ "98d08705087a33ec",
2241
+ "d0028361fe567add"
2242
+ ],
2243
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2244
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2245
+ "## Rules": "2e6b29afb85dcf7e",
2246
+ "## Telling Homie": "80b46fdd9e578db8",
2247
+ "## Beta": "9d02638207a26e73"
2248
+ }
2249
+ },
2250
+ "named": {
2251
+ "Homie Arcade": {
2252
+ "sections": {
2253
+ "AGENTS.md": {
2254
+ "## Commands (all through the pinned CLI in node_modules)": [
2255
+ "7cad8fd10f62fef1",
2256
+ "cae1df6235f51d17"
2257
+ ]
2258
+ }
2259
+ }
2260
+ }
2261
+ }
2262
+ },
2263
+ "0.41.0": {
2202
2264
  "files": {
2203
2265
  "CLAUDE.md": "ddf2b2a0376202f6",
2204
2266
  "README.md": "684b3cca8001673c",
@@ -2213,8 +2275,52 @@
2213
2275
  "sections": {
2214
2276
  "AGENTS.md": {
2215
2277
  "(intro)": "214aa46eb3a895d5",
2216
- "## Layout": "c18362ed28975d81",
2278
+ "## Layout": "f3a280b8c96cd068",
2217
2279
  "## Apps: one screen, roles and parts": "be6f8fa45f93211a",
2280
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2281
+ "## The site": "0cb8dd9a6f8a0096",
2282
+ "## Making games": "9e44559de535d842",
2283
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2284
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2285
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2286
+ "## Servers and AI seats": "d0028361fe567add",
2287
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2288
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2289
+ "## Rules": "2e6b29afb85dcf7e",
2290
+ "## Telling Homie": "80b46fdd9e578db8",
2291
+ "## Beta": "9d02638207a26e73"
2292
+ }
2293
+ },
2294
+ "named": {
2295
+ "Homie Arcade": {
2296
+ "sections": {
2297
+ "AGENTS.md": {
2298
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2299
+ }
2300
+ }
2301
+ }
2302
+ }
2303
+ },
2304
+ "0.42.0": {
2305
+ "files": {
2306
+ "CLAUDE.md": "ddf2b2a0376202f6",
2307
+ "README.md": "684b3cca8001673c",
2308
+ "HANDOFF.md": "29337e2ace95b2e7",
2309
+ "games/README.md": "5ad32fe80c61f117",
2310
+ "music/README.md": "a3dc042dadd39df8",
2311
+ "videos/README.md": "16babc3437d5545b",
2312
+ "posts/README.md": "37841aae476bd540",
2313
+ "site/README.md": "732894273a32dc3f",
2314
+ "site/src/worker.mjs": "34444a6ca4b14437"
2315
+ },
2316
+ "sections": {
2317
+ "AGENTS.md": {
2318
+ "(intro)": "214aa46eb3a895d5",
2319
+ "## Layout": "c18362ed28975d81",
2320
+ "## Apps: one screen, roles and parts": [
2321
+ "8596f2b25edda3e2",
2322
+ "be6f8fa45f93211a"
2323
+ ],
2218
2324
  "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2219
2325
  "## The site": "0cb8dd9a6f8a0096",
2220
2326
  "## Making games": "2da7fa74c8884834",
@@ -2238,6 +2344,47 @@
2238
2344
  }
2239
2345
  }
2240
2346
  }
2347
+ },
2348
+ "0.43.0": {
2349
+ "files": {
2350
+ "CLAUDE.md": "ddf2b2a0376202f6",
2351
+ "README.md": "684b3cca8001673c",
2352
+ "HANDOFF.md": "29337e2ace95b2e7",
2353
+ "games/README.md": "5ad32fe80c61f117",
2354
+ "music/README.md": "a3dc042dadd39df8",
2355
+ "videos/README.md": "16babc3437d5545b",
2356
+ "posts/README.md": "37841aae476bd540",
2357
+ "site/README.md": "732894273a32dc3f",
2358
+ "site/src/worker.mjs": "34444a6ca4b14437"
2359
+ },
2360
+ "sections": {
2361
+ "AGENTS.md": {
2362
+ "(intro)": "214aa46eb3a895d5",
2363
+ "## Layout": "f3a280b8c96cd068",
2364
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2365
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2366
+ "## The site": "0cb8dd9a6f8a0096",
2367
+ "## Making games": "9e44559de535d842",
2368
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2369
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2370
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2371
+ "## Servers and AI seats": "d0028361fe567add",
2372
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2373
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2374
+ "## Rules": "2e6b29afb85dcf7e",
2375
+ "## Telling Homie": "80b46fdd9e578db8",
2376
+ "## Beta": "9d02638207a26e73"
2377
+ }
2378
+ },
2379
+ "named": {
2380
+ "Homie Arcade": {
2381
+ "sections": {
2382
+ "AGENTS.md": {
2383
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2384
+ }
2385
+ }
2386
+ }
2387
+ }
2241
2388
  }
2242
2389
  }
2243
2390
  }
@@ -38,6 +38,7 @@ export async function buildTools(root,esbuild) {
38
38
  if(!['owner','signed-in','public'].includes(t.audience)&&!(t.audience&&typeof t.audience.app==='string'&&typeof t.audience.role==='string'))throw new Error(`tool ${t.name} needs an audience`);
39
39
  if(t.inputSchema?.type!=='object')throw new Error(`tool ${t.name} input schema must be an object`);
40
40
  validator.getValidator(t.inputSchema);
41
+ if(t.price && (t.kind==='prompt'||!Number.isSafeInteger(t.price.amount)||t.price.amount<=0||! /^[a-z]{3}$/.test(t.price.currency)))throw new Error(`tool ${t.name}: price needs a positive minor-unit amount and currency`);
41
42
  if(t.kind!==undefined&&!['tool','prompt'].includes(t.kind))throw new Error(`tool ${t.name}: invalid kind`);
42
43
  if(t.webhook&&(!/^[A-Z][A-Z0-9_]{0,63}$/.test(t.webhook.secret)||typeof t.webhook.person!=='string'||!t.webhook.person))throw new Error(`tool ${t.name}: webhook needs a named secret and account`);
43
44
  for(const [name,event] of Object.entries(t.events??{})){
@@ -51,5 +52,6 @@ export async function buildTools(root,esbuild) {
51
52
  let text=readFileSync(worker,'utf8');
52
53
  if(!text.includes('useTools(')){text += "\nimport { useTools } from '@homie-rocks/studio/worker';\nuseTools(async () => (await import('./tools/index.mjs')).default);\n";writeFileSync(worker,text);}
53
54
  }
55
+ writeFileSync(join(dir,'declarations.json'),JSON.stringify({paid:defs.some(t=>t.price)})+'\n');
54
56
  return defs.map(t=>t.name);
55
57
  }
package/lib/trailer.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `homie-studio trailer <id>`: a trailer of a game, in one command.
3
3
  *
4
- * The work is not here. It is the Homie plugin's video skill (`video.mjs trailer`): the game rendered frame by frame
5
- * on a virtual clock, its sound rebuilt from its own files, the highlights picked from what it played, an end card,
4
+ * The work is not here. It is the Homie plugin's video skill (`video.mjs trailer`): live footage for server rules,
5
+ * or a virtual clock and rebuilt sound for older browser games, selected highlights, an end card,
6
6
  * and 16:9, 1:1 and 9:16 deliveries. This finds that script and hands the command over, so a person at a terminal
7
7
  * and an agent with the plugin run the same thing. The video goes to videos/<id>-trailer/ (or --slug <slug>).
8
8
  *
@@ -36,10 +36,14 @@ export function trailerCommand(root, id, rest, { skills = null, slug = null } =
36
36
  }
37
37
 
38
38
  export function trailerLines(r) {
39
+ const live = r.capture?.mode === 'live';
40
+ const audio = r.capture?.audio;
41
+ const sound = audio?.rebuilt ? `${audio.placed} sounds rebuilt from the game's own files`
42
+ : audio ? 'game audio captured live' : live ? 'no audio captured' : 'the game logged no sound';
39
43
  return [
40
44
  ...(r.problems ?? []).map((x) => `PROBLEM: ${x}`),
41
45
  `${r.seconds} s trailer of ${r.game}: ${Object.values(r.outputs ?? {}).map((o) => o.file).join(', ')}`,
42
- ` filmed ${r.capture?.seconds} s frame by frame (${r.capture?.frames} frames, ${r.capture?.heldFrames} held); ${r.capture?.audio ? `${r.capture.audio.placed} sounds rebuilt from the game's own files` : 'the game logged no sound'}`,
46
+ ` filmed ${r.capture?.seconds} s ${live ? 'live' : 'frame by frame'} (${r.capture?.frames} frames, ${r.capture?.heldFrames} held); ${sound}`,
43
47
  ` ${r.edit?.shots} shots of ${r.edit?.shotSeconds} s, picked ${r.edit?.picked}`,
44
48
  ...(r.warnings ?? []).map((w) => ` warning: ${w}`),
45
49
  ...(r.next ? [` next (the video skill's script): ${r.next}`] : []),
package/lib/typecheck.mjs CHANGED
@@ -93,12 +93,12 @@ export function typecheck(root, games, { log = () => {} } = {}) {
93
93
  }
94
94
  // An error with no file is the project itself (a tsconfig.json that does not read): the game's to fix.
95
95
  const bare = /^error (TS\d+): (.*)$/.exec(line);
96
- if (bare) mine.push(`games/${g.id}${own ? '/tsconfig.json' : ''} ${bare[1]} ${bare[2]}`);
96
+ if (bare) mine.push(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}${own ? '/tsconfig.json' : ''} ${bare[1]} ${bare[2]}`);
97
97
  }
98
98
  rows.push({ id: g.id, checked: true, with: own ? 'its tsconfig.json' : 'the build\'s own settings', errors: mine.length, elsewhere });
99
- if (elsewhere) log(`types: games/${g.id}: ${elsewhere} ${elsewhere === 1 ? 'error' : 'errors'} outside the game's own files (a package it imports as source); not the game's, so not counted`);
99
+ if (elsewhere) log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: ${elsewhere} ${elsewhere === 1 ? 'error' : 'errors'} outside the game's own files (a package it imports as source); not the game's, so not counted`);
100
100
  if (mine.length) found.push(...mine);
101
- else log(`types: games/${g.id} has no type errors (TypeScript ${ts.version ?? '?'}, ${own ? 'its tsconfig.json' : 'the build\'s own settings'})`);
101
+ else log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id} has no type errors (TypeScript ${ts.version ?? '?'}, ${own ? 'its tsconfig.json' : 'the build\'s own settings'})`);
102
102
  }
103
103
  if (found.length) {
104
104
  const shown = found.slice(0, 20);
@@ -125,7 +125,7 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
125
125
  '@homie-rocks/studio/*': [join(PACKAGE_ROOT, '*')],
126
126
  } }, files: [join(g.dir, 'src/rules.ts'), join(g.dir, g.entry ?? 'src/view.ts'), join(dir, 'files.d.ts')] }, null, 2)}\n`);
127
127
  const res = await runRulesCompiler(compiler, ['-p', project, '--pretty', 'false']);
128
- if (res.error) throw new Error(`games/${g.id}: the rules type check could not finish: ${res.error.message}`);
128
+ if (res.error) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: the rules type check could not finish: ${res.error.message}`);
129
129
  const messages = [];
130
130
  for (const line of `${res.stdout ?? ''}\n${res.stderr ?? ''}`.split(/\r?\n/)) {
131
131
  const at = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
@@ -134,7 +134,9 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
134
134
  // Imported game helpers count too. Only the toolkit's own implementation is outside the author's remit.
135
135
  if (!file.startsWith(`${PACKAGE_ROOT}${sep}`) && !file.includes(`${sep}node_modules${sep}`) || file.startsWith(`${g.dir}${sep}`)) {
136
136
  const sourceLine = readFileSync(file,'utf8').split(/\r?\n/)[Number(at[2])-1] ?? '';
137
- const advice = /Property 'ask'.*type 'never'/.test(at[5]) ? 'This handler scope never calls world.ask, so no answer can arrive here. Handle the answer in the scope that asks.'
137
+ const advice = /Property 'bot'.*RosterRow/.test(at[5]) ? "Rules roster rows use driver === 'bot' or 'ai' (not row.bot); preserve the visible AI label."
138
+ : /'[^']*\.at' is possibly 'undefined'/.test(at[5]) ? 'Effects carry either at (position) or id (entity). Check e.at before reading coordinates, or resolve e.id with room.get.'
139
+ : /Property 'ask'.*type 'never'/.test(at[5]) ? 'This handler scope never calls world.ask, so no answer can arrive here. Handle the answer in the scope that asks.'
138
140
  : /world\.round\.end/.test(sourceLine) && at[4] === 'TS2339' ? 'Only room handlers end a round. Send a declared event to a room handler that calls world.round.end().'
139
141
  : /'check'.*does not exist/.test(at[5]) ? 'Remove the obsolete check key. Generated observations require no exemption.'
140
142
  : /=> (?:GameDecision|GuideDecision)'/.test(at[5]) ? 'A floor returns goal and say names from agents.json with exactly the args and sayArgs each declares, or {} to decline. Forward a request as { goal: ask.k, args: ask.args }.'
@@ -150,7 +152,7 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
150
152
  } else if (/^error TS/.test(line)) messages.push(line);
151
153
  else if (/^\s+\S/.test(line) && messages.length) messages[messages.length - 1] += `\n${line.length > 360 ? line.slice(0, 220) + ' … ' + line.slice(-100) : line}`;
152
154
  }
153
- if (messages.length) throw new Error(`games/${g.id}: its rules have type errors:\n${messages.slice(0, 30).map((m) => ` ${m}`).join('\n')}`);
154
- if (res.status !== 0 && !res.stdout) throw new Error(`games/${g.id}: TypeScript stopped without diagnostics (${res.signal ?? res.status})`);
155
- log(`types: games/${g.id}: rules, move and view checked with the toolkit's strict compiler`);
155
+ if (messages.length) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules have type errors:\n${messages.slice(0, 30).map((m) => ` ${m}`).join('\n')}`);
156
+ if (res.status !== 0 && !res.stdout) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: TypeScript stopped without diagnostics (${res.signal ?? res.status})`);
157
+ log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: rules, move and view checked with the toolkit's strict compiler`);
156
158
  }
package/lib/upgrade.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {refreshWorkerConfig} from './worker-config.mjs';
1
2
  /**
2
3
  * `homie-studio upgrade [--apply] [--diff]` — an existing studio takes what this version's template adds, and
3
4
  * nothing it made its own is ever overwritten.
@@ -346,6 +347,7 @@ export function upgradeApply(root, plan) {
346
347
  }
347
348
  if (text !== null && (!existsSync(path) || readFileSync(path, 'utf8') !== text)) writeAtomic(path, text);
348
349
  }
350
+ refreshWorkerConfig(root);
349
351
  const pinned = done.some((c) => c.kind === 'pin');
350
352
  return {
351
353
  ...plan, applied: true, done, skipped,
@@ -1,34 +1,22 @@
1
- import {existsSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
2
- import {join,relative,dirname} from 'node:path';
1
+ import {refreshWorkerConfig} from './worker-config.mjs';
2
+ import {pathToFileURL} from 'node:url';
3
+ import {existsSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
4
+ import {join,relative} from 'node:path';
3
5
  import {readConfig} from './routes.mjs';
4
6
  import {paidReleases} from './parts-upload.mjs';
5
- import {configPath} from './studio.mjs';
6
7
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
7
8
  export async function buildWorker(root,esbuild) {
8
9
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
9
10
  const out=join(root,'site/src/runtime');
11
+ refreshWorkerConfig(root);
10
12
  const alias={...(readConfig(root)?.alias??{})};
11
- if(paidReleases(root).length)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
13
+ const toolModule=join(root,'site/src/tools/index.mjs');
14
+ const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
15
+ if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
12
16
  const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
13
17
  rmSync(out,{recursive:true,force:true});
14
18
  mkdirSync(out,{recursive:true});
15
19
  for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
16
- const config=configPath(root);
17
- if(existsSync(config)){
18
- const original=readFileSync(config,'utf8');
19
- const json=JSON.parse(original.replace(/^\s*\/\/.*$/gm,''));
20
- const before=JSON.stringify(json);
21
- json.main=relative(dirname(config),join(out,'worker.js')).split('\\').join('/');
22
- json.no_bundle=true;json.find_additional_modules=true;
23
- json.rules=[...(json.rules??[]).filter(r=>!r.homieMcp),{type:'ESModule',globs:['**/*.js'],fallthrough:true}];
24
- // No duplicate rule on rebuild.
25
- json.rules=json.rules.filter((r,i,all)=>all.findIndex(x=>JSON.stringify(x)===JSON.stringify(r))===i);
26
- json.compatibility_flags=[...new Set([...(json.compatibility_flags??[]),'nodejs_compat'])];
27
- if(JSON.stringify(json)!==before) {
28
- const comments=original.match(/^(?:\s*\/\/[^\n]*\n)*/)?.[0]??'';
29
- writeFileSync(config,comments+JSON.stringify(json,null,2)+'\n');
30
- }
31
- }
32
20
  const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
33
21
  writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
34
22
  return sizes;
@@ -0,0 +1,28 @@
1
+ import {readFileSync,writeFileSync,existsSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
4
+ import {wranglerConfig} from './scaffold.mjs';
5
+ import {paidReleases} from './parts-upload.mjs';
6
+ const parse=t=>JSON.parse(t.replace(/^\s*\/\/.*$/gm,''));
7
+ const read=file=>existsSync(file)?parse(readFileSync(file,'utf8')):{};
8
+ function merge(base,custom) {
9
+ if(Array.isArray(custom))return custom;
10
+ if(custom && typeof custom==='object')return Object.fromEntries([...new Set([...Object.keys(base??{}),...Object.keys(custom)])].sort().map(k=>[k,k in custom?merge(base?.[k],custom[k]):base[k]]));
11
+ return custom;
12
+ }
13
+ /** Generated output is never an input. Studio additions belong in wrangler.custom.json. */
14
+ export function updateWorkerConfig(root,generated) {
15
+ const file=configPath(root),next=merge(parse(generated),read(join(root,'wrangler.custom.json')));
16
+ const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
17
+ if(!existsSync(file)||readFileSync(file,'utf8')!==text)writeFileSync(file,text);
18
+ }
19
+ export function refreshWorkerConfig(root,overrides={}) {
20
+ const studio=readStudio(root),cf=studio.cloudflare;
21
+ if(!cf?.worker)return;
22
+ const metadata=read(join(root,'site/src/functions/declarations.json'));
23
+ const functions=Array.isArray(metadata)?metadata:[];
24
+ const tools=read(join(root,'site/src/tools/declarations.json'));
25
+ const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
26
+ const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
27
+ updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,triggers:functions.length?{crons:[...new Set(['* * * * *',...functions.map(f=>f.schedule).filter(Boolean)])].sort()}:null,...overrides}));
28
+ }
@@ -1,5 +1,13 @@
1
1
  # Homie netplay contract, v1 (revision 11)
2
2
 
3
+ **Authoring now:** New games use rules plus view and the server as host. Read the
4
+ Homie game skill's RULES.md and section 29 below. Sections 1–28 preserve the
5
+ browser-hosted helper contract for existing games and describe shared shell
6
+ features; their createNetplay/host-loop examples are not a recipe for new games.
7
+ Use the skill's REWRITE.md for an owner-requested rewrite. All five starters use
8
+ server rules. Room seats are capped at 32; larger rooms are future work.
9
+
10
+
3
11
  Status: **v1, revision 11** (2026-10-08, `@homie-rocks/studio` 0.33.1). The wire
4
12
  version is `v: 1`. Everything revisions 2 to 11 added is either an optional field, a new message type,
5
13
  a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.40.0",
3
+ "version": "0.43.0",
4
4
  "description": "A studio for games, music and video 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 and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -88,6 +88,10 @@
88
88
  "./tools": {
89
89
  "types": "./tools/tools.ts",
90
90
  "default": "./tools/tools.ts"
91
+ },
92
+ "./functions": {
93
+ "types": "./functions/functions.ts",
94
+ "default": "./functions/functions.ts"
91
95
  }
92
96
  },
93
97
  "files": [
@@ -116,7 +120,8 @@
116
120
  "links/",
117
121
  "apps/",
118
122
  "app-starters/",
119
- "tools/"
123
+ "tools/",
124
+ "functions/"
120
125
  ],
121
126
  "engines": {
122
127
  "node": ">=22"
package/rules/host.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * host.ts — the host runtime: a rules module playing the host's part on the wire.
3
3
  * =============================================================================
4
4
  *
5
- * Today one player's browser is a room's host: it runs the rules and the clock and sends everybody the state. The host
6
- * runtime is that host with no browser in it. It takes a compiled rules module (rules.ts) and three things from its
5
+ * The server normally hosts the room: rules, clock and state. Browser hosting uses the same runtime
6
+ * for offline play, local development or private friends games. It takes a compiled rules module (rules.ts) and three things from its
7
7
  * caller: a way to send a frame, a clock and a store. It has no transport in it and no Cloudflare in it, in the style of
8
8
  * worker/room.mjs. Inside the `Table` (worker/index.mjs) the relay hands it parsed frames and fans out what it sends;
9
9
  * in Node the tests and the build check drive it with a clock of their own.
package/rules/view.ts CHANGED
@@ -672,6 +672,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
672
672
  net.expose({
673
673
  self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
674
674
  peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
675
+ movement: () => { const kinds = schema.kinds.filter(k => k.player); return kinds.length && kinds.every(k => k.maxSpeed === 0) ? 'stationary' : 'spatial'; },
675
676
  prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
676
677
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
677
678
  score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
package/shop/SHOP.md CHANGED
@@ -39,9 +39,10 @@ A kind is a label, not a recurring-billing, wallet, random-reward or fulfillment
39
39
  omitted limits do not apply. `referralNewPlayersOnly: true` limits attribution to new players.
40
40
  - `catalog`: modes (`test`, `live`) populated by `homie-studio shop catalog` for named Stripe Products.
41
41
  Prices always come from your file, even when a Stripe Product exists.
42
- - Flood protection: `purchaseAttemptsPerMinute` (6 per buyer),
43
- `purchaseAttemptsPerAddressPerMinute` (600), `guestBuyersPerAddressPerHour` (600).
44
- These are configurable positive integers, per Worker instance. `PLAYER_LIMIT_DAILY` also applies.
42
+ - Optional flood protection: `purchaseAttemptsPerMinute`,
43
+ `purchaseAttemptsPerAddressPerMinute`, `guestBuyersPerAddressPerHour`.
44
+ Omitted or null means no limit. Explicit positive integers apply per Worker instance.
45
+ An explicitly set `PLAYER_LIMIT_DAILY` also applies to new shop buyers.
45
46
  Referral delivery uses `REFERRAL_STATEMENTS_PER_MINUTE`, `REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE`
46
47
  (both 30), and `REFERRAL_STATEMENT_BYTES` (65536). `requestBytes` overrides the catalog-sized shop request
47
48
  allowance; `SHOP_WEBHOOK_BYTES` (524288) controls the webhook byte allowance.
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  /*
2
4
  * COIN DASH — how a runner moves. Its own file, because two places run it: the server, for every body it moves, and
3
5
  * each player's browser, for that player's own body. So it reads only what both have: the body's own position,
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  /*
2
4
  * COIN DASH — the rules: the example of a game whose rules run on the server.
3
5
  *
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /*
2
4
  * COIN DASH — the view: what a player sees, and what they press.
3
5
  *
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  import { defineMove } from '@homie-rocks/studio/rules';
2
4
 
3
5
  export const move = defineMove({
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  import { defineRules, f, type GameWorld, type GameSelf } from '@homie-rocks/studio/rules';
2
4
  import { move } from './move';
3
5
 
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /** Ember Vale: server-run hunts, with the hero save and memorials owned by its player. */
2
4
  import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
3
5
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  import { defineMove } from '@homie-rocks/studio/rules';
2
4
 
3
5
  export const move = defineMove({
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  import { defineRules, f } from '@homie-rocks/studio/rules';
2
4
  import { move } from './move';
3
5
 
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /** Gem Rush: the canvas draws the room; rules.ts decides the game. Coordinates here are pixels (50 per metre). */
2
4
  import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
3
5
  import type rules from './rules';
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
2
4
 
3
5
  import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  import { defineRules, f, type GameWorld } from '@homie-rocks/studio/rules';
2
4
  import { move } from './move';
3
5
 
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /** Gem Rush 3D: the room runs the rules; this view draws the meadow and its animals. */
2
4
  import {
3
5
  AmbientLight, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,
@@ -1,3 +1,5 @@
1
+ // This same guarded step runs on the server and immediately for the local player.
2
+ // Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
1
3
  import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
2
4
 
3
5
  import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
@@ -1,3 +1,5 @@
1
+ // Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
2
+ // Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
1
3
  import { defineRules, f, type GameWorld } from '@homie-rocks/studio/rules';
2
4
  import { move } from './move';
3
5
 
@@ -1,3 +1,5 @@
1
+ // Presentation only: openRoom gives predicted own movement and interpolated other bodies.
2
+ // Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
1
3
  /** Hero Rush 3D: server rules, predicted jumps, and the original meadow and heroes. */
2
4
  import {
3
5
  AmbientLight, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,