@homie-rocks/studio 0.37.2 → 0.39.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 (48) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/homie-studio.mjs +16 -4
  3. package/lib/build.mjs +3 -0
  4. package/lib/cloudflare.mjs +2 -2
  5. package/lib/dev.mjs +12 -1
  6. package/lib/mcp-tools.mjs +10 -0
  7. package/lib/perf.mjs +17 -10
  8. package/lib/port-check.mjs +101 -19
  9. package/lib/rules-check-worker.mjs +15 -2
  10. package/lib/scaffold.mjs +19 -6
  11. package/lib/shoot.mjs +11 -5
  12. package/lib/studio.mjs +1 -1
  13. package/lib/template-history.json +85 -0
  14. package/lib/tools-build.mjs +55 -0
  15. package/lib/tools-cli.mjs +55 -0
  16. package/lib/worker-build.mjs +35 -0
  17. package/netplay/NETPLAY.md +20 -6
  18. package/netplay/netplay.ts +3 -1
  19. package/package.json +17 -6
  20. package/port/probe.ts +2 -1
  21. package/rules/core.ts +3 -1
  22. package/rules/host.ts +3 -1
  23. package/rules/pack.ts +30 -3
  24. package/rules/view.ts +232 -40
  25. package/starters/coin-dash/lab.json +17 -0
  26. package/starters/coin-dash/src/rules.ts +3 -3
  27. package/starters/gem-rush/game.json +19 -4
  28. package/starters/gem-rush/map/main.json +50 -0
  29. package/starters/gem-rush/src/move.ts +23 -0
  30. package/starters/gem-rush/src/rules.ts +118 -0
  31. package/starters/gem-rush/src/view.ts +429 -0
  32. package/starters/gem-rush/tunables.json +98 -11
  33. package/tools/TOOLS.md +113 -0
  34. package/tools/tools.ts +28 -0
  35. package/worker/app-records.mjs +8 -0
  36. package/worker/index.mjs +26 -0
  37. package/worker/mcp-room.mjs +61 -0
  38. package/worker/mcp-store.mjs +45 -0
  39. package/worker/mcp-tools.mjs +105 -0
  40. package/worker/mcp.mjs +159 -0
  41. package/worker/office-page.mjs +1 -1
  42. package/worker/office.mjs +2 -1
  43. package/worker/purchase-mcp.mjs +3 -3
  44. package/worker/tool-events.mjs +14 -0
  45. package/worker/tool-identity.mjs +4 -0
  46. package/worker/tool-webhook.mjs +28 -0
  47. package/worker/version.mjs +1 -1
  48. package/starters/gem-rush/src/main.ts +0 -1087
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
1
2
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
2
3
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
3
4
  /**
@@ -67,8 +68,8 @@ export function slugify(name) {
67
68
  * the binding names and keep it linked.
68
69
  */
69
70
  export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
70
- const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
71
- : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
71
+ const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
72
+ : { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
72
73
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
73
74
  const config = {
74
75
  $schema: `${at.schema}/wrangler/config-schema.json`,
@@ -80,7 +81,8 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
80
81
  // subdomain) reaches it instead of failing with 1042.
81
82
  // `disallow_eval_during_startup`: no code is made from a string while the Worker loads (Cloudflare never allows it
82
83
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
83
- compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup', ...(paidParts ? ['nodejs_compat'] : [])],
84
+ no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
85
+ compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
84
86
  ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ratelimits: [partsRateLimit ?? { name: 'PURCHASE_RATE_LIMITER', namespace_id: '1001', simple: { limit: 60, period: 60 } }] } : {}),
85
87
  workers_dev: true,
86
88
  preview_urls: true,
@@ -152,6 +154,8 @@ An app is ONE screen that morphs with context: camera moves, panels unfold, its
152
154
 
153
155
  Declare roles, surfaces, words and lasting record collections in app.json. Netplay's host is not a staff permission. Use \`@homie-rocks/studio/apps\` for authorized lasting records and \`@homie-rocks/studio/links\` for ticket links, QR and HTTP-safe IDs. Staff use private role links plus existing account grants; keep private records out of public collections. Look for engine mechanisms and shared parts before writing a component. Credit licences; sharing is optional and separate from selling.
154
156
 
157
+ Your studio also serves MCP at \`/mcp\`. Staff connect their own AI with their existing account and one browser approval; never ask them for a key. Add business actions with \`homie-studio tool new <name> --app <id>\`, keep their declared role narrow, and reuse the app's existing record IDs, field formats and versions. Test owner, staff and public calls locally. Read \`node_modules/@homie-rocks/studio/tools/TOOLS.md\` and the Homie plugin's \`tools\` skill.
158
+
155
159
  Build normally, run \`homie-studio dev --lan\`, then \`homie-studio check <id> --url <origin>\`: the app check proves an actual action across a wall and two phones plus reconnect. The public screen is \`/<id>/open\`, the wall \`/<id>/tv\`. Customer apps use the same \`standalone\` build for desktop/iOS/Android; its existing sign-in and store limitations still apply. See \`node_modules/@homie-rocks/studio/apps/APPS.md\`.
156
160
 
157
161
  ## Commands (all through the pinned CLI in node_modules)
@@ -645,6 +649,8 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
645
649
  `;
