@homie-rocks/studio 0.39.0 → 0.42.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 (83) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +11 -0
  3. package/bin/homie-studio.mjs +8 -0
  4. package/functions/FUNCTIONS.md +99 -0
  5. package/functions/functions.ts +13 -0
  6. package/lib/build.mjs +2 -0
  7. package/lib/cloudflare.mjs +9 -9
  8. package/lib/dev.mjs +2 -1
  9. package/lib/functions-build.mjs +36 -0
  10. package/lib/functions-cli.mjs +23 -0
  11. package/lib/port-check.mjs +6 -3
  12. package/lib/purchase-links.mjs +8 -0
  13. package/lib/rules-build.mjs +8 -1
  14. package/lib/rules-check-worker.mjs +3 -3
  15. package/lib/rules-check.mjs +3 -1
  16. package/lib/scaffold.mjs +16 -3
  17. package/lib/shop.mjs +1 -1
  18. package/lib/template-history.json +85 -0
  19. package/lib/tools-build.mjs +2 -0
  20. package/lib/upgrade.mjs +2 -0
  21. package/lib/worker-build.mjs +8 -20
  22. package/lib/worker-config.mjs +28 -0
  23. package/netplay/NETPLAY.md +74 -2
  24. package/package.json +7 -2
  25. package/rules/collision.ts +115 -0
  26. package/rules/core.ts +65 -25
  27. package/rules/host.ts +5 -1
  28. package/rules/math.ts +15 -5
  29. package/rules/rules.ts +33 -10
  30. package/rules/types.ts +2 -2
  31. package/rules/view.ts +126 -32
  32. package/shop/SHOP.md +4 -3
  33. package/starters/ember-vale/game.json +16 -4
  34. package/starters/ember-vale/map/main.json +66 -0
  35. package/starters/ember-vale/src/move.ts +31 -0
  36. package/starters/ember-vale/src/rules.ts +244 -0
  37. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  38. package/starters/ember-vale/tunables.json +100 -11
  39. package/starters/gem-rush/src/move.ts +9 -1
  40. package/starters/gem-rush/src/view.ts +2 -1
  41. package/starters/gem-rush-3d/game.json +23 -5
  42. package/starters/gem-rush-3d/map/main.json +153 -0
  43. package/starters/gem-rush-3d/src/move.ts +37 -0
  44. package/starters/gem-rush-3d/src/rules.ts +133 -0
  45. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  46. package/starters/gem-rush-3d/tunables.json +98 -11
  47. package/starters/hero-rush-3d/game.json +23 -5
  48. package/starters/hero-rush-3d/map/main.json +153 -0
  49. package/starters/hero-rush-3d/src/move.ts +49 -0
  50. package/starters/hero-rush-3d/src/rules.ts +200 -0
  51. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  52. package/starters/hero-rush-3d/tunables.json +260 -30
  53. package/tools/TOOLS.md +42 -2
  54. package/tools/tools.ts +3 -0
  55. package/worker/customer-mcp.mjs +77 -0
  56. package/worker/customer-resources.mjs +53 -0
  57. package/worker/customer-shop.mjs +54 -0
  58. package/worker/extensions.mjs +1 -0
  59. package/worker/functions-schema.mjs +55 -0
  60. package/worker/functions.mjs +93 -0
  61. package/worker/index.mjs +37 -2
  62. package/worker/machine-payments.mjs +1 -1
  63. package/worker/mcp-store.mjs +3 -2
  64. package/worker/mcp-tools.mjs +16 -4
  65. package/worker/mcp.mjs +31 -15
  66. package/worker/office-page.mjs +1 -1
  67. package/worker/players.mjs +5 -2
  68. package/worker/purchase-delivery.mjs +1 -0
  69. package/worker/purchase-hosted-readiness.mjs +0 -1
  70. package/worker/purchase-links.mjs +1 -1
  71. package/worker/purchase-machine.mjs +1 -0
  72. package/worker/purchase-office.mjs +1 -1
  73. package/worker/purchase-owner.mjs +0 -2
  74. package/worker/purchase-reconciliation.mjs +1 -0
  75. package/worker/purchase-routes.mjs +11 -17
  76. package/worker/purchase-store.mjs +1 -0
  77. package/worker/selling/runtime.mjs +4 -1
  78. package/worker/service-run.mjs +25 -0
  79. package/worker/service-terms.mjs +2 -0
  80. package/worker/shop-rules.mjs +9 -8
  81. package/worker/shop.mjs +22 -9
  82. package/worker/tool-webhook.mjs +1 -0
  83. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,60 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.42.0] - 2026-10-09
