@homie-rocks/studio 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,9 +17,59 @@ 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.34.0] - 2026-10-08
21
+
22
+ **Plugin 0.35.0** · [#64](https://github.com/homie-rocks/homie/pull/64)
23
+
24
+ A plain shop: nothing is imposed unless you write it. A shop is open to guests by default, the protective rules 0.33.0 started with are a preset you choose, and a player can put several items in a cart and pay once.
25
+
26
+ ### Added
27
+
28
+ - **A cart.** A cart holds several items and quantities, free and paid lines together, and is paid in one Stripe Checkout Session. `shop.add(item, quantity, amount?)` and `shop.checkout()` build and pay for a cart; `shop.buy(item)` still buys one thing directly. Each line keeps the item as it was defined at the time of sale, grants exactly once, and can be refunded on its own in the office.
29
+ - A cart with only free lines grants its items without going to Stripe.
30
+ - `checkoutMinutes` in `shop.json` sets how long a checkout stays open, within Stripe's bounds of 30 to 1440 minutes.
31
+ - The shop accepts a restricted (`rk_`) or a full secret (`sk_`) Stripe key. A preview you give its own database and both Stripe secrets can sell; previews normally have no database.
32
+
33
+ ### Changed
34
+
35
+ - **Your shop is open by default.** 0.33.0 started every shop on the protective preset. Now a shop that names no preset has no policy: guests can buy, and buy again, without an age question or a parent step, from any page. You choose your items, wording, kinds, prices, dates, caps, refund windows and referral terms. `shop init` writes no policy.
36
+ - **Every policy is optional.** The account, age, kids-server, advantage, repeat-purchase, used-item refund and TV rules apply only when you write them, one by one or with `"preset": "protective"` or `"preset": "adults-only"`. Stripe Tax and the withdrawal acknowledgement are optional too. The toolkit no longer scans your items' wording in any preset. Flood protection stays, with rates you can change.
37
+ - **A player cannot refund a tip.** A refund window you set covers items, including used ones unless you choose otherwise. You can refund any order or any line from your office.
38
+ - A checkout now stays open for Stripe's default of 24 hours instead of 31 minutes.
39
+ - Shop and owned-item pages now ask Stripe about unfinished payments in the background (up to three at a time), instead of only when a purchase was refused by your spending cap. A paid order nobody came back to is found after a minute, with or without a cap, and by your Worker's scheduled handler if you set one.
40
+ - With a spending cap, a refund of one line gives back that line's amount. Before, a partial refund kept the whole price counted.
41
+
42
+ ### Fixed
43
+
44
+ - **The webhook signing secret is required.** The shop is ready only with both the Stripe key and the webhook signing secret, whichever kind of key you use, and the shop's setup steps and readiness name whichever is missing. Payment is confirmed by Stripe's signed event, not only by the buyer's return to your site.
45
+ - Cancelling or replacing a checkout asks Stripe to expire the open session before freeing its place under your spending cap. A checkout with no Stripe session frees its place after the checkout lifetime plus one minute.
46
+ - Refunds follow Stripe's own list of refunds and their statuses. A refund that names a line affects only that line, a pending refund keeps the item, and a failed refund gives it back. A partial refund made in the Stripe Dashboard lowers the order's balance, cap and referral totals without guessing a line. A refund of the whole order takes back the whole order, and a refund of a paid line leaves free lines in the same order alone. A refund event that arrives before its payment was recorded records the payment first.
47
+ - An order in dispute cannot be refunded until Stripe resolves the dispute, and a lost dispute keeps the refunds made before it. Every recorded payment is checked against Stripe's refunds and dispute state.
48
+ - A payment, its items and its referral share are recorded together, including a verified payment that arrives late. A paid order that is missing an item is repaired once.
49
+ - Shop errors no longer show Stripe keys or secrets. Deleting an account clears the checkout's buyer too. An older order whose item name is missing falls back to the catalog; an item with no definition gives an answer that can be retried and a note in the office.
50
+ - Checkout from inside a game keeps the running game in its tab and explains a blocked tab or blocked cookies. When a new tab cannot open, game and TV checkout use the current tab and return to the game or TV page.
51
+ - Signed Stripe events that are not about your shop are ignored. Optional cron triggers survive a deploy.
52
+
53
+ ### Upgrade notes
54
+
55
+ - **To keep the 0.33.0 behaviour, write one line.** A `shop.json` that names no preset was protective in 0.33.0 and is open in 0.34.0. To keep the account and age rules you had, write `"policy": { "preset": "protective" }` in `shop.json`. Settings you wrote yourself stay as written. The wording scans are gone in every preset.
56
+ - To keep Stripe Tax, write `"automaticTax": true`. To count referrals for new players only, write `"referralNewPlayersOnly": true`. To keep the shorter checkout, write `"checkoutMinutes": 31`.
57
+ - **Deploy applies one new database step, `0012_shop_lines.sql`.** It only adds: your orders become one-line orders, and nothing is removed, so the 0.33.0 Worker keeps working while you deploy and if you roll back. Until the step has run, owned items and office refunds work and checkout says which step is missing.
58
+ - **The webhook signing secret is required, with either kind of key.** A shop with a key and no signing secret reports not ready and does not sell. `homie-studio shop connect` makes the webhook and stores the secret, or takes one you made yourself.
59
+
60
+ ## [0.33.1] - 2026-10-08
61
+
62
+ **Plugin 0.34.1** · [#65](https://github.com/homie-rocks/homie/pull/65) · [release-2026-10-08-studio-0.33.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.33.1)
63
+
64
+ A fix for studios upgrading to 0.33.0, found by upgrading one of Homie's own.
65
+
66
+ ### Fixed
67
+
68
+ - **A game that already had its own `src/rules.ts` builds again.** 0.33.0 treated any game with a file of that name as a game whose rules run on the server, checked it against those rules and refused the build. A game is written that way only when its game.json has a `"room"` object; every other game builds exactly as it did, whatever its files are called.
69
+
20
70
  ## [0.33.0] - 2026-10-08
21
71
 
22
- **Plugin 0.34.0** · [#57](https://github.com/homie-rocks/homie/pull/57)
72
+ **Plugin 0.34.0** · [#57](https://github.com/homie-rocks/homie/pull/57) · [release-2026-10-08-studio-0.33.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.33.0)
23
73
 
24
74
  A game's rules can run on your own Cloudflare instead of in a player's browser. This is the first piece of it: the format, the server that runs it, and one example game. Your existing games are not touched.
25
75
 
package/README.md CHANGED
@@ -452,9 +452,9 @@ npx homie-studio lounge mod <player id> # a mode
452
452
  A studio sells items for its games (0.24.0; Stripe's own agent tools since 0.24.3) with **its own Stripe account**: the studio is the seller, money goes
453
453
  straight from players to its Stripe, and homie.rocks never sees, holds or moves it (Homie takes no cut; no shared
454
454
  currency; not a Connect platform). The studio chooses its prices, item kinds and optional spending and refund
455
- settings, with no Homie ceiling. New shops start with the editable `protective` policy: accounts and an age
456
- question, no purchases for under-13s, parent checkout for teens, no shop on kids servers, and no paid randomness.
457
- The studio can inspect and change each rule in shop.json; [shop/SHOP.md](shop/SHOP.md) explains every preset.
455
+ settings, with no Homie ceiling. The default is open: guests can buy repeatedly without an age question,
456
+ from any page, with any wording. `protective` and `adults-only` are optional presets; neither scans content.
457
+ The studio chooses its settings in shop.json; [shop/SHOP.md](shop/SHOP.md) explains them.
458
458
 
459
459
  ```sh
460
460
  npx homie-studio shop init --supporter # shop.json with a US$5 Supporter pack, and SELLING.md (the owner's plain words)
@@ -473,10 +473,10 @@ connect page, which makes the webhook with it, so its signing secret goes straig
473
473
  chat. A webhook is never made through Stripe's MCP (its answer carries the secret). From 2026-10-31 Stripe's MCP takes
474
474
  only OAuth or Agent-tagged keys; the shop's own key is a plain restricted key and is not affected.
475
475
 
476
- In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`); a
477
- supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), Stripe Tax on, or Stripe Managed
476
+ In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`, `add`, `checkout`, `buy`); a
477
+ supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), optional Stripe Tax, or Stripe Managed
478
478
  Payments (Stripe as seller of record, 3.5% more) as one switch; a signed, idempotent webhook; refunds from the
479
- office; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
479
+ office, per order or line; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
480
480
  referrer). The guide: `shop/SHOP.md`.
481
481
 
482
482
  ## Setup status
package/lib/build.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  * site/dist/games.json { studio, games[], songs[], videos[], posts[], site, shop } from studio.json,
19
19
  * game.json files, the music/ and videos/ manifests (media/MEDIA.md),
20
20
  * posts/*.md, the studio's site/ folder (site/SITE.md) and shop.json (checked
21
- * against the kit's rules: a shop that breaks them stops the build)
21
+ * against studio settings, Stripe constraints and arithmetic correctness)
22
22
  * site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
23
23
  * site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
24
24
  * site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
@@ -385,7 +385,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
385
385
  let bundled = null;
386
386
  let chunks = [];
387
387
  const budgets = modelBudgetsOf(g, log);
388
- // RULES PLUS VIEW (rooms on the server). A game with a src/rules.ts has its rules checked, guarded and loaded first:
388
+ // RULES PLUS VIEW (rooms on the server). A game with "room" in game.json and a src/rules.ts has its rules checked, guarded and loaded first:
389
389
  // a rule the wall refuses stops the build here, with the line named. A game without one is hosted by a player's
390
390
  // browser exactly as before, and cannot ask for the server.
391
391
  const ruled = isRulesGame(g);
@@ -489,7 +489,7 @@ export function orderGames(games, order, log = () => {}) {
489
489
  export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
490
490
  const esbuild = await studioEsbuild(root);
491
491
  const studio = readStudio(root);
492
- // The shop first (shop/SHOP.md): a shop.json that breaks the kit's rules stops the build before anything is built.
492
+ // The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
493
493
  const shop = shopForBuild(root, { log });
494
494
  const live = join(root, 'site', 'dist');
495
495
  const games = listGames(root).filter((g) => !only || g.id === only);
@@ -677,7 +677,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
677
677
  : studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
678
678
  // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
679
679
  ...(studio.template === true ? { template: true } : {}),
680
- // studio.json "audience": "kids" (a studio made for children: no shop at all) or "teens"; and "referrals": false
680
+ // studio.json "audience": "kids" (a studio audience label; shop policies are optional) or "teens"; and "referrals": false
681
681
  // (this studio takes no referral statements as a referrer).
682
682
  ...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
683
683
  ...(studio.referrals === false ? { referrals: false } : {}),
@@ -238,6 +238,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
238
238
  const wide = wideRouteRefusal(readConfig(root), studio);
239
239
  if (wide) return { ok: false, command: 'deploy', ...wide };
240
240
  let routes = keptRoutes(root, studio);
241
+ const triggers = readConfig(root)?.triggers;
241
242
  const who = whoami(root);
242
243
  if (!who) {
243
244
  if (cf.auth === 'stripe-projects') {
@@ -352,7 +353,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
352
353
  const r2 = storage;
353
354
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
354
355
 
355
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes }));
356
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
356
357
  if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
357
358
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
358
359
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
@@ -360,7 +361,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
360
361
  step('D1 migrations applied');
361
362
  // Workers AI (0.17.0): bound only when a server's AI guides think with it.
362
363
  if (needsWorkersAi(root, w, cf.d1)) {
363
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes }));
364
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
364
365
  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)');
365
366
  }
366
367
 
package/lib/scaffold.mjs CHANGED
@@ -29,7 +29,7 @@ import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs
29
29
  import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
30
30
  import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
31
31
  import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
32
- import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE } from '../worker/shop-store.mjs';
32
+ import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE, SHOP_LINES, SHOP_LINES_FILE } from '../worker/shop-store.mjs';
33
33
  import { LOUNGE_MIGRATION, LOUNGE_MIGRATION_FILE } from '../worker/lounge-store.mjs';
34
34
  import { themeFile } from './site.mjs';
35
35
  import { rulesIndex } from './studio.mjs';
@@ -64,7 +64,7 @@ export function slugify(name) {
64
64
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
65
65
  * the binding names and keep it linked.
66
66
  */
67
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null }) {
67
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, triggers = null }) {
68
68
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
69
69
  : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
70
70
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
@@ -85,6 +85,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
85
85
  // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
86
86
  // (lib/routes.mjs). A studio without routes gets no key at all.
87
87
  ...(routes?.length ? { routes } : {}),
88
+ ...(triggers ? { triggers } : {}),
88
89
  assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
89
90
  durable_objects: { bindings: rooms },
90
91
  migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
@@ -433,41 +434,30 @@ studio's pinned copy, never a registry lookup of the bare name.
433
434
 
434
435
  ## Selling things (the shop)
435
436
 
436
- - **The studio sells with its OWN Stripe account.** The studio is the seller: its prices, its refunds, its tax, its
437
- disputes. Money goes from players to the studio's Stripe; homie.rocks never sees it and Homie takes no cut.
438
- \`SELLING.md\` says what that means for the owner in plain words (not legal advice).
439
- - **\`shop.json\`** is the studio's settings file: \`till\` (\`stripe\`, \`stripe-managed\`, or \`off\`), \`currency\`,
440
- optional \`refundDays\` and \`capPerPlayerMonth\`, and items with studio-chosen kinds, names, prices and entitlement
441
- keys. No Homie ceiling on prices, tips, item count, keys, text, durations or referral terms. Stripe currency
442
- units and provider minimums still apply, including zero-decimal currencies. \`shop init --supporter\` writes a
443
- supporter item and explicit protective policy, with no spending cap or refund window. Existing values stay.
444
- - **The studio chooses who may buy:** \`policy.preset\` is \`protective\` (the default), \`adults-only\`, or \`custom\`.
445
- Each rule can be read and edited in shop.json; SHOP.md lists them. Protective keeps the account and age
446
- question, under-13 refusal, parent checkout for teens, closed kids shops, beginner fairness, refusal of paid
447
- randomness and countdowns, and TV code to a phone. Do not silently relax protection for children. The studio
448
- is responsible for law and provider terms where it sells; presets are not legal advice.
449
- - **Flood protection:** \`purchaseAttemptsPerMinute\` (default 6) is per account; \`purchaseAttemptsPerAddressPerMinute\` (default 600) is per address. Both are per Worker instance and configurable.
450
- New guest buyers use \`guestBuyersPerAddressPerHour\` (default 600). These protect against floods, not spending.
451
- - **In a game:** \`import { createShop } from '@homie-rocks/studio/shop'\`; \`shop.has('skin:ember')\`,
452
- \`shop.entitlements()\`, \`shop.on('change', …)\`, \`shop.open(item)\` from a button the player pressed (never the
453
- play button, never on a timer), \`shop.used(key)\` when it is equipped. A supporter's badge rides on their seat
454
- (\`peer.badge\`, set by the Worker, never by a hello). The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
455
- - **Stripe's own tools** (set up when the studio starts selling): \`npm install -g @stripe/cli@latest && stripe agent
456
- setup\` installs Stripe's agent plugin (its MCP server and skills); the owner signs in once on Stripe's page and
457
- gives access to a sandbox first. With it you make the catalog (\`npx --no-install homie-studio shop catalog\` says
458
- the read, then the exact \`stripe_api_write\` calls), check tax settings, and answer "how are sales?" read-only.
459
- Never make a webhook or an API key through the MCP (a secret would land in the chat), and never retry a write
460
- Stripe sent to the owner for confirmation until they approved it.
461
- - **The key** goes in only from a page on the owner's own computer: \`npx --no-install homie-studio shop connect\`
462
- prints a 127.0.0.1 link; the owner pastes one restricted key there, the page makes the webhook with it, and the
463
- key and the webhook's secret go straight to the Worker secrets. Test keys only unless \`--live\`. Never ask for a
464
- key in the chat; never write one anywhere.
465
- - **Refunds:** the owner's one tap in the office (\`/_studio/office/shop\`). You may only ASK:
466
- \`homie-studio shop refund <order>\` gives the owner a one-tap link. A card dispute never touches the player's
467
- account. \`shop\` says what is missing; \`shop orders\` lists orders (never a card or an email).
468
- - **Referrals:** a \`?via=<host>\` link from another site (homie.rocks is one more referrer) is remembered on a new
469
- player's first visit; a kept sale owes that referrer the rate in \`shop.json\` \`referrals\`. \`shop statements\`
470
- signs them; the referrer invoices the studio. Nothing moves through Homie.
437
+ Your shop, your choices. The studio sells through its own Stripe account and is responsible for the law
438
+ where it sells and for Stripe's terms. Homie takes no cut.
439
+
440
+ - \`shop.json\` lists items, wording, kinds, prices, quantities and entitlements. Optional settings include
441
+ sale dates, tips, currency, \`automaticTax\`, refund windows, spending caps and referral terms.
442
+ - The default is open: guests can buy repeatedly, without age questions, from any page. Signing in keeps
443
+ guest purchases. \`shop init --supporter\` writes an example item without a policy, cap or refund window.
444
+ - Choose \`policy.preset: "protective"\` or \`"adults-only"\` to enable an account and age policy. Omitted
445
+ presets, including in existing files, use the open default. Individual overrides are in SHOP.md.
446
+ - \`createShop()\` from \`@homie-rocks/studio/shop\` supplies \`open\`, \`has\`, \`entitlements\`, \`used\`, and \`change\`.
447
+ \`shop.add(item, quantity)\` builds a cart; \`shop.checkout()\` pays; \`shop.buy(item)\` buys directly.
448
+ - A cart has independent lines and quantities. The office can refund a whole order or line. The optional
449
+ player refund window covers items; a tip is never taken back by the player.
450
+ - Flood settings are configurable: buyer attempts 6/minute, address attempts 600/minute,
451
+ new guest buyers 600/hour. Stripe constraints, integer arithmetic, ownership and signatures protect payments.
452
+ - \`shop check\` validates settings; \`shop connect\` provides a local page for credentials, stored as Worker
453
+ secrets. Stripe's agent tools can manage Products through \`shop catalog\`; the connect page creates the
454
+ webhook so its secret stays out of chat. Both the key and signing secret are part of working payment setup.
455
+ Stripe's approval links belong to the owner. \`checkoutMinutes\` is optional (Stripe: 30 to 1440 minutes,
456
+ default 1440; values below 31 use 31 for transport margin); cancelling a named checkout, or replacing one at least a minute old, expires the open session on Stripe's confirmation.
457
+ - \`shop orders\` and \`/_studio/office/shop\` show sales. \`shop refund <order>\` asks the owner to confirm.
458
+ \`shop statements\` records optional referral shares; studios pay their referrers directly.
459
+
460
+ The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
471
461
 
472
462
  ## Continuing a build from the Claude app
473
463
 
@@ -730,6 +720,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
730
720
  [`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
731
721
  [`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
732
722
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
723
+ [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
733
724
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
734
725
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
735
726
  '.claude/skills/.gitkeep': '',
@@ -852,6 +843,14 @@ function ensureShopStatements(root) {
852
843
  return `site/migrations/${SHOP_STATEMENTS_FILE}`;
853
844
  }
854
845
 
846
+ function ensureShopLines(root) {
847
+ const file = join(root, 'site', 'migrations', SHOP_LINES_FILE);
848
+ if (existsSync(file)) return null;
849
+ mkdirSync(dirname(file), { recursive: true });
850
+ writeFileSync(file, SHOP_LINES);
851
+ return `site/migrations/${SHOP_LINES_FILE}`;
852
+ }
853
+
855
854
  /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
856
855
  /** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
857
856
  function ensureChatMigration(root) {
@@ -872,7 +871,7 @@ function ensureLoungeMigration(root) {
872
871
  }
873
872
 
874
873
  export function ensureMigrations(root) {
875
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root)].filter(Boolean);
874
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root)].filter(Boolean);
876
875
  }
877
876
 
878
877
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -31,11 +31,11 @@ export async function wantedProducts(shop, slug) {
31
31
  return Promise.all((shop?.items ?? []).map(async (it) => {
32
32
  const id = await productIdOf(slug, it.id);
33
33
  return {
34
- id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: defaultTaxCode(it),
34
+ id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: it.taxCode ?? (shop.till === 'stripe-managed' ? defaultTaxCode(it) : null),
35
35
  // Stripe metadata values: 500 characters. https://docs.stripe.com/metadata (fetched 2026-10-08).
36
36
  metadata: { homie: MARK, homie_studio: String(slug), homie_item: it.id.length <= 500 ? it.id : id },
37
- price: it.kind === 'tip' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
38
- shown: it.kind === 'tip' ? `pay what you want, ${money(it.min, shop.currency)}${it.max === null ? ' or more' : ` to ${money(it.max, shop.currency)}`}` : money(it.price, shop.currency),
37
+ price: it.price === 'choose' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
38
+ shown: it.price === 'choose' ? `pay what you want, ${money(it.min, shop.currency)}${it.max === null ? ' or more' : ` to ${money(it.max, shop.currency)}`}` : money(it.price, shop.currency),
39
39
  };
40
40
  }));
41
41
  }
@@ -96,7 +96,7 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
96
96
  if (!p) {
97
97
  writes.push({
98
98
  tool: 'stripe_api_write', method: 'POST', path: '/v1/products', why: `make ${w.name} (${w.shown})`,
99
- params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), tax_code: w.tax_code, metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
99
+ params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), ...(w.tax_code ? { tax_code: w.tax_code } : {}), metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
100
100
  });
101
101
  continue;
102
102
  }
package/lib/shop.mjs CHANGED
@@ -32,7 +32,7 @@ import { fileURLToPath } from 'node:url';
32
32
  import { askedFor, withKey } from './office.mjs';
33
33
  import { runner } from './cloudflare.mjs';
34
34
  import { listGames, readStudio, siteUrl } from './studio.mjs';
35
- import { POLICY_PRESETS, SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
35
+ import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
36
36
  import {
37
37
  KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
38
38
  stripeCall, updateWebhookEndpoint,
@@ -43,7 +43,7 @@ const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
43
43
  /** The events the webhook endpoint listens to (the owner ticks these in Stripe). */
44
44
  export const HOOK_EVENTS = Object.freeze([
45
45
  'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
46
- 'charge.refunded', 'refund.created', 'refund.updated', 'charge.dispute.created', 'charge.dispute.closed',
46
+ 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
47
47
  ]);
48
48
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
49
49
  export const KEY_PERMISSIONS = Object.freeze([
@@ -77,10 +77,10 @@ export function shopForBuild(root, { log = () => {} } = {}) {
77
77
  export function shopCheck(root) {
78
78
  const r = readShop(root);
79
79
  if (r.absent) return { ok: true, command: 'shop check', absent: true, message: 'No shop.json: this studio sells nothing (homie-studio shop init writes one).' };
80
- return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.kind === 'tip' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
80
+ return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.price === 'choose' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
81
81
  }
82
82
 
83
- /** The Supporter pack the kit suggests first: deterministic, never pay-to-win, a badge on the profile and in rooms. */
83
+ /** The Supporter pack the kit suggests first: a badge on the profile and in rooms; the studio can replace or edit it. */
84
84
  export function supporterItem({ price = 500, days = 365, name = 'Supporter' } = {}) {
85
85
  return { id: 'supporter', kind: 'supporter', name, price, days, gives: ['badge:supporter'], badge: 'Supporter', blurb: 'A Supporter badge on your account and beside your name in rooms, for a year. It changes nothing about how any game plays.' };
86
86
  }
@@ -91,7 +91,6 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
91
91
  const studio = readStudio(root);
92
92
  const shop = {
93
93
  till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
94
- policy: { preset: 'protective', ...POLICY_PRESETS.protective },
95
94
  items: supporter ? [supporterItem({ price: Number(price) })] : [],
96
95
  };
97
96
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
@@ -99,7 +98,7 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
99
98
  writeFileSync(file, `${JSON.stringify(shop, null, 2)}\n`);
100
99
  const wrote = [SHOP_FILE];
101
100
  if (!existsSync(join(root, 'SELLING.md')) && existsSync(SELLING)) { writeFileSync(join(root, 'SELLING.md'), readFileSync(SELLING, 'utf8')); wrote.push('SELLING.md'); }
102
- return { ok: true, command: 'shop init', wrote, till: shop.till, items: shop.items.map((i) => i.id), next: ['homie-studio shop check', 'npm run deploy (the shop stays closed until the key is in)', 'homie-studio shop connect (the owner pastes a restricted key into a page on this computer)'] };
101
+ return { ok: true, command: 'shop init', wrote, till: shop.till, items: shop.items.map((i) => i.id), next: ['homie-studio shop check', 'npm run deploy (the shop stays closed until the key and webhook signing secret are in)', 'homie-studio shop connect (the owner pastes a Stripe key into a page on this computer)'] };
103
102
  }
104
103
 
105
104
  /** The office's view of the live shop: open or what is missing, the last 30 days, links to Stripe. */
@@ -162,8 +161,9 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
162
161
  <style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2.5rem auto;padding:0 1rem;color:#1b1b1b}h1{font-size:1.6rem}h2{font-size:1.1rem;margin-top:1.8rem}input[type=password]{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}label{display:block;margin:.8rem 0 .3rem;font-weight:600}fieldset{border:1px solid #ccc;border-radius:10px;padding:.6rem 1rem}fieldset label{font-weight:400;display:flex;gap:.6rem;align-items:flex-start}button{margin-top:1.2rem;font:inherit;font-weight:700;padding:.7rem 1.3rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}code{font-size:.85em;background:#f2f2f2;padding:0 .25em;border-radius:4px}ol li,ul li{margin:.25rem 0}.box{background:#f6f6f2;border-radius:10px;padding:.8rem 1rem}details{margin-top:1rem}summary{cursor:pointer}</style>
163
162
  <h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
164
163
  <p>Money goes straight from players to <b>your own Stripe account</b>. Homie never sees it and takes no cut. What you paste here goes from this page to Stripe (one check) and to your studio's Worker as a secret, and nowhere else: not to the chat, not to a file, not to a log.</p>
165
- <p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code>): nothing real is charged, and test cards work. Live keys only when you say the shop is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
166
- <h2>1. In Stripe: one restricted key</h2>
164
+ <p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code> or <code>sk_test_</code>): nothing real is charged, and test cards work. Live keys only when you say the shop is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
165
+ <h2>1. In Stripe: a key for this shop</h2>
166
+ <p>You can use a restricted (<code>rk_</code>) or full secret (<code>sk_</code>) key. To choose a restricted key with just the permissions this shop uses:</p>
167
167
  <ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b>. If Stripe asks what the key is for, choose your own integration, <b>not</b> "Authorizing agent access" (Stripe holds an agent key's refunds for a second approval).</li>
168
168
  <li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
169
169
  <li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
@@ -171,7 +171,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
171
171
  <p>With your key, this page makes the endpoint where Stripe tells your shop about payments (<code>${esc(hookUrlOf(site))}</code>, for ${HOOK_EVENTS.length} events), and its signing secret goes straight to your Worker. Nobody sees it. An older one this page made for the same address is turned off, never deleted.</p>
172
172
  <form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
173
173
  <h2>3. Paste the key here</h2>
174
- <label for="key">Restricted key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
174
+ <label for="key">Stripe key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
175
175
  <details><summary>I made the webhook myself (or my key has no Webhook Endpoints permission)</summary>
176
176
  <ol><li>Open <a href="${dash}/workbench/webhooks" target="_blank" rel="noopener">Webhooks</a> and press <b>Create an event destination</b> (Your account, a Webhook endpoint).</li>
177
177
  <li>Endpoint URL: <code>${esc(hookUrlOf(site))}</code></li><li>Events: ${events}</li>
@@ -179,7 +179,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
179
179
  <label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…"></details>
180
180
  <h2>4. Who is the seller?</h2>
181
181
  <fieldset>
182
- <label><input type="radio" name="till" value="stripe"${till !== 'stripe-managed' ? ' checked' : ''}> <span><b>You are</b> (standard Stripe). Stripe takes its usual card fee (in the US 2.9% + 30¢ a sale; in Canada 2.9% + CA$0.30). Stripe Tax is on: it works out and collects sales tax and VAT where you have told Stripe you are registered (0.5% a sale there). Registering and filing are yours, and some countries (the EU, the UK) expect a foreign seller to register from the first sale.</span></label>
182
+ <label><input type="radio" name="till" value="stripe"${till !== 'stripe-managed' ? ' checked' : ''}> <span><b>You are</b> (standard Stripe). Stripe takes its usual card fee (in the US 2.9% + 30¢ a sale; in Canada 2.9% + CA$0.30). Set automaticTax: true in shop.json to enable Stripe Tax: it works out and collects sales tax and VAT where you have told Stripe you are registered (0.5% a sale there). Registering and filing are yours, and some countries (the EU, the UK) expect a foreign seller to register from the first sale.</span></label>
183
183
  <label><input type="radio" name="till" value="stripe-managed"${till === 'stripe-managed' ? ' checked' : ''}> <span><b>Stripe is</b> (Stripe Managed Payments). <b>3.5% more</b> a sale, on top of the card fee. Stripe becomes the seller of record: it registers for, collects, files and pays sales tax and VAT in 80+ countries, runs fraud checks, answers card disputes for you and handles buyers' payment questions. Statements read <code>LINK.COM*</code>. You still cover the money of a lost dispute, and Stripe may refund a buyer within 60 days. Turn it on in Stripe first (<a href="${dash}/settings/managed-payments" target="_blank" rel="noopener">Managed Payments</a>, after Stripe's eligibility review; Canada and the US are among the countries it serves).${test ? ' In test mode this page tries one test checkout with Managed Payments (expired at once) and says whether Stripe takes it.' : ''}</span></label>
184
184
  </fieldset>
185
185
  <button>Save to my Worker</button></form>
@@ -225,7 +225,7 @@ async function makeWebhook(env, site, { fetcher }) {
225
225
  * (there is no API to read it). Never in live mode.
226
226
  */
227
227
  async function probeManaged(env, shop, { fetcher }) {
228
- const item = shop?.items?.find((i) => i.kind !== 'tip') ?? null;
228
+ const item = shop?.items?.find((i) => i.price !== 'choose') ?? null;
229
229
  try {
230
230
  const s = await stripeCall(env, 'POST', '/v1/checkout/sessions', {
231
231
  mode: 'payment', success_url: 'https://example.com/homie-shop-check', cancel_url: 'https://example.com/homie-shop-check',
@@ -270,10 +270,9 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
270
270
  const say = ({ status, text }) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
271
271
  if (form.get('n') !== nonce || finished) { say(said(403, 'This page was used already.')); return; }
272
272
  if (busy) { say(said(409, 'Still saving the last press. Wait a moment.')); return; }
273
- if (/^sk_live_/.test(key)) { say(said(400, 'That is a full secret key. The shop takes only a RESTRICTED key (rk_…) with the permissions on the page. Go back and make one.')); return; }
274
- if (!KEY_SHAPE.test(key) || !/^rk_/.test(key)) { say(said(400, 'That does not look like a restricted key (rk_test_… or rk_live_…). Go back and try again.')); return; }
273
+ if (!KEY_SHAPE.test(key)) { say(said(400, 'That does not look like a Stripe key (rk_test_…, rk_live_…, sk_test_… or sk_live_…). Go back and try again.')); return; }
275
274
  // Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
276
- if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE restricted key (rk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).')); return; }
275
+ if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE Stripe key (rk_live_… or sk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_… or sk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).')); return; }
277
276
  if (hook && !WEBHOOK_SECRET_SHAPE.test(hook)) { say(said(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again, or leave it empty and this page makes the webhook.')); return; }
278
277
  busy = true;
279
278
  try {
package/lib/studio.mjs CHANGED
@@ -95,11 +95,12 @@ export function listGames(root) {
95
95
  }
96
96
 
97
97
  /**
98
- * RULES ON THE SERVER (NETPLAY.md section 29). A game written as rules plus view has a src/rules.ts; its settings are
99
- * game.json `"room"` (where its rules run, its tick rate…; rules/rules.ts `roomSettings` checks them at build). Any
100
- * other game is hosted by a player's browser, as before.
98
+ * RULES ON THE SERVER (NETPLAY.md section 29). A game written as rules plus view says so in game.json with a `"room"`
99
+ * object (where its rules run, its tick rate…; rules/rules.ts `roomSettings` checks them at build) and has a
100
+ * src/rules.ts. Any other game is hosted by a player's browser, as before. The file alone decides nothing: games made
101
+ * before this had their own src/rules.ts, with whatever they liked in it, and they build exactly as they did.
101
102
  */
102
- export const isRulesGame = (g) => existsSync(join(g.dir, 'src', 'rules.ts'));
103
+ export const isRulesGame = (g) => g?.room != null && typeof g.room === 'object' && existsSync(join(g.dir, 'src', 'rules.ts'));
103
104
 
104
105
  const RULES_HEADER = '// Written by `homie-studio build` from this studio\'s games. Do not edit: the next build writes it again.\n';
105
106
  /**
@@ -1782,6 +1782,86 @@
1782
1782
  }
1783
1783
  }
1784
1784
  }
1785
+ },
1786
+ "0.33.1": {
1787
+ "files": {
1788
+ "CLAUDE.md": "ddf2b2a0376202f6",
1789
+ "README.md": "684b3cca8001673c",
1790
+ "HANDOFF.md": "29337e2ace95b2e7",
1791
+ "games/README.md": "5ad32fe80c61f117",
1792
+ "music/README.md": "a3dc042dadd39df8",
1793
+ "videos/README.md": "16babc3437d5545b",
1794
+ "posts/README.md": "37841aae476bd540",
1795
+ "site/README.md": "732894273a32dc3f",
1796
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1797
+ },
1798
+ "sections": {
1799
+ "AGENTS.md": {
1800
+ "(intro)": "214aa46eb3a895d5",
1801
+ "## Layout": "8baf49e4291e7e88",
1802
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
1803
+ "## The site": "0cb8dd9a6f8a0096",
1804
+ "## Making games": "2da7fa74c8884834",
1805
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1806
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1807
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1808
+ "## Servers and AI seats": "98d08705087a33ec",
1809
+ "## Selling things (the shop)": "56225d9c6292d975",
1810
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1811
+ "## Rules": "2e6b29afb85dcf7e",
1812
+ "## Telling Homie": "80b46fdd9e578db8",
1813
+ "## Beta": "9d02638207a26e73"
1814
+ }
1815
+ },
1816
+ "named": {
1817
+ "Homie Arcade": {
1818
+ "sections": {
1819
+ "AGENTS.md": {
1820
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1821
+ }
1822
+ }
1823
+ }
1824
+ }
1825
+ },
1826
+ "0.34.0": {
1827
+ "files": {
1828
+ "CLAUDE.md": "ddf2b2a0376202f6",
1829
+ "README.md": "684b3cca8001673c",
1830
+ "HANDOFF.md": "29337e2ace95b2e7",
1831
+ "games/README.md": "5ad32fe80c61f117",
1832
+ "music/README.md": "a3dc042dadd39df8",
1833
+ "videos/README.md": "16babc3437d5545b",
1834
+ "posts/README.md": "37841aae476bd540",
1835
+ "site/README.md": "732894273a32dc3f",
1836
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1837
+ },
1838
+ "sections": {
1839
+ "AGENTS.md": {
1840
+ "(intro)": "214aa46eb3a895d5",
1841
+ "## Layout": "8baf49e4291e7e88",
1842
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
1843
+ "## The site": "0cb8dd9a6f8a0096",
1844
+ "## Making games": "2da7fa74c8884834",
1845
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1846
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1847
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1848
+ "## Servers and AI seats": "98d08705087a33ec",
1849
+ "## Selling things (the shop)": "57c04f6526e16890",
1850
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1851
+ "## Rules": "2e6b29afb85dcf7e",
1852
+ "## Telling Homie": "80b46fdd9e578db8",
1853
+ "## Beta": "9d02638207a26e73"
1854
+ }
1855
+ },
1856
+ "named": {
1857
+ "Homie Arcade": {
1858
+ "sections": {
1859
+ "AGENTS.md": {
1860
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1861
+ }
1862
+ }
1863
+ }
1864
+ }
1785
1865
  }
1786
1866
  }
1787
1867
  }
@@ -2068,7 +2068,7 @@ such a game fails the build with the reason. So does a ported game.
2068
2068
 
2069
2069
  ### 29.1 Who is the host
2070
2070
 
2071
- - game.json `"room": { "host": "server" }` (the default for a game that has a `src/rules.ts`). The
2071
+ - game.json `"room": { "host": "server" }` (the default for a game with a `"room"` object in game.json and a `src/rules.ts`). The
2072
2072
  build checks the rules, guards them (section 29.5) and puts them into the studio's Worker
2073
2073
  (`site/src/rules/`, imported by `site/src/worker.mjs` and handed over with `hostRules`).
2074
2074
  - When a room of such a game opens, its `Table` starts the host runtime **before the first socket
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.33.0",
3
+ "version": "0.34.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",