646
650
 
647
651
  const GITIGNORE = `node_modules/
652
+ site/src/runtime/
653
+ site/src/tools/
648
654
  # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
649
655
  .studio/
650
656
  # This person's own Claude Code settings for this studio (the status line, for one).
@@ -734,11 +740,12 @@ export function studioFiles({ name, slug, homie, template = false }) {
734
740
  'site/README.md': SITE_README,
735
741
  'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
736
742
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
737
- import { hostRules } from '@homie-rocks/studio/worker';
743
+ import { hostRules, useTools } from '@homie-rocks/studio/worker';
738
744
  // The rules of this studio's games that run on the server (written by homie-studio build; empty until a game has some).
739
745
  import rules from './rules/index.mjs';
740
746
 
741
747
  hostRules(rules);
748
+ useTools(async () => (await import('./tools/index.mjs')).default);
742
749
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';
743
750
  `,
744
751
  'site/src/rules/index.mjs': rulesIndex([]),
@@ -755,6 +762,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
755
762
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
756
763
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
757
764
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
765
+ [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
758
766
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
759
767
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
760
768
  '.claude/skills/.gitkeep': '',
@@ -926,13 +934,18 @@ function ensurePartsStateMigration(root) {
926
934
  return `site/migrations/${PURCHASE_STATE_FILE}`;
927
935
  }
928
936
 
937
+ function ensureMcpMigration(root) {
938
+ const file=join(root,'site','migrations',MCP_MIGRATION_FILE);
939
+ if(existsSync(file))return null;
940
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
941
+ }
929
942
  export function ensureMigrations(root) {
930
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root)].filter(Boolean);
943
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root)].filter(Boolean);
931
944
  }
932
945
 
933
946
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
934
947
  export function migrationWord(file) {
935
- return /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
948
+ return /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
936
949
  }
937
950
 