21
+
22
+ **Plugin 0.42.0**
23
+
24
+ Customers and other businesses can use a studio's tools, buy its goods and services, and trigger its own functions.
25
+
26
+ ### Added
27
+
28
+ - Customer catalogue, cart, checkout and agent purchases through the studio MCP server, using the existing payment standards and purchase lifecycle.
29
+ - Priced studio tools and configurable built-in audiences, with current permissions checked when listing and calling.
30
+ - Studio functions: plain-file event handlers, local firing, durable retries and office delivery status.
31
+
32
+ ### Changed
33
+
34
+ - MCP call rates and shop purchase/guest-buyer rates are opt-in.
35
+
36
+ ### Fixed
37
+
38
+ - Claim refunds use the same tip, used-item and window policy as the web shop. Guest carts create usable accounts before payment.
39
+ - Builds fully regenerate Worker configuration, remove stale generated bindings and the old default purchase limiter, and keep explicit additions in `wrangler.custom.json`.
40
+ - New functions skip history unless explicitly replayed; indexed subscriptions and cursors avoid unused events and prune completed delivery pages.
41
+ - Paid tools renew execution leases, return saved results on retries and automatically refund failed handlers. Unpaid quotes expire and are pruned.
42
+
43
+ ### Upgrade notes
44
+
45
+ - Run the toolkit upgrade and build, then commit the generated Worker configuration before deploying. Move hand-added Wrangler settings to root `wrangler.custom.json`; apply migrations 0018 and 0019 with the other studio migrations. Functions add a Cloudflare cron for retries only when declared.
46
+
47
+ ## [0.40.0] - 2026-10-09
48
+
49
+ **Plugin 0.40.0**
50
+
51
+ New 3D games keep their matches on the server and answer your controls immediately.
52
+
53
+ ### Added
54
+
55
+ - Rules games can move and face in three dimensions, jump and land, and collide with boxes, spheres, upright capsules and sloped height tiles. Declared level data reaches the server and the browser together and is checked with the game's save and restore.
56
+ - Prediction follows vertical movement and facing, eases corrections, and draws other players' poses and animation state smoothly. The build reports the completed local room-capacity trial separately from its generated game check.
57
+
58
+ ### Changed
59
+
60
+ - Gem Rush 3D, Hero Rush 3D and Ember Vale now use rules plus view with the server keeping the match, including bots, companions, pickups and combat. Ember Vale keeps its saved hero. All five game starters now use the rules runtime; Ember Vale keeps its two-dimensional play.
61
+
62
+ ### Fixed
63
+
64
+ - Predicted movement respects the server's floor and ceiling, and catch-up animation follows the pose being drawn.
65
+ - Knockback no longer stores blocked travel and bursts around obstacles. A jump or direction change partway through a tick preserves the current drawn position before advancing.
66
+ - Phone control checks start measuring with the first directional touch movement, so slow browser automation cannot cross the arena before measurement begins.
67
+ - Gem Rush keeps sliding along arena walls under thumb input, and a delayed animation frame cannot give a newly arrived effect a negative radius.
68
+
69
+ ### Upgrade notes
70
+
71
+ - Existing games keep their source and their hosting choice. The game skill is unchanged; its authoring update is the next slice.
72
+ - Capacity measurements in this release are local. Cloudflare capacity and billing have not been measured.
73
+
20
74
  ## [0.39.0] - 2026-10-09
21
75
 