938
951
  /**
package/lib/shoot.mjs CHANGED
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * `--preview` (no --url): THE FRAMES HALF WITHOUT THE WHOLE SITE. It starts the light preview server itself
29
29
  * (lib/preview.mjs: the one built game's files on 127.0.0.1, no Wrangler, no rooms), opens the game's own page there
30
- * and shoots it. The game plays alone, offline, with its bots, which is what pictures of the game itself want. The
30
+ * and shoots it. A game with offline play enabled plays alone, with its bots, which is what pictures of the game itself want. The
31
31
  * smoke is NOT RUN (there is no room to meet in: that half needs `dev`), and there is no loading cover to wait for.
32
32
  * The result says which build the frames are of (`build`: its hash, the one `build` printed) and that the page
33
33
  * really loaded that build's hashed bundle (`bundle`: lib/build.mjs bundleOf, never a guess at assets/main.js).
@@ -274,7 +274,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
274
274
  const inAll = (fn, arg) => Promise.all(all().map((f) => T(f.evaluate(fn, arg), 20_000)));
275
275
  const frozen = await inAll(() => (window.__homieClock ? (window.__homieClock.freeze(), true) : false));
276
276
  if (!frozen.some(Boolean)) { result.why = 'the virtual clock is not in the page (it was opened before the script could be injected)'; return result; }
277
- if (hold) await first.page.keyboard.down(hold).catch(() => {});
277
+ if (hold) {
278
+ // The play page owns the top-level focus; the game's key listener lives in its iframe.
279
+ if (gf) await T(gf.evaluate(() => window.focus()), 5000);
280
+ await first.page.keyboard.down(hold).catch(() => {});
281
+ }
278
282
  const rows = [];
279
283
  for (let n = 1; n <= frames; n++) {
280
284
  if (left() < 3000) { result.partial = `stopped at frame ${n - 1} of ${frames}: the ${Math.round(timeoutMs / 1000)} s bound was reached (a slow renderer; ask for fewer frames or a longer --timeout)`; break; }
@@ -287,6 +291,7 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
287
291
  rows.push({ n, file, virtualMs: +(n * (1000 / fps)).toFixed(3), clockMs: nows.find((v) => Number.isFinite(v)) ?? null, realMs: Date.now() - t, ...(await frameFacts(first) ?? {}) });
288
292
  }
289
293
  if (hold) await first.page.keyboard.up(hold).catch(() => {});
294
+ result.rules = gf ? await T(gf.evaluate(() => { const p = window.__homieNet?.probe; return typeof p?.hosted === 'function' ? { hosted: p.hosted(), status: p.status?.(), tick: p.tick?.() } : null; }), 5000) : null;
290
295
  result.frames = rows.length;
291
296
  result.realSeconds = +((Date.now() - started) / 1000).toFixed(1);
292
297
  result.virtualSeconds = +((rows.length * 1000) / fps / 1000).toFixed(3);
@@ -294,10 +299,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
294
299
  result.limits = 'Stepped: requestAnimationFrame, performance.now, Date, setTimeout, setInterval, CSS and Web animations, in the page and the game\'s frame. NOT stepped: the room\'s socket and its server clock (other players and the round arrive in real time), Web Audio\'s clock, <video>, workers. These frames show what was drawn; they are not a frame rate.';
295
300
  const probed = rows.some((r) => r.phase !== undefined);
296
301
  if (!probed) result.note = [result.note, 'the game exposes no port probe (exposePort): the frames carry no round phase or position'].filter(Boolean).join('; ');
297
- writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
298
302
  const smokeOk = result.smoke.ok !== false;
299
- result.ok = rows.length === frames && smokeOk;
300
- if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : result.partial;
303
+ const rulesOk = !result.rules || (result.rules.status === 'playing' && result.rules.tick > 0);
304
+ result.ok = rows.length === frames && smokeOk && rulesOk && !result.errors.length;
305
+ if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : !rulesOk ? 'The rules game is not playing. A server-only build needs its room: run homie-studio dev and shoot with --url (or explicitly enable room.offline for offline play).' : result.errors.length ? `game errors: ${result.errors.join('; ')}` : result.partial;
306
+ writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
301
307
  return result;
302
308
  } finally {
303
309
  if (served?.server) { served.server.closeAllConnections?.(); served.server.close(); }
package/lib/studio.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
11
- const RESERVED_IDS = new Set(['api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
11
+ const RESERVED_IDS = new Set(['mcp', 'oauth', 'hooks', 'api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
12
12
 
13
13
  export function findStudio(from = process.cwd()) {
14
14
  let at = resolve(from);
@@ -2112,6 +2112,91 @@
2112
2112
  }
2113
2113
  }
2114
2114
  }
2115
+ },
2116
+ "0.38.0": {
2117
+ "files": {
2118
+ "CLAUDE.md": "ddf2b2a0376202f6",
2119
+ "README.md": "684b3cca8001673c",
2120
+ "HANDOFF.md": "29337e2ace95b2e7",
2121
+ "games/README.md": "5ad32fe80c61f117",
2122
+ "music/README.md": "a3dc042dadd39df8",
2123
+ "videos/README.md": "16babc3437d5545b",
2124
+ "posts/README.md": "37841aae476bd540",
2125
+ "site/README.md": "732894273a32dc3f",
2126
+ "site/src/worker.mjs": "fc90f3609cad18ba"
2127
+ },
2128
+ "sections": {
2129
+ "AGENTS.md": {
2130
+ "(intro)": "214aa46eb3a895d5",
2131
+ "## Layout": [
2132
+ "8baf49e4291e7e88",
2133
+ "c18362ed28975d81"
2134
+ ],
2135
+ "## Apps: one screen, roles and parts": "4db0abe4cdfac09f",
2136
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2137
+ "## The site": "0cb8dd9a6f8a0096",
2138
+ "## Making games": "2da7fa74c8884834",
2139
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2140
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2141
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2142
+ "## Servers and AI seats": "98d08705087a33ec",
2143
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2144
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2145
+ "## Rules": "2e6b29afb85dcf7e",
2146
+ "## Telling Homie": "80b46fdd9e578db8",
2147
+ "## Beta": "9d02638207a26e73"
2148
+ }
2149
+ },
2150
+ "named": {
2151
+ "Homie Arcade": {
2152
+ "sections": {
2153
+ "AGENTS.md": {
2154
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2155
+ }
2156
+ }
2157
+ }
2158
+ }
2159
+ },
2160
+ "0.39.0": {
2161
+ "files": {
2162
+ "CLAUDE.md": "ddf2b2a0376202f6",
2163
+ "README.md": "684b3cca8001673c",
2164
+ "HANDOFF.md": "29337e2ace95b2e7",
2165
+ "games/README.md": "5ad32fe80c61f117",
2166
+ "music/README.md": "a3dc042dadd39df8",
2167
+ "videos/README.md": "16babc3437d5545b",
2168
+ "posts/README.md": "37841aae476bd540",
2169
+ "site/README.md": "732894273a32dc3f",
2170
+ "site/src/worker.mjs": "34444a6ca4b14437"
2171
+ },
2172
+ "sections": {
2173
+ "AGENTS.md": {
2174
+ "(intro)": "214aa46eb3a895d5",
2175
+ "## Layout": "c18362ed28975d81",
2176
+ "## Apps: one screen, roles and parts": "be6f8fa45f93211a",
2177
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2178
+ "## The site": "0cb8dd9a6f8a0096",
2179
+ "## Making games": "2da7fa74c8884834",
2180
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2181
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2182
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2183
+ "## Servers and AI seats": "98d08705087a33ec",
2184
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2185
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2186
+ "## Rules": "2e6b29afb85dcf7e",
2187
+ "## Telling Homie": "80b46fdd9e578db8",
2188
+ "## Beta": "9d02638207a26e73"
2189
+ }
2190
+ },
2191
+ "named": {
2192
+ "Homie Arcade": {
2193
+ "sections": {
2194
+ "AGENTS.md": {
2195
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2196
+ }
2197
+ }
2198
+ }
2199
+ }
2115
2200
  }
2116
2201
  }
2117
2202
  }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { createRequire } from 'node:module';
5
+ import { partsPlugin, resolvePartImport } from './parts-build.mjs';
6
+ const require=createRequire(import.meta.url);
7
+ export function toolFiles(root) {
8
+ const dirs=[join(root,'tools')];
9
+ const apps=join(root,'apps');if(existsSync(apps))for(const app of readdirSync(apps,{withFileTypes:true}))if(app.isDirectory())dirs.push(join(apps,app.name,'tools'));
10
+ return dirs.flatMap(dir=>existsSync(dir)?readdirSync(dir).filter(f=>/^[a-z][a-z0-9-]*\.ts$/.test(f)).sort().map(f=>join(dir,f)):[]);
11
+ }
12
+ export async function buildTools(root,esbuild) {
13
+ const files=toolFiles(root),dir=join(root,'site/src/tools');
14
+ mkdirSync(dir,{recursive:true});
15
+ if(files.length){
16
+ const ts=require('typescript');
17
+ const options={target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,strict:true,noEmit:true,skipLibCheck:true,allowImportingTsExtensions:true};
18
+ const host=ts.createCompilerHost(options);
19
+ host.resolveModuleNames=(names,containing)=>names.map(name=>{
20
+ if(name.startsWith('@parts/')){const part=resolvePartImport(root,name);if(part.path)return {resolvedFileName:part.path,extension:part.path.endsWith('.ts')?ts.Extension.Ts:ts.Extension.Js};}
21
+ return ts.resolveModuleName(name,containing,options,host).resolvedModule;
22
+ });
23
+ const program=ts.createProgram(files,options,host);
24
+ const errors=ts.getPreEmitDiagnostics(program);
25
+ if(errors.length)throw new Error(ts.formatDiagnosticsWithColorAndContext(errors,{getCurrentDirectory:()=>root,getCanonicalFileName:f=>f,getNewLine:()=> '\n'}));
26
+ }
27
+ const contents=files.map((f,i)=>`import t${i} from ${JSON.stringify(f)};`).join('\n')+`\nexport default [${files.map((_,i)=>'t'+i).join(',')}];\n`;
28
+ const result=await esbuild.build({stdin:{contents,resolveDir:root},bundle:true,format:'esm',platform:'neutral',target:'es2022',write:false,plugins:[partsPlugin(root)],logLevel:'silent'});
29
+ const temp=join(dir,'index.mjs');writeFileSync(temp,result.outputFiles[0].text);
30
+ // Trusted studio code, as with the existing build command. No Worker bindings exist during build.
31
+ const defs=(await import(pathToFileURL(temp).href+'?build='+Date.now())).default;
32
+ const { CfWorkerJsonSchemaValidator }=await import('@modelcontextprotocol/server/validators/cf-worker');
33
+ const validator=new CfWorkerJsonSchemaValidator(),seen=new Set();
34
+ for(const t of defs){
35
+ if(!/^[a-z][a-z0-9_]{0,63}$/.test(t.name)||/^(studio_|app_|agent_|room_|shop_)/.test(t.name)||seen.has(t.name))throw new Error(`tool name is invalid, reserved or duplicated: ${t.name}`);
36
+ seen.add(t.name);
37
+ if(typeof t.description!=='string'||!t.description.trim()||typeof t.handler!=='function')throw new Error(`tool ${t.name} needs description and handler`);
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
+ if(t.inputSchema?.type!=='object')throw new Error(`tool ${t.name} input schema must be an object`);
40
+ validator.getValidator(t.inputSchema);
41
+ if(t.kind!==undefined&&!['tool','prompt'].includes(t.kind))throw new Error(`tool ${t.name}: invalid kind`);
42
+ 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
+ for(const [name,event] of Object.entries(t.events??{})){
44
+ if(!/^external:[a-z][a-z0-9_-]{0,40}$/.test(name)||!event.game||!event.schema)throw new Error(`tool ${t.name}: invalid external event`);
45
+ validator.getValidator(event.schema);
46
+ }
47
+ }
48
+ // Existing studios retain their Worker customization; install the small registry hook once.
49
+ const worker=join(root,'site/src/worker.mjs');
50
+ if(existsSync(worker)){
51
+ let text=readFileSync(worker,'utf8');
52
+ 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
+ return defs.map(t=>t.name);
55
+ }
@@ -0,0 +1,55 @@
1
+ import {existsSync,mkdirSync,writeFileSync,readFileSync,chmodSync} from 'node:fs';
2
+ import {join,dirname} from 'node:path';
3
+ import {createServer} from 'node:http';
4
+ import {randomUUID} from 'node:crypto';
5
+ import {spawn} from 'node:child_process';
6
+ export function newTool(root,name,{app=null}={}) {
7
+ if(!/^[a-z][a-z0-9-]{0,39}$/.test(name??'') || app!==null&&!/^[a-z][a-z0-9-]{0,39}$/.test(app))throw new Error('name uses lowercase letters, numbers and hyphens');
8
+ if(app&&!existsSync(join(root,'apps',app,'app.json')))throw new Error('unknown app');
9
+ const file=join(root,...(app?['apps',app,'tools']:['tools']),name+'.ts');if(existsSync(file))throw new Error('tool already exists');
10
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,`import { defineTool } from '@homie-rocks/studio/tools';\n\nexport default defineTool<{ message: string }>({\n name: '${name.replaceAll('-','_')}',\n description: 'Describe what this tool does and when to use it.',\n audience: ${app?JSON.stringify({app,role:'staff'}):"'owner'"},\n inputSchema: { type: 'object', properties: { message: { type: 'string', maxLength: 200 } }, required: ['message'], additionalProperties: false },\n async handler({ message }, context) {\n return { message, by: context.caller.id };\n },\n});\n`);
11
+ return {ok:true,command:'tool new',file,next:'Edit the definition, then homie-studio dev. Call it with homie-studio tool call '+name.replaceAll('-','_')+' --input \'{"message":"Hello"}\' --url http://localhost:8787'};
12
+ }
13
+ /** The official SDK owns discovery, registration, PKCE, refresh and issuer checks. */
14
+ export async function connectToolClient(root,site,{publicOnly=false,open=defaultOpen,log=()=>{}}={}) {
15
+ const {Client,StreamableHTTPClientTransport,auth}=await import('mcp-studio-client');
16
+ const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
17
+ const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
18
+ if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
19
+ const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
20
+ let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
21
+ // Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.
22
+ if((state.tokens&&!state.tokens.issuer)||(state.client&&!state.client.issuer))state={};
23
+ const save=()=>{mkdirSync(dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(state),{mode:0o600});chmodSync(file,0o600);};
24
+ let accept;const returned=new Promise(resolve=>{accept=resolve;});
25
+ const csrf=randomUUID();
26
+ const server=createServer((req,res)=>{const back=new URL(req.url,'http://localhost');if(back.pathname!=='/callback'||back.searchParams.get('state')!==csrf){res.writeHead(400);res.end('Invalid callback');return;}res.end('Your AI is connected. You can close this tab.');accept(back);});
27
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
28
+ const redirect=`http://127.0.0.1:${server.address().port}/callback`;
29
+ const provider={redirectUrl:redirect,clientMetadata:{client_name:'Homie studio tool CLI',redirect_uris:[redirect],grant_types:['authorization_code','refresh_token'],response_types:['code'],token_endpoint_auth_method:'none'},state:()=>csrf,
30
+ clientInformation:()=>state.client,saveClientInformation:v=>{state.client=v;save();},tokens:()=>state.tokens,saveTokens:v=>{state.tokens=v;save();},
31
+ codeVerifier:()=>state.verifier,saveCodeVerifier:v=>{state.verifier=v;save();},
32
+ discoveryState:()=>state.discovery,saveDiscoveryState:v=>{state.discovery=v;save();},
33
+ invalidateCredentials:scope=>{if(scope==='all')state={};else {delete state[scope==='tokens'?'tokens':scope];if(scope==='tokens')delete state.client;}save();},
34
+ redirectToAuthorization:async u=>{log('Approve this connection in your browser.');await open(u.href);},
35
+ };
36
+ try{
37
+ // A new loopback port needs a matching registered redirect; refresh still uses the prior client.
38
+ if(!state.tokens){delete state.client;delete state.discovery;}
39
+ const result=await auth(provider,{serverUrl:url,scope:'studio offline_access'});
40
+ if(result==='REDIRECT'){
41
+ let timer;const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Browser approval timed out')),300000);});
42
+ const back=await Promise.race([returned,timeout]).finally(()=>clearTimeout(timer));
43
+ if(back.searchParams.has('error'))throw new Error('Connection declined');
44
+ await auth(provider,{serverUrl:url,authorizationCode:back.searchParams.get('code'),iss:back.searchParams.get('iss')??undefined});
45
+ }
46
+ await client.connect(new StreamableHTTPClientTransport(url,{authProvider:provider}));return client;
47
+ }finally{server.close();}
48
+ }
49
+ function defaultOpen(url){const cmd=process.platform==='darwin'?'open':process.platform==='win32'?'explorer':'xdg-open';const child=spawn(cmd,[url],{stdio:'ignore',detached:true});child.unref();}
50
+ export async function toolCommand(root,sub,positional,flags,{log=()=>{}}={}) {
51
+ if(sub==='new')return newTool(root,positional[2],{app:flags.get('app')??null});
52
+ if(!['call','list'].includes(sub))throw new Error('Use tool new <name>, tool list, or tool call <name> --input <JSON>');
53
+ const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',{publicOnly:flags.has('public'),log});
54
+ try{const result=sub==='list'?await client.listTools():await client.callTool({name:positional[2],arguments:JSON.parse(flags.get('input')??'{}')});return {ok:result.isError!==true,command:'tool '+sub,result,...(result.isError?{why:'The tool refused or failed; see its result.'}:{})};}finally{await client.close();}
55
+ }
@@ -0,0 +1,35 @@
1
+ import {existsSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
2
+ import {join,relative,dirname} from 'node:path';
3
+ import {readConfig} from './routes.mjs';
4
+ import {paidReleases} from './parts-upload.mjs';
5
+ import {configPath} from './studio.mjs';
6
+ /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
7
+ export async function buildWorker(root,esbuild) {
8
+ const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
9
+ const out=join(root,'site/src/runtime');
10
+ const alias={...(readConfig(root)?.alias??{})};
11
+ if(paidReleases(root).length)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
12
+ 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
+ rmSync(out,{recursive:true,force:true});
14
+ mkdirSync(out,{recursive:true});
15
+ 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
+ const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
33
+ writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
34
+ return sizes;
35
+ }
@@ -2151,12 +2151,26 @@ and holds that lead from `lead`. A step whose sample (or claim) changed, or that
2151
2151
  makes an entry. A period with no entry sends nothing; after 250 ms without a frame the held values
2152
2152
  are sent again as an ordinary entry. A hidden tab sends one neutral entry and stops stepping.
2153
2153
 
2154
- **In this release a person's body is owner-moved** (`body: { move: 'owner' }` in the rules): its
2155
- browser runs the game's `move` code and claims a position, as `netplay.movement: 'owner'` does
2156
- today, and the server holds each claim to the body's `maxSpeed` (a tick of it every tick, a quarter
2157
- of a second banked at most). It does not stop a changed browser walking through a wall. Bots, and a
2158
- body whose player is away, are moved by the server with the same `move` code. Scores, pickups,
2159
- rounds and placements are the server's in either case.
2154
+ **Server movement with immediate local prediction.** The host runs `move` for every body.
2155
+ The view library runs the same guarded module for its own player, one step per tick and one
2156
+ throwaway preview between ticks. A snapshot replaces that body's position, velocity, heading,
2157
+ grounded flag and motion; the view replays pending inputs from there. Late presses are mirrored
2158
+ onto the next replay tick while they remain inside the quarter-second window. Scores, pickups,
2159
+ collisions between entities and effects are always the host's decisions. Bot and AI bodies
2160
+ are interpolated, never predicted by a player's view.
2161
+
2162
+ Small errors fade as a visual offset with a nominal 100 ms blend. Overlapping small corrections
2163
+ queue their individual fades, so their accumulated offset is not erased all at once. A disagreement
2164
+ beyond three ticks of travel plays the corrected path from the snapshot at 1.25 times speed,
2165
+ showing the hold and slide of a knock. Offset fades are limited by progress along the drawn
2166
+ path and the body's declared maximum movement speed, so a late-input correction cannot erase more travel than the player makes or compound a fast knock's catch-up speed. Catch-up preserves the drawn pose when a snapshot replaces its history, and uses the next movement preview when its next whole-tick history entry is not yet available. When movement
2167
+ stops after catch-up, the remaining fade follows the configured blend time, still bounded by that speed. An effect on the body waits for its drawn tick. Placements, epoch changes and clock rebases
2168
+ jump deliberately. Other bodies keep the helper's adaptive interpolation, with one send period
2169
+ as the default minimum delay. The `room.predict` settings customize these defaults.
2170
+
2171
+ `body: { move: 'owner' }` remains available for existing rules games: the browser supplies its
2172
+ position and the host limits each claim to `maxSpeed`. It cannot prevent a modified owner walking
2173
+ through a wall. New Coin Dash and Gem Rush use authoritative server movement instead.
2160
2174
 
2161
2175
  ### 29.3 The life of a server-hosted room
2162
2176
 
@@ -631,6 +631,8 @@ export interface NetplayOptions<C = unknown> {
631
631
  checkpointMs?: number;
632
632
  /** Fixed interpolation delay in ms. Default: adaptive (arrival age p90 + 1.2 intervals). */
633
633
  interpDelayMs?: number;
634
+ /** Floor under the adaptive interpolation buffer (rules views). */
635
+ interpFloorMs?: number;
634
636
  /** Host: return the full rules state. Called every `checkpointMs`, before yielding, and on pagehide. */
635
637
  checkpoint?: () => C;
636
638
  /** ms to wait for a welcome before falling back to offline host, when the socket could not even open. Default 4000. */
@@ -2947,7 +2949,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2947
2949
 
2948
2950
  function interpDelay(): number {
2949
2951
  if (opts.interpDelayMs !== undefined) return opts.interpDelayMs;
2950
- return delaySmooth || Math.max(50, iv * 1.6 + 8);
2952
+ return Math.max(opts.interpFloorMs ?? 0, delaySmooth || Math.max(50, iv * 1.6 + 8));
2951
2953
  }
2952
2954
 
2953
2955
  // ---------------------------------------------------------------- connect
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.37.2",
3
+ "version": "0.39.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",
@@ -84,7 +84,11 @@
84
84
  "types": "./apps/apps.ts",
85
85
  "default": "./apps/apps.ts"
86
86
  },