22
76
  **Plugin 0.39.0** · [#78](https://github.com/homie-rocks/homie/pull/78)
@@ -48,7 +102,7 @@ Your studio has its own MCP server. Connect your AI with one browser approval an
48
102
 
49
103
  ## [0.38.0] - 2026-10-09
50
104
 
51
- **Plugin 0.39.0**
105
+ **Plugin 0.39.0** · [#77](https://github.com/homie-rocks/homie/pull/77)
52
106
 
53
107
  Your own character answers as soon as you press, even when the room runs on the server.
54
108
 
package/README.md CHANGED
@@ -44,6 +44,17 @@ asks (`game new <id> --from gem-rush`, or `--from ember-vale` for a hero who las
44
44
  npx -y @homie-rocks/studio mcp --studios "<the folder the studios live in>" # the toolkit as a local MCP server (stdio)
45
45
  ```
46
46
 
47
+ Every studio also has its own customer-facing `/mcp`. Customers can browse, quote a cart,
48
+ and pay through the same MPP/x402 purchase engine used for paid parts, or open the studio's
49
+ Stripe checkout for human approval. Studio tools choose `public`, `signed-in`, an app staff
50
+ role, or `owner`; a tool can sell its service with `price`. See [Studio tools](tools/TOOLS.md).
51
+
52
+ [Studio functions](functions/FUNCTIONS.md) run your code when an order is paid or refunded,
53
+ a player joins, a room closes, an app record changes, a tool is called, or a schedule fires.
54
+ Add one with `homie-studio function new paid-order --event order.paid`; fire an event locally
55
+ with `homie-studio function fire order.paid --input '{"order":"example"}'`. Deliveries persist,
56
+ retry with the same event ID, and show failures in the office. Policies and rate limits are opt-in.
57
+
47
58
  The toolkit as MCP tools, so the chat that shows Homie's cards also does the work: the setup status, a new studio,
48
59
  the live demo, make / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
49
60
  playtest, deploy, publish, the progress feed, the studio's files, and the music, sound, art and video scripts where
@@ -155,6 +155,9 @@
155
155
  * homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>]
156
156
  * [--kids] [--door open|accounts|invite] [--level 1-5] [--level-max 1-5]
157
157
  * [--speech game|lines|off] [--bots fill|off] [--rooms <n>] [--listed on|off]
158
+ * homie-studio function new <name> [--event order.paid]
159
+ * homie-studio function replay <name> --replay [--url <studio-site>]
160
+ * homie-studio function fire <event> [--input <JSON>] [--id <event-id>] [--url <local-site>]
158
161
  * homie-studio tool new <name> [--app <id>]
159
162
  * homie-studio tool call <name> --input <JSON> [--url <site>] [--public]
160
163
  * homie-studio servers set <game> <server> [the same flags]
@@ -489,9 +492,12 @@ function print(result) {
489
492
  lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
490
493
  break;
491
494
  }
495
+ case 'function new':
492
496
  case 'tool new':
493
497
  lines.push(result.file, result.next);
494
498
  break;
499
+ case 'function replay':
500
+ case 'function fire':
495
501
  case 'tool call':
496
502
  case 'tool list':
497
503
  lines.push(JSON.stringify(result.result, null, 2));
@@ -833,6 +839,7 @@ const OUTWARD_FLAGS = {
833
839
  'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
834
840
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
835
841
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
842
+ 'function replay': ['url','replay'], 'function new': ['event'], 'function fire': ['url','input','id'],
836
843
  'tool new': ['app'], 'tool call': ['url', 'input', 'public'], 'tool list': ['url', 'public'],
837
844
  // With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
838
845
  'standalone run': ['for', 'device', 'site'],
@@ -894,6 +901,7 @@ async function main() {
894
901
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
895
902
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
896
903
  const root = requireStudio();
904
+ if (cmd === 'function') return (await import('../lib/functions-cli.mjs')).functionCommand(root, sub, positional, flags, { log });
897
905
  if (cmd === 'tool') return (await import('../lib/tools-cli.mjs')).toolCommand(root, sub, positional, flags, { log });
898
906
  // A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
899
907
  if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
@@ -0,0 +1,99 @@
1
+ # Studio functions: when this happens, run that
2
+
3
+ A function is your trusted code in your own Worker. Run:
4
+
5
+ ```sh
6
+ homie-studio function new prepare-order --event order.paid
7
+ homie-studio dev
8
+ homie-studio function fire order.paid --id local-order-1 --input '{"order":"example"}'
9
+ ```
10
+
11
+ The fire command connects to the local studio through the existing browser OAuth
12
+ approval. It is an owner tool and refuses remote hosts. It records an event in the
13
+ same durable dispatcher as real events; firing the same ID again is a duplicate.
14
+
15
+ ```ts
16
+ import { defineFunction } from '@homie-rocks/studio/functions';
17
+ export default defineFunction<{ order: string }>({
18
+ name: 'prepare-order',
19
+ event: 'order.paid',
20
+ async handler(event, context) {
21
+ // For an external API, send event.id as its idempotency key.
22
+ await context.database.put(event.id, { order: event.data.order });
23
+ },
24
+ });
25
+ ```
26
+
27
+ Build discovers and type-checks `functions/*.ts`. Handlers have the studio tool
28
+ context and run with the studio's authority. They can change records, send a
29
+ room event through declared capabilities, call an external service, or implement
30
+ another business workflow. Treat event data as data, not instructions.
31
+
32
+ | Event | Data |
33
+ | --- | --- |
34
+ | `order.paid` | `order`, `source`, `amount`, `currency`, `mode` |
35
+ | `payment.refunded` | `refund`, `payment`, `amount`, `currency` (each successful Stripe refund, including partial refunds) |
36
+ | `order.refunded` | `order`, `source`, `refund`, `mode` (full refund) |
37
+ | `player.joined` | `game`, `room`, `player` (nullable), `seat` |
38
+ | `room.closed` | `game`, `room`, closing/ending facts |
39
+ | `record.changed` | `app`, `collection`, `record`, `version`, `operation`, `data` |
40
+ | `tool.called` | `tool`, `person`, `client` (successful call, no arguments) |
41
+
42
+ For a scheduled function, add `schedule: '0 9 * * *'` and a chosen event name,
43
+ for example `event: 'morning.open'`. Schedules use Cloudflare cron syntax and UTC.
44
+ The event includes the function name, cron and scheduled time. Its ID remains the
45
+ same if Cloudflare delivers the same tick again. Only that scheduled function
46
+ receives its tick. Build reads custom crons from `wrangler.custom.json` and adds a minute tick
47
+ for pending delivery retries while functions exist. Commit build changes before
48
+ deploying. Cloudflare's own plan limits apply; Homie sets no attempt or event cap.
49
+
50
+ Order, refund, record and tool events use database triggers: the source change
51
+ and event either both commit or both roll back. Room events first enter the room's
52
+ durable SQLite outbox; its alarm retries transfer to D1. Delivery is **at least
53
+ once**, including after a worker dies between an external effect and its local
54
+ acknowledgement. `event.id` is the de-duplication key and `event.attempt` is the
55
+ attempt number. Completed deliveries do not run again. Failed/crashed deliveries
56
+ retry after a one-minute lease. Make effects idempotent; a database marker written
57
+ before an external request is not proof that the external request completed.
58
+
59
+ Open **Office → Functions** for event IDs, states, attempt counts and failures.
60
+ Use its Retry button, or an authenticated same-origin POST to `/_studio/office/functions` with
61
+ `{"retry":"event-id"}` retries a failed event immediately. Failure text is generic
62
+ so a thrown credential is never rendered in the office. Inspect the function's
63
+ code and the studio's own provider logs to diagnose its failure.
64
+
65
+ Existing signed inbound tool webhooks remain the external integration door. Their
66
+ successful calls produce `tool.called`; record changes they make also produce
67
+ `record.changed`. Use functions for durable downstream work instead of expecting
68
+ a webhook sender to orchestrate the studio's internal actions. New declarations start at deployment and skip past events. To replay retained
69
+ history, explicitly add `replay: true` to the declaration, then run
70
+ `homie-studio function replay <name> --replay --url https://your-studio.example`.
71
+ The owner OAuth connection is required. Merely adding `replay: true` does not run
72
+ history. Fully delivered events are pruned; replay cannot recover pruned events.
73
+ Only declared event types are recorded. Each function has an indexed sequence
74
+ cursor; dispatch and maintenance use pages of 100, with no event or retry cap.
75
+
76
+
77
+ Worker configuration is generated at build and upgrade time from `studio.json`,
78
+ shop and tool declarations, game files, and function declarations. Commit the
79
+ build before deploy. Put custom bindings, variables, aliases, routes, crons or
80
+ observability settings in root `wrangler.custom.json` (plain JSON); objects merge
81
+ recursively and arrays replace generated arrays. Do not hand-edit the generated
82
+ `wrangler.jsonc`. Move existing manual additions into that file before upgrading.
83
+ `studio.json.cloudflare.ai` explicitly enables/disables AI; otherwise game
84
+ chat/decision declarations determine it. Set `cloudflare.routes` for routes and
85
+ `cloudflare.purchaseRateLimit` for an explicitly chosen Wrangler rate-limit
86
+ binding. An old generated purchase limiter is removed unless explicitly set.
87
+
88
+ Machine cart quotes bind an account before payment. Anonymous buyers receive
89
+ `account.player` and private `account.cookies`; retain the cookie and send its
90
+ name/value as the Cookie header to this studio to use the grants. These credentials
91
+ are returned only to the quoting caller, never embedded in the public offer.
92
+ Guest limits are checked at quote time using the incoming request address.
93
+ Quotes expire after 24 hours; paid orders retain their independent snapshots.
94
+
95
+ Paid handlers renew their execution lease while running. Retries share the first
96
+ result and never rerun an abandoned handler. A failed or abandoned run requests an
97
+ automatic Stripe refund; retries report completion or continue refund recovery.
98
+ Paid services require the studio's refundable Stripe connection; chain settlement
99
+ is not offered for services. Handler errors never expose their private contents.
@@ -0,0 +1,13 @@
1
+ import type { ToolContext, StudioTool } from '../tools/tools';
2
+ export interface StudioEvent<Data = unknown> { readonly id: string; readonly type: string; readonly at: number; readonly data: Data; readonly attempt: number }
3
+ export interface StudioFunction<Data = unknown> {
4
+ name: string;
5
+ event: string;
6
+ /** Cloudflare cron expression; scheduled events are named by this function. */
7
+ schedule?: string;
8
+ /** Permit an explicit owner replay command to consume retained history. */
9
+ replay?: boolean;
10
+ events?: StudioTool['events'];
11
+ handler(event: StudioEvent<Data>, context: ToolContext): unknown | Promise<unknown>;
12
+ }
13
+ export function defineFunction<Data = unknown>(definition: StudioFunction<Data>): StudioFunction<Data> { return definition; }
package/lib/build.mjs CHANGED
@@ -492,6 +492,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
492
492
  const esbuild = await studioEsbuild(root);
493
493
  const studio = readStudio(root);
494
494
  await (await import('./tools-build.mjs')).buildTools(root, esbuild);
495
+ await (await import('./functions-build.mjs')).buildFunctions(root, esbuild);
495
496
  // The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
496
497
  const shop = shopForBuild(root, { log });
497
498
  const live = join(root, 'site', 'dist');
@@ -544,6 +545,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
544
545
  if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
545
546
  built.push({
546
547
  id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
548
+ ...(rules ? { capacityTrial: rules.check.capacityTrial } : {}),
547
549
  ...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
548
550
  });
549
551
  log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
@@ -1,3 +1,4 @@
1
+ import { refreshWorkerConfig } from './worker-config.mjs';
1
2
  import { paidReleases, uploadPaidParts } from './parts-upload.mjs';
2
3
  /**
3
4
  * `homie-studio deploy` — the studio's site on the studio's OWN Cloudflare
@@ -41,7 +42,7 @@ import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File,
41
42
  import { whyFailed } from './net.mjs';
42
43
  import { repoOf } from './repo.mjs';
43
44
  import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
44
- import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
45
+ import { ensureLocalIgnored, ensureMigrations, migrationWord } from './scaffold.mjs';
45
46
  import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
46
47
  import { projectsCloudflareEnv } from './projects-env.mjs';
47
48
 
@@ -240,7 +241,6 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
240
241
  const wide = wideRouteRefusal(readConfig(root), studio);
241
242
  if (wide) return { ok: false, command: 'deploy', ...wide };
242
243
  let routes = keptRoutes(root, studio);
243
- const triggers = readConfig(root)?.triggers;
244
244
  const who = whoami(root);
245
245
  if (!who) {
246
246
  if (cf.auth === 'stripe-projects') {
@@ -277,6 +277,9 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
277
277
  if (zone) {
278
278
  if (ownRoute && zone.state === 'foreign') {
279
279
  routes = [...(routes ?? []), zone.route];
280
+ const customFile=join(root,'wrangler.custom.json');
281
+ const custom=existsSync(customFile)?JSON.parse(readFileSync(customFile,'utf8')):{};
282
+ writeFileSync(customFile,JSON.stringify({...custom,routes},null,2)+'\n');
280
283
  step(`added the studio's own exact-host route to "routes" in wrangler.jsonc: ${zone.line}. It covers ${zone.host} only, and every deploy keeps it; the other site's route was not touched.`, { route: zone.route });
281
284
  const config = readConfig(root) ?? {};
282
285
  zone = await zoneCheck(root, { studio, accountId, fetchFn, config: { ...config, routes: [...routesOf(config), zone.route] } });
@@ -368,17 +371,14 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
368
371
  const r2 = storage;
369
372
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
370
373
 
371
- writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
374
+ refreshWorkerConfig(root, {d1Id: db.uuid});
372
375
  if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
373
376
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
374
377
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
375
378
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
376
379
  step('D1 migrations applied');
377
- // Workers AI (0.17.0): bound only when a server's AI guides think with it.
378
- if (needsWorkersAi(root, w, cf.d1)) {
379
- writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
380
- step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
381
- }
380
+ const subscriptions=w(['d1','execute',cf.d1,'--remote','--command',(await import('./functions-build.mjs')).functionDeploymentSQL(root)]);
381
+ if(subscriptions.code!==0)return refuse('Could not register studio functions',subscriptions.out);
382
382
 
383
383
  const started = Date.now();
384
384
  let dep = w(['deploy', ...repoVar(root)]);
@@ -680,7 +680,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
680
680
  writeStudio(root, next);
681
681
  if (next.cloudflare.d1Id) {
682
682
  // The studio's own routes stay through this rewrite too (lib/routes.mjs).
683
- writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
683
+ refreshWorkerConfig(root);
684
684
  }
685
685
  log(`created R2 ${bucket}`);
686
686
  return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
package/lib/dev.mjs CHANGED
@@ -459,8 +459,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
459
459
  return;
460
460
  }
461
461
  };
462
+ const { functionFiles } = await import('./functions-build.mjs');
462
463
  const { toolFiles } = await import('./tools-build.mjs');
463
- const toolsStamp = () => toolFiles(root).map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
464
+ const toolsStamp = () => [...toolFiles(root),...functionFiles(root)].map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
464
465
  let lastTools = toolsStamp();
465
466
  const toolsLook = async () => {
466
467
  if (rebuilding || ending) return;
@@ -0,0 +1,36 @@
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
+ const require=createRequire(import.meta.url);
6
+ export function functionFiles(root) { const dir=join(root,'functions');return existsSync(dir)?readdirSync(dir).filter(f=>/^[a-z][a-z0-9-]*\.ts$/.test(f)).sort().map(f=>join(dir,f)):[]; }
7
+ export async function buildFunctions(root,esbuild) {
8
+ const files=functionFiles(root),dir=join(root,'site/src/functions');mkdirSync(dir,{recursive:true});
9
+ if(files.length) {
10
+ const ts=require('typescript'),options={target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,strict:true,noEmit:true,skipLibCheck:true};
11
+ const errors=ts.getPreEmitDiagnostics(ts.createProgram(files,options));
12
+ if(errors.length)throw new Error(ts.formatDiagnosticsWithColorAndContext(errors,{getCurrentDirectory:()=>root,getCanonicalFileName:f=>f,getNewLine:()=> '\n'}));
13
+ }
14
+ const contents=files.map((f,i)=>`import f${i} from ${JSON.stringify(f)};`).join('\n')+`\nexport default [${files.map((_,i)=>'f'+i).join(',')}];`;
15
+ const built=await esbuild.build({stdin:{contents,resolveDir:root},bundle:true,format:'esm',platform:'neutral',write:false,logLevel:'silent'});
16
+ const file=join(dir,'index.mjs');writeFileSync(file,built.outputFiles[0].text);
17
+ const definitions=(await import(pathToFileURL(file).href+'?build='+Date.now())).default;
18
+ const names=new Set();
19
+ for(const fn of definitions) {
20
+ if(!/^[a-z][a-z0-9-]*$/.test(fn.name)||names.has(fn.name)||typeof fn.handler!=='function'||typeof fn.event!=='string'||!fn.event.trim())throw new Error('Function needs a unique name, event and handler');
21
+ if(fn.schedule && (typeof fn.schedule!=='string'||fn.schedule.trim().split(/\s+/).length!==5))throw new Error('Function schedule needs a Cloudflare cron expression');
22
+ names.add(fn.name);
23
+ }
24
+ const worker=join(root,'site/src/worker.mjs');
25
+ if(existsSync(worker)) {let text=readFileSync(worker,'utf8');if(!text.includes('useFunctions(')){text+="\nimport { useFunctions } from '@homie-rocks/studio/worker';\nuseFunctions(async () => (await import('./functions/index.mjs')).default);\n";writeFileSync(worker,text);}}
26
+ writeFileSync(join(dir,'declarations.json'),JSON.stringify(definitions.map(({name,event,schedule,replay})=>({name,event,schedule,replay})),null,2)+'\n');
27
+ (await import('./worker-config.mjs')).refreshWorkerConfig(root);
28
+ return definitions;
29
+ }
30
+ /** Install subscriptions before the new Worker can produce its first event. */
31
+ export function functionDeploymentSQL(root) {
32
+ const file=join(root,'site/src/functions/declarations.json');
33
+ const definitions=existsSync(file)?JSON.parse(readFileSync(file,'utf8')):[];
34
+ const literal=value=>"'"+String(value).replaceAll("'","''")+"'";
35
+ return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),`DELETE FROM function_subscriptions WHERE name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
36
+ }
@@ -0,0 +1,23 @@
1
+ import {existsSync,mkdirSync,writeFileSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {connectToolClient} from './tools-cli.mjs';
4
+ export async function functionCommand(root,sub,positional,flags,{log=()=>{}}={}) {
5
+ if(sub==='new') {
6
+ const name=positional[2],event=flags.get('event')??'order.paid';
7
+ if(!/^[a-z][a-z0-9-]*$/.test(name??''))throw new Error('Use a lowercase function name');
8
+ const file=join(root,'functions',name+'.ts');if(existsSync(file))throw new Error('Function already exists');
9
+ mkdirSync(join(root,'functions'),{recursive:true});
10
+ writeFileSync(file,`import { defineFunction } from '@homie-rocks/studio/functions';\nexport default defineFunction({\n name: ${JSON.stringify(name)}, event: ${JSON.stringify(event)},\n async handler(event, context) {\n // Deduplicate external side effects with event.id (for example an API idempotency key).\n await context.database.put(event.id, event.data);\n },\n});\n`);
11
+ return {ok:true,command:'function new',file,next:'Edit your function, then run homie-studio dev.'};
12
+ }
13
+ if(sub==='replay') {
14
+ if(!flags.has('replay'))throw new Error('Pass --replay to explicitly request retained history');
15
+ const client=await connectToolClient(root,new URL(flags.get('url')??'http://localhost:8787'),{log});
16
+ try {const result=await client.callTool({name:'studio_function_replay',arguments:{name:positional[2]}});return {ok:!result.isError,command:'function replay',result};}finally{await client.close();}
17
+ }
18
+ if(sub!=='fire')throw new Error('Use function new <name> --event <event>, or function fire <event> --input <JSON>');
19
+ const url=new URL(flags.get('url')??'http://localhost:8787');
20
+ if(!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Fire is local testing only');
21
+ const client=await connectToolClient(root,url,{log});
22
+ try {const result=await client.callTool({name:'studio_function_fire',arguments:{event:positional[2],data:JSON.parse(flags.get('input')??'{}'),...(flags.has('id')?{id:flags.get('id')}: {})}});return {ok:!result.isError,command:'function fire',result};}finally{await client.close();}
23
+ }
@@ -314,10 +314,10 @@ async function focusGame(h) {
314
314
  async function holdKey(h, code, ms, ready) { await h.page.keyboard.down(code); if (ready) await ready(); await sleep(ms); await h.page.keyboard.up(code); }
315
315
 
316
316
  function thumbAt(h, i) { const t = i?.thumb ?? [0.24, 0.74]; return [Math.round(h.vp.width * t[0]), Math.round(h.vp.height * t[1])]; }
317
- async function touchDrag(h, from, dir, reach, holdMs, ready) {
317
+ async function touchDrag(h, from, dir, reach, holdMs, ready, steps = 8) {
318
318
  const [x0, y0] = from; const u = DIRV[dir];
319
319
  await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
320
- for (let k = 1; k <= 8; k++) { await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + (u[0] * reach * k) / 8), y: Math.round(y0 + (u[1] * reach * k) / 8) }]); await sleep(12); }
320
+ for (let k = 1; k <= steps; k++) { await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + (u[0] * reach * k) / steps), y: Math.round(y0 + (u[1] * reach * k) / steps) }]); await sleep(12); }
321
321
  if (ready) await ready();
322
322
  const end = Date.now() + holdMs;
323
323
  let w = 0;
@@ -369,7 +369,10 @@ export async function measuredPress(h, how, code, from, dir) {
369
369
  b = await frameNow(h);
370
370
  };
371
371
  if (how === 'keys') await holdKey(h, code, 0, ready);
372
- else await touchDrag(h, from, dir, 70, 0, ready);
372
+ // One real move establishes the measured direction. An eight-event ramp can
373
+ // take seconds on software Chrome and drive all the way into a wall before
374
+ // its final-point receipt starts the measurement. Holds still exercise ramps.
375
+ else await touchDrag(h, from, dir, 70, 0, ready, 1);
373
376
  return { dir, a, b };
374
377
  }
375
378
 
@@ -1,3 +1,5 @@
1
+ import {pathToFileURL} from 'node:url';
2
+ import {serviceTerms} from '../worker/service-terms.mjs';
1
3
  /** Uses the shop's authorized Stripe API transport; no API credential goes to the Worker. */
2
4
  import { createHash } from 'node:crypto';
3
5
  import { existsSync, readFileSync } from 'node:fs';
@@ -13,6 +15,12 @@ export async function syncPurchaseLinks({ root, api, slug, site, mode, list, til
13
15
  const p = readPart(packedDir(root,id,version)), config = settings[id], selected = config?.releases?.[version];
14
16
  return p.sale && config?.active !== false && selected?.active !== false && (version === versions.at(-1) || selected?.keepOnSale === true) ? [{kind:'part',resource:id,version,name:part.name,sale:{...p.sale,...config?.price}}] : [];
15
17
  }));