87
- "./worker/selling": "./worker/selling/index.mjs"
87
+ "./worker/selling": "./worker/selling/index.mjs",
88
+ "./tools": {
89
+ "types": "./tools/tools.ts",
90
+ "default": "./tools/tools.ts"
91
+ }
88
92
  },
89
93
  "files": [
90
94
  "bin/",
@@ -111,7 +115,8 @@
111
115
  "NOTICE",
112
116
  "links/",
113
117
  "apps/",
114
- "app-starters/"
118
+ "app-starters/",
119
+ "tools/"
115
120
  ],
116
121
  "engines": {
117
122
  "node": ">=22"
@@ -122,17 +127,22 @@
122
127
  "@babel/parser": "8.0.7",
123
128
  "@babel/traverse": "8.0.7",
124
129
  "@babel/types": "8.0.6",
130
+ "@cloudflare/workers-oauth-provider": "1.2.3",
125
131
  "@coinbase/x402": "2.1.0",
126
132
  "@gltf-transform/core": "4.5.1",
127
133
  "@gltf-transform/extensions": "4.5.1",
128
134
  "@gltf-transform/functions": "4.5.1",
129
135
  "@jridgewell/trace-mapping": "0.3.31",
130
- "@modelcontextprotocol/sdk": "1.32.1",
136
+ "@modelcontextprotocol/sdk": "1.30.0",
137
+ "@modelcontextprotocol/server": "2.0.0",
131
138
  "@x402/core": "2.28.0",
132
139
  "@x402/evm": "2.28.0",
140
+ "agents": "0.28.0",
133
141
  "esbuild": "0.28.1",
134
142
  "gltf-validator": "2.0.0-dev.3.10",
135
143
  "jose": "6.2.12",
144
+ "mcp-purchase-sdk": "npm:@modelcontextprotocol/sdk@1.32.1",
145
+ "mcp-studio-client": "npm:@modelcontextprotocol/client@2.3.1",
136
146
  "meshoptimizer": "1.3.0",
137
147
  "mppx": "0.13.2",
138
148
  "puppeteer-core": "25.8.0",
@@ -140,10 +150,11 @@
140
150
  "spdx-expression-parse": "5.0.0",
141
151
  "stripe": "23.0.0",
142
152
  "three": "0.185.1",
153
+ "typescript": "5.9.3",
143
154
  "viem": "2.57.4",
144
- "typescript": "5.9.3"
155
+ "zod": "4.3.6"
145
156
  },