18
+ const {toolFiles,buildTools}=await import('./tools-build.mjs');
19
+ if(toolFiles(root).length){
20
+ await buildTools(root,await import('esbuild'));
21
+ const definitions=(await import(pathToFileURL(join(root,'site/src/tools/index.mjs')).href+'?links='+Date.now())).default;
22
+ offers.push(...definitions.filter(t=>t.price).map(t=>({kind:'service',resource:t.name,version:'1.0.0',name:t.description,sale:serviceTerms(t.price)})));
23
+ }
16
24
  const configured = offers.flatMap(o => {
17
25
  const quantities = settings[o.resource]?.quantities ?? [1];
18
26
  if (!Array.isArray(quantities) || !quantities.length || quantities.some(q=>!Number.isSafeInteger(q) || q<1 || !Number.isSafeInteger(q*o.sale.amount))) throw new Error(`Invalid Payment Link quantities for ${o.resource}`);
@@ -33,6 +33,10 @@ import { typecheckRules } from './typecheck.mjs';
33
33
  export { isRulesGame };
34
34
  export { stateHash } from './rules-check.mjs';
35
35
 
36
+ // A reference workload, not a promise about every game's rules or a Cloudflare measurement.
37
+ // Receipt and method: docs/rooms-slice-7-notes.md, "Budget measurements".
38
+ export const RULES_CAPACITY_TRIAL = Object.freeze({ game: 'hero-rush-3d', seats: 32, tickHz: 30, minutes: 20, host: 'Node', platform: 'macOS arm64', cloudflare: false });
39
+
36
40
  const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
37
41
  const RULES_FILES = { [RULES_MODULE]: 'rules.ts', [GUARD_MODULE]: 'guard.ts', [`${RULES_MODULE}/view`]: 'view.ts', [`${RULES_MODULE}/host`]: 'host.ts' };
38
42
 
@@ -150,6 +154,9 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
150
154
  for (const p of checked.problems) log(`warning: games/${g.id}/game.json: ${p}`);
151
155
  for (const line of checked.information) log(`info: ${line}`);
152
156
  const { settings, schema, publicTune, rounds, stateHash } = checked;
157
+ const trial = RULES_CAPACITY_TRIAL;
158
+ log(`info: rules runtime reference trial: ${trial.game}, ${trial.seats} players at ${trial.tickHz} Hz for ${trial.minutes} minutes on ${trial.host} (${trial.platform}). Cloudflare capacity and billing are unmeasured.`);
159
+ if (settings.tickHz > trial.tickHz) log(`info: games/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
153
160
  const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
154
161
  const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
155
162
  const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
@@ -171,7 +178,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
171
178
  build: createHash('sha256').update(guarded.code).update(JSON.stringify(built)).digest('hex'),
172
179
  snapshotCap, checkpointCap, snapshotBytes: stats.snapshotBytes, checkpointBytes: stats.checkpointBytes,
173
180
  units: checked.maxUnits, tickUnits: checked.maxTickUnits,
174
- check: { information: checked.information, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
181
+ check: { information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
175
182
  };
176
183
  }
177
184
 
@@ -236,7 +236,7 @@ function play(c, companions, allow) {
236
236
  const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
237
237
  const steering = new Map();
238
238
  let moveTick = 0;
239
- const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, dims: c.dims })]));
239
+ const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
240
240
  /**
241
241
  * An owner-moved body's own browser, for one tick: the game's `move` from where the last snapshot has the body, by
242
242
  * the step a browser takes (pack.ts `stepMove`), and the claim of where that leaves it. Its units are the play's.
@@ -350,8 +350,8 @@ function play(c, companions, allow) {
350
350
  const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
351
351
  error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
352
352
  units += result.used;
353
- const r = kind.body.radius, bounds = c.map.bounds;
354
- result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: result.pos.z }, c.dims);
353
+ const r = kind.body.radius, height = kind.body.height || 2 * r, bounds = c.map.bounds;
354
+ result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: c.dims === 3 ? Math.max(bounds.min.z, Math.min(bounds.max.z - height, result.pos.z)) : 0 }, c.dims);
355
355
  delete result.used;
356
356
  if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
357
357
  }
@@ -28,7 +28,9 @@ export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE
28
28
 
29
29
  /** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
30
30
  export function stateHash(c) {
31
- const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, save } = c;
31
+ const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save } = c;
32
+ // Keep existing 2D save compatibility hashes when the 3D map vocabulary grows.
33
+ const map = dims === 2 ? { name: geometry.name, bounds: geometry.bounds, boxes: geometry.boxes, circles: geometry.circles, spots: geometry.spots } : geometry;
32
34
  return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
33
35
  }
34
36
 
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
1
2
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
2
3
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
3
4
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
@@ -83,7 +84,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
83
84
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
84
85
  no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
85
86
  compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
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 } }] } : {}),
87
+ ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
87
88
  workers_dev: true,
88
89
  preview_urls: true,
89
90
  // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
@@ -154,7 +155,7 @@ An app is ONE screen that morphs with context: camera moves, panels unfold, its
154
155
 
155
156
  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.
156
157
 
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
+ 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. Customers can browse and buy through this same endpoint, and tools may declare a price using the studio's existing payments. Add event handlers with \`homie-studio function new <name> --event order.paid\`; read \`node_modules/@homie-rocks/studio/functions/FUNCTIONS.md\`. Test owner, staff and public calls locally. Read \`node_modules/@homie-rocks/studio/tools/TOOLS.md\` and the Homie plugin's \`tools\` skill.
158
159
 
159
160
  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\`.
160
161
 
@@ -763,6 +764,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
763
764
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
764
765
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
765
766
  [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
767
+ [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
768
+ [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
766
769
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
767
770
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
768
771
  '.claude/skills/.gitkeep': '',
@@ -934,13 +937,23 @@ function ensurePartsStateMigration(root) {
934
937
  return `site/migrations/${PURCHASE_STATE_FILE}`;
935
938
  }
936
939
 
940
+ function ensureFunctionCursors(root) {
941
+ const file=join(root,'site','migrations',FUNCTIONS_CURSOR_MIGRATION_FILE);
942
+ if(existsSync(file))return null;
943
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_CURSOR_MIGRATION);return `site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`;
944
+ }
945
+ function ensureFunctionsMigration(root) {
946
+ const file=join(root,'site','migrations',FUNCTIONS_MIGRATION_FILE);
947
+ if(existsSync(file))return null;
948
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_MIGRATION);return `site/migrations/${FUNCTIONS_MIGRATION_FILE}`;
949
+ }
937
950
  function ensureMcpMigration(root) {
938
951
  const file=join(root,'site','migrations',MCP_MIGRATION_FILE);
939
952
  if(existsSync(file))return null;
940
953
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
941
954
  }
942
955
  export function ensureMigrations(root) {
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);
956
+ 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), ensureFunctionsMigration(root), ensureFunctionCursors(root)].filter(Boolean);
944
957
  }
945
958
 
946
959
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
package/lib/shop.mjs CHANGED
@@ -287,7 +287,7 @@ export async function shopConnectManual(root, { fromClipboard = false, clipboard
287
287
  const shopNow = readShop(root);
288
288
  const till = managed === true ? 'stripe-managed' : managed === false ? 'stripe' : shopNow.shop?.till ?? 'stripe';
289
289
  const nonce = randomBytes(16).toString('hex');
290
- const paidParts = paidReleases(root).length > 0;
290
+ const paidParts = paidReleases(root).length > 0 || (shopNow.shop?.items?.length??0)>0 || (await import('./tools-build.mjs')).toolFiles(root).length>0;
291
291
  const page = connectPage({ paidParts, nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
292
292
  return await new Promise((done) => {
293
293
  let finished = false;