146
157
  "scripts": {
147
- "test": "node --test test/*.test.mjs"
158
+ "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs"
148
159
  }
149
160
  }
package/port/probe.ts CHANGED
@@ -123,7 +123,8 @@ export function exposePort(net: Netplay<unknown, unknown, unknown> | null, opts:
123
123
  rows.push([t, me ? me.x : NaN, me ? me.y : NaN, b.right[0], b.right[1], b.up[0], b.up[1], m, busy]);
124
124
  if (rows.length > RING) rows.splice(0, rows.length - RING);
125
125
  };
126
- const loop = (t: number): void => { sample(t); requestAnimationFrame(loop); };
126
+ // The position is sampled now; rAF may carry an earlier timestamp on a busy renderer.
127
+ const loop = (): void => { sample(performance.now()); requestAnimationFrame(loop); };
127
128
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(loop);
128
129
  const probe: PortProbe = {
129
130
  v: 1,
package/rules/core.ts CHANGED
@@ -177,7 +177,7 @@ export interface Core {
177
177
  * `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
178
178
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
179
179
  */
180
- export function createCore(c: Compiled, opts: { observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
180
+ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
181
181
  if (c.dims !== 2) throw new Error('this release runs rules with space.dims: 2; bodies with height (dims: 3) arrive in a later one');
182
182
  const dims = c.dims;
183
183
  const tickHz = c.settings.tickHz;
@@ -1002,6 +1002,7 @@ export function createCore(c: Compiled, opts: { observe?: (kind: string, handler
1002
1002
  }
1003
1003
  if (!k.move) continue;
1004
1004
  const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
1005
+ const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
1005
1006
  moveRadius = body.radius;
1006
1007
  run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
1007
1008
  // The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
@@ -1011,6 +1012,7 @@ export function createCore(c: Compiled, opts: { observe?: (kind: string, handler
1011
1012
  naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
1012
1013
  G.note = null;
1013
1014
  e.grounded = own(b, 'grounded') === true;
1015
+ if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } });
1014
1016
  }
1015
1017
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
1016
1018
  const list = [...ents.values()];
package/rules/host.ts CHANGED
@@ -58,6 +58,8 @@ export interface HostClock {
58
58
  }
59
59
  export interface HostOptions {
60
60
  game: string;
61
+ /** The build check compares a movement step before any tick handler can change it. */
62
+ moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
61
63
  /** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
62
64
  observe?(kind: string, handler: string, error?: string): void;
63
65
  /** Told of a written value the runtime changed to make it fit (core.ts `noted`): the build check listens. */
@@ -226,7 +228,7 @@ export function createHost(o: HostOptions): Host {
226
228
  }
227
229
  }
228
230
  if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
229
- const core = createCore(c, { observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
231
+ const core = createCore(c, { moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
230
232
  let epoch = core.epoch;
231
233
  if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
232
234
  let lastSaveAt = o.clock.now();
package/rules/pack.ts CHANGED
@@ -263,12 +263,39 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
263
263
  */
264
264
  export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
265
265
  G.left = quota;
266
+ const bag = coerceFields(motion, body.motion, dims);
267
+ const target: Record<string, unknown> = {};
268
+ const work: Record<string, unknown> = {};
269
+ // Match core.typed: scalar writes round immediately; collections copy on first read and settle at the end.
270
+ for (const [name, fd] of motion) {
271
+ const held = (v: unknown): unknown => { naming('body.motion', name); const out = coerce(fd, v, dims); naming(); return out; };
272
+ Object.defineProperty(target, name, { enumerable: true,
273
+ get: () => {
274
+ if (!mutable(fd)) return bag[name];
275
+ if (work[name] === undefined) {
276
+ const size = fd.t === 'list' ? 1 + (bag[name] as unknown[]).length * cellsOf(fd.of as Field)
277
+ : fd.t === 'map' ? 1 + Object.keys(bag[name] as object).length * (1 + cellsOf(fd.of as Field)) : cellsOf(fd);
278
+ charge(6 * size); work[name] = thaw(fd, bag[name]);
279
+ }
280
+ return work[name];
281
+ },
282
+ set: (v: unknown) => {
283
+ charge(mutable(fd) ? 6 * est(fd, v) : cellsOf(fd) === 1 ? 3 : 6 * cellsOf(fd));
284
+ bag[name] = held(v); work[name] = undefined;
285
+ },
286
+ });
287
+ }
288
+ body.motion = Object.preventExtensions(target);
266
289
  try { fn(body, input, ctx); } catch (error) { failed(error); }
290
+ try {
291
+ for (const [name, fd] of motion) if (work[name] !== undefined) {
292
+ charge(6 * est(fd, work[name])); naming('body.motion', name); bag[name] = coerce(fd, work[name], dims); naming();
293
+ }
294
+ } catch (error) { failed(error); }
267
295
  const used = quota - (G.left > 0 ? G.left : 0);
268
296
  G.left = Infinity;
269
- naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims);
270
- naming('body', 'motion'); const held = thawFields(motion, coerceFields(motion, body.motion, dims)); naming();
271
- return { pos, vel, heading, grounded: body.grounded === true, motion: held, used };
297
+ naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims); naming();
298
+ return { pos, vel, heading, grounded: body.grounded === true, motion: thawFields(motion, bag), used };
272
299
  }
273
300
 
274
301
  /** How many values `pack` has packed since this was last set to 0: the core reads it to charge a tick for the state it sends. */