@homie-rocks/studio 0.32.1 → 0.33.1

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 (66) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/studio.mjs +22 -1
  21. package/lib/template-history.json +90 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. package/worker/version.mjs +1 -1
package/lib/scaffold.mjs CHANGED
@@ -29,9 +29,10 @@ 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 } from '../worker/shop-store.mjs';
32
+ import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_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
+ import { rulesIndex } from './studio.mjs';
35
36
 
36
37
  export const COMPAT_DATE = '2026-06-01';
37
38
  /** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
@@ -75,7 +76,9 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
75
76
  // The site's own fetches (its directory claim, "played this week") go out as any browser's would, so a studio
76
77
  // whose directory is on the same zone (a house studio on *.homie.rocks, or two Workers on one workers.dev
77
78
  // subdomain) reaches it instead of failing with 1042.
78
- compatibility_flags: ['global_fetch_strictly_public'],
79
+ // `disallow_eval_during_startup`: no code is made from a string while the Worker loads (Cloudflare never allows it
80
+ // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
81
+ compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup'],
79
82
  workers_dev: true,
80
83
  preview_urls: true,
81
84
  // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
@@ -210,7 +213,7 @@ studio's pinned copy, never a registry lookup of the bare name.
210
213
  one-time link that marks their own player account (a passkey on this site) as the owner's, so their games and
211
214
  the studio's back office recognise them.
212
215
  - \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
213
- \`npx -y --package=<its tarball> homie-studio upgrade\`): what the newer template adds to this studio (AGENTS.md
216
+ \`npx -y @homie-rocks/studio@<its version> upgrade\`): what the newer template adds to this studio (AGENTS.md
214
217
  sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
215
218
  touches a file or section this studio changed; \`--diff\` shows how those differ from the template's.
216
219
 
@@ -433,18 +436,18 @@ studio's pinned copy, never a registry lookup of the bare name.
433
436
  - **The studio sells with its OWN Stripe account.** The studio is the seller: its prices, its refunds, its tax, its
434
437
  disputes. Money goes from players to the studio's Stripe; homie.rocks never sees it and Homie takes no cut.
435
438
  \`SELLING.md\` says what that means for the owner in plain words (not legal advice).
436
- - **\`shop.json\`** (at the studio's root, reviewed in git): \`till\` (\`stripe\`, the studio is the seller with Stripe
437
- Tax on; \`stripe-managed\`, Stripe Managed Payments is the seller of record and files the tax for 3.5% more; or
438
- \`off\`), \`currency\`, \`refundDays\` (at least 14), \`capPerPlayerMonth\` (cents, at most 5000) and \`items\`:
439
- \`{ "id", "kind": "cosmetic"|"supporter"|"pass"|"unlock"|"tip", "name", "price": <cents>, "gives": ["skin:ember"],
440
- "days"?, "game"?, "advantage"? }\`. Real money only: no gems, coins or points. \`npx --no-install homie-studio shop
441
- init --supporter\` writes a US$5 Supporter pack; \`shop check\` and every build refuse what the kit refuses.
442
- - **The kids rules are the kit's, not the studio's to switch off:** nothing random for money (an item that names
443
- chance, odds, a crate, a box or a mystery is refused), no countdown offers, no shop on a kids server or in a studio
444
- with \`"audience": "kids"\` in studio.json, spending off on every account until a neutral age question says adult,
445
- nothing ever for under-13s, 13-17 only through a parent's own checkout (a one-time link), nothing with
446
- \`"advantage": true\` on a beginner server, one hosted Stripe checkout per purchase, a monthly cap. The television
447
- never sells: its store sheet is a code to buy on a phone.
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.
448
451
  - **In a game:** \`import { createShop } from '@homie-rocks/studio/shop'\`; \`shop.has('skin:ember')\`,
449
452
  \`shop.entitlements()\`, \`shop.on('change', …)\`, \`shop.open(item)\` from a button the player pressed (never the
450
453
  play button, never on a timer), \`shop.used(key)\` when it is equipped. A supporter's badge rides on their seat
@@ -710,8 +713,14 @@ export function studioFiles({ name, slug, homie, template = false }) {
710
713
  'site/README.md': SITE_README,
711
714
  'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
712
715
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
716
+ import { hostRules } from '@homie-rocks/studio/worker';
717
+ // The rules of this studio's games that run on the server (written by homie-studio build; empty until a game has some).
718
+ import rules from './rules/index.mjs';
719
+
720
+ hostRules(rules);
713
721
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';
714
722
  `,
723
+ 'site/src/rules/index.mjs': rulesIndex([]),
715
724
  'site/migrations/0001_studio.sql': MIGRATION,
716
725
  [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
717
726
  [`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
@@ -719,6 +728,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
719
728
  [`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
720
729
  [`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
721
730
  [`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
731
+ [`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
732
+ [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
722
733
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
723
734
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
724
735
  '.claude/skills/.gitkeep': '',
@@ -825,6 +836,22 @@ export function ensureShopMigration(root) {
825
836
  return `site/migrations/${SHOP_MIGRATION_FILE}`;
826
837
  }
827
838
 
839
+ function ensureShopReservations(root) {
840
+ const file = join(root, 'site', 'migrations', SHOP_RESERVATIONS_FILE);
841
+ if (existsSync(file)) return null;
842
+ mkdirSync(dirname(file), { recursive: true });
843
+ writeFileSync(file, SHOP_RESERVATIONS);
844
+ return `site/migrations/${SHOP_RESERVATIONS_FILE}`;
845
+ }
846
+
847
+ function ensureShopStatements(root) {
848
+ const file = join(root, 'site', 'migrations', SHOP_STATEMENTS_FILE);
849
+ if (existsSync(file)) return null;
850
+ mkdirSync(dirname(file), { recursive: true });
851
+ writeFileSync(file, SHOP_STATEMENTS);
852
+ return `site/migrations/${SHOP_STATEMENTS_FILE}`;
853
+ }
854
+
828
855
  /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
829
856
  /** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
830
857
  function ensureChatMigration(root) {
@@ -845,7 +872,7 @@ function ensureLoungeMigration(root) {
845
872
  }
846
873
 
847
874
  export function ensureMigrations(root) {
848
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root)].filter(Boolean);
875
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root)].filter(Boolean);
849
876
  }
850
877
 
851
878
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -27,16 +27,17 @@ import { productIdOf } from '../worker/stripe.mjs';
27
27
  const MARK = 'shop-v1';
28
28
 
29
29
  /** The Products the catalog should hold, from the checked shop.json. */
30
- export function wantedProducts(shop, slug) {
31
- return (shop?.items ?? []).map((it) => {
32
- const id = productIdOf(slug, it.id);
30
+ export async function wantedProducts(shop, slug) {
31
+ return Promise.all((shop?.items ?? []).map(async (it) => {
32
+ const id = await productIdOf(slug, it.id);
33
33
  return {
34
34
  id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: defaultTaxCode(it),
35
- metadata: { homie: MARK, homie_studio: String(slug), homie_item: it.id },
35
+ // Stripe metadata values: 500 characters. https://docs.stripe.com/metadata (fetched 2026-10-08).
36
+ metadata: { homie: MARK, homie_studio: String(slug), homie_item: it.id.length <= 500 ? it.id : id },
36
37
  price: it.kind === 'tip' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
37
- shown: it.kind === 'tip' ? `pay what you want, ${money(it.min, shop.currency)} to ${money(it.max, shop.currency)}` : money(it.price, shop.currency),
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),
38
39
  };
39
- });
40
+ }));
40
41
  }
41
42
 
42
43
  /**
@@ -60,14 +61,14 @@ export function productsIn(value, out = [], depth = 0) {
60
61
  const taxCodeOf = (p) => (typeof p?.tax_code === 'string' ? p.tax_code : p?.tax_code?.id ?? null);
61
62
 
62
63
  /** The plan: the read first, then (with what Stripe answered) the writes still needed. */
63
- export function catalogPlan(root, { have = null, mode = null, record = true } = {}) {
64
+ export async function catalogPlan(root, { have = null, mode = null, record = true } = {}) {
64
65
  const r = readShop(root);
65
66
  if (r.absent) return { ok: false, command: 'shop catalog', why: 'no shop.json: homie-studio shop init writes one first' };
66
- if (!r.ok) return { ok: false, command: 'shop catalog', why: `shop.json breaks the rules (homie-studio shop check): ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
67
+ if (!r.ok) return { ok: false, command: 'shop catalog', why: `shop.json conflicts with the studio settings or payment requirements (homie-studio shop check): ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
67
68
  const studio = readStudio(root);
68
69
  const slug = studio.slug;
69
70
  if (!slug) return { ok: false, command: 'shop catalog', why: 'studio.json has no slug: the catalog\'s Product ids are made from it' };
70
- const wanted = wantedProducts(r.shop, slug);
71
+ const wanted = await wantedProducts(r.shop, slug);
71
72
  const read = {
72
73
  tool: 'stripe_api_read', method: 'GET', path: '/v1/products', params: { limit: 100, expand: ['data.default_price'] },
73
74
  then: 'Save exactly what it answers (every page, while has_more) to a file in the scratch folder, then run: npx --no-install homie-studio shop catalog --have <that file>',
package/lib/shop.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * homie-studio shop is it selling, and if not, what is missing (the live site's office view)
6
6
  * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed]
7
7
  * writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
8
- * homie-studio shop check shop.json against the kit's rules (no randomness, real money, the cap…)
8
+ * homie-studio shop check shop.json against the studio settings and provider requirements
9
9
  * homie-studio shop connect [--managed] [--live] a page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
10
10
  * pastes the studio's restricted Stripe key (the one thing only they can make);
11
11
  * with it this process makes the webhook (0.24.3), and the key and the
@@ -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 { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
35
+ import { POLICY_PRESETS, 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,
@@ -62,14 +62,14 @@ export function readShop(root) {
62
62
  try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { return { ok: false, shop: null, errors: [{ at: '', message: `shop.json is not JSON: ${error.message}` }], warnings: [], file }; }
63
63
  const games = listGames(root).map((g) => g.id);
64
64
  const studio = readStudio(root);
65
- return { ...checkShop(raw, { games, audience: audienceOf(studio) }), file };
65
+ return { ...checkShop(raw, { games, audience: audienceOf(studio), studioName: studio.name }), file };
66
66
  }
67
67
 
68
68
  /** The build's half: the checked shop for games.json, or a thrown error that says what to fix (nothing is published). */
69
69
  export function shopForBuild(root, { log = () => {} } = {}) {
70
70
  const r = readShop(root);
71
71
  if (r.absent) return null;
72
- if (!r.ok) throw new Error(`shop.json breaks the shop's rules, so the build stops (nothing is sold that the kit refuses):\n${r.errors.map((e) => ` ${e.at || 'shop.json'}: ${e.message}`).join('\n')}`);
72
+ if (!r.ok) throw new Error(`shop.json conflicts with the studio settings or payment requirements, so the build stops (check the studio settings and payment requirements):\n${r.errors.map((e) => ` ${e.at || 'shop.json'}: ${e.message}`).join('\n')}`);
73
73
  for (const w of r.warnings) log(`shop.json ${w.at}: ${w.message}`);
74
74
  return r.shop;
75
75
  }
@@ -77,7 +77,7 @@ 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', 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.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('; ')}` };
81
81
  }
82
82
 
83
83
  /** The Supporter pack the kit suggests first: deterministic, never pay-to-win, a badge on the profile and in rooms. */
@@ -89,11 +89,10 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
89
89
  const file = join(root, SHOP_FILE);
90
90
  if (existsSync(file)) return { ok: false, command: 'shop init', why: 'shop.json is here already: change it, then homie-studio shop check' };
91
91
  const studio = readStudio(root);
92
- if (audienceOf(studio) === 'kids') return { ok: false, command: 'shop init', why: 'studio.json says "audience": "kids": a studio made for children sells nothing in its games' };
93
92
  const shop = {
94
- till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(), refundDays: 14, capPerPlayerMonth: 5000,
95
- items: supporter ? [supporterItem({ price: Math.floor(Number(price)) || 500 })] : [],
96
- referrals: { rate: 0.1, windowDays: 30, capPerPlayer: 1000, holdDays: 30, minimumInvoice: 2500, accept: ['stripe-invoice'] },
93
+ till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
94
+ policy: { preset: 'protective', ...POLICY_PRESETS.protective },
95
+ items: supporter ? [supporterItem({ price: Number(price) })] : [],
97
96
  };
98
97
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
99
98
  if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
@@ -124,10 +123,26 @@ export async function shopRefund(root, order, { url, reason, note } = {}) {
124
123
  return askedFor(root, url, r, 'shop refund');
125
124
  }
126
125
 
127
- export async function shopStatements(root, { url, period, send = false } = {}) {
128
- const r = await withKey(root, url, (call) => (send ? call('/_studio/api/shop/statements/send', { ...(period ? { period } : {}) }) : call(`/_studio/api/shop/statements${period ? `?period=${encodeURIComponent(period)}` : ''}`)));
129
- if (!r.ok) return { ok: false, command: 'shop statements', why: r.message ?? r.why ?? 'the studio did not answer' };
130
- return { ok: true, command: 'shop statements', period: r.period, ...(send ? { sent: r.sent } : { statements: r.statements }) };
126
+ export async function shopStatements(root, { url, period, cursor = '', send = false } = {}) {
127
+ if (!send) {
128
+ const r = await withKey(root, url, (call) => call(`/_studio/api/shop/statements?cursor=${encodeURIComponent(cursor)}${period ? `&period=${encodeURIComponent(period)}` : ''}`));
129
+ return r.ok ? { ok: true, command: 'shop statements', period: r.period, nextCursor: r.nextCursor, statements: r.statements } : { ok: false, command: 'shop statements', why: r.message ?? r.why ?? 'the studio did not answer' };
130
+ }
131
+ const sent = [];
132
+ console.error('Sending statements. Keep this command open; run it again after closing to resume safely.');
133
+ return withKey(root, url, async (call) => {
134
+ do {
135
+ const page = await call('/_studio/api/shop/statements/send', { cursor, ...(period ? { period } : {}) });
136
+ if (!page.ok) return { ok: false, command: 'shop statements', sent, why: page.message ?? page.why ?? 'the studio did not answer' };
137
+ for (const failure of page.failures ?? []) if (!sent.some((x) => !x.ok && x.via === failure.via)) { sent.push(failure); console.error(`${failure.via}: failed: ${failure.why ?? failure.status}`); }
138
+ sent.push(...page.sent);
139
+ for (const result of page.sent) console.error(`${result.via}: ${result.ok ? 'page sent' : 'failed: ' + (result.why ?? result.status)}`);
140
+ period = page.period;
141
+ cursor = page.nextCursor;
142
+ if (page.retryAfter) await new Promise((resolve) => setTimeout(resolve, page.retryAfter * 1000));
143
+ } while (cursor !== null);
144
+ return { ok: true, command: 'shop statements', period, nextCursor: null, sent };
145
+ });
131
146
  }
132
147
 
133
148
  /* ------------------------------------------------------------------ the key, from a page on this computer */
@@ -346,15 +361,17 @@ export function shopLines(r) {
346
361
  } else if (r.command === 'shop check') {
347
362
  if (r.absent) lines.push(r.message);
348
363
  else {
349
- lines.push(r.ok ? `shop.json follows the rules: ${r.items.join(', ') || 'no items yet'} (till: ${r.till}).` : 'shop.json breaks the rules:');
364
+ lines.push(r.ok ? `shop.json matches the studio settings: ${r.items.join(', ') || 'no items yet'} (till: ${r.till}).` : 'shop.json conflicts with the studio settings or payment requirements:');
365
+ if (r.policy) lines.push(`Studio policy: ${r.policy.preset}; edit policy in shop.json. Monthly cap: ${r.capPerPlayerMonth ?? 'not set'}; refund days: ${r.refundDays ?? 'not set'}.`);
350
366
  for (const e of r.errors ?? []) lines.push(` ${e.at}: ${e.message}`);
351
367
  for (const w of r.warnings ?? []) lines.push(` note ${w.at}: ${w.message}`);
352
368
  }
353
369
  } else if (r.command === 'shop init') {
354
370
  lines.push(`Wrote ${r.wrote.join(', ')} (till: ${r.till}${r.items.length ? `; ${r.items.join(', ')}` : ''}). Next:`, ...r.next.map((n) => ` ${n}`));
355
371
  } else if (r.command === 'shop statements') {
372
+ if (r.nextCursor) lines.push(`More rows: shop statements --period ${r.period} --cursor '${r.nextCursor.replaceAll("'", "'\\''")}'${r.sent ? ' --send' : ''}`);
356
373
  if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
357
- else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${money(s.statement.totals.due, s.statement.currency)} (${s.statement.lines.length} line(s)), signed`);
374
+ else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${s.statement.totals ? money(s.statement.totals.due, s.statement.currency) : 'see first page'} (${s.statement.lines.length} line(s)), signed`);
358
375
  if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
359
376
  } else lines.push(r.message ?? '');
360
377
  return lines;
package/lib/studio.mjs CHANGED
@@ -94,11 +94,32 @@ export function listGames(root) {
94
94
  .sort((a, b) => a.id.localeCompare(b.id));
95
95
  }
96
96
 
97
+ /**
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.
102
+ */
103
+ export const isRulesGame = (g) => g?.room != null && typeof g.room === 'object' && existsSync(join(g.dir, 'src', 'rules.ts'));
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';
106
+ /**
107
+ * site/src/rules/index.mjs: the table of this studio's server-hosted games as site/src/worker.mjs imports it (an
108
+ * empty one for a studio with none, which is what a new studio starts with). lib/rules-build.mjs writes the rest.
109
+ */
110
+ export function rulesIndex(ids = []) {
111
+ const lines = [`${RULES_HEADER}// The rules of this studio's server-hosted games, for the Worker (site/src/worker.mjs hands them to hostRules).`];
112
+ ids.forEach((id, i) => lines.push(`import rules${i} from './${id}.mjs';`, `import data${i} from './${id}.data.mjs';`));
113
+ lines.push(`export default {${ids.map((id, i) => `\n ${JSON.stringify(id)}: { rules: rules${i}, ...data${i} },`).join('')}${ids.length ? '\n' : ''}};`);
114
+ return `${lines.join('\n')}\n`;
115
+ }
116
+
97
117
  export function starters() {
98
118
  const dir = join(PACKAGE_ROOT, 'starters');
99
119
  return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
100
120
  const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
101
- return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds };
121
+ // `example`: a starter that shows how something is written (coin-dash: rules on the server) and is not yet offered as the start of a new game.
122
+ return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds, ...(meta.example === true ? { example: true } : {}) };
102
123
  });
103
124
  }
104
125
 
@@ -1732,6 +1732,96 @@
1732
1732
  }
1733
1733
  }
1734
1734
  }
1735
+ },
1736
+ "0.33.0": {
1737
+ "files": {
1738
+ "CLAUDE.md": "ddf2b2a0376202f6",
1739
+ "README.md": "684b3cca8001673c",
1740
+ "HANDOFF.md": "29337e2ace95b2e7",
1741
+ "games/README.md": "5ad32fe80c61f117",
1742
+ "music/README.md": "a3dc042dadd39df8",
1743
+ "videos/README.md": "16babc3437d5545b",
1744
+ "posts/README.md": "37841aae476bd540",
1745
+ "site/README.md": "732894273a32dc3f",
1746
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1747
+ },
1748
+ "sections": {
1749
+ "AGENTS.md": {
1750
+ "(intro)": "214aa46eb3a895d5",
1751
+ "## Layout": "8baf49e4291e7e88",
1752
+ "## Commands (all through the pinned CLI in node_modules)": [
1753
+ "29a04ed3d956610a",
1754
+ "a7f290579585368c"
1755
+ ],
1756
+ "## The site": "0cb8dd9a6f8a0096",
1757
+ "## Making games": "2da7fa74c8884834",
1758
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1759
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1760
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1761
+ "## Servers and AI seats": "98d08705087a33ec",
1762
+ "## Selling things (the shop)": [
1763
+ "3173053462311959",
1764
+ "56225d9c6292d975",
1765
+ "b04b0a2b25f524f2"
1766
+ ],
1767
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1768
+ "## Rules": "2e6b29afb85dcf7e",
1769
+ "## Telling Homie": "80b46fdd9e578db8",
1770
+ "## Beta": "9d02638207a26e73"
1771
+ }
1772
+ },
1773
+ "named": {
1774
+ "Homie Arcade": {
1775
+ "sections": {
1776
+ "AGENTS.md": {
1777
+ "## Commands (all through the pinned CLI in node_modules)": [
1778
+ "5d1c068adda1116b",
1779
+ "7cad8fd10f62fef1"
1780
+ ]
1781
+ }
1782
+ }
1783
+ }
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
+ }
1735
1825
  }
1736
1826
  }
1737
1827
  }
package/lib/upgrade.mjs CHANGED
@@ -15,8 +15,9 @@
15
15
  * `--diff` prints the difference, so the person can take the template's words by hand.
16
16
  *
17
17
  * Plus the pin: when this CLI is newer than the version package.json pins (run through
18
- * `npx -y --package=<the new tarball> homie-studio upgrade`), --apply pins this version (then `npm install`), and
19
- * studio.json's `homie.studio` says the version the studio is on.
18
+ * `npx -y @homie-rocks/studio@<the new version> upgrade`), --apply pins this version (then `npm install`), and
19
+ * studio.json's `homie.studio` says the version the studio is on. The pin it writes is always the npm registry's:
20
+ * a studio made before 0.10.0 pinned an address on homie.rocks, and its first upgrade moves it to the registry.
20
21
  *
21
22
  * Without --apply it changes nothing and says what --apply would do: the person agrees first. It never touches
22
23
  * studio.json beyond `homie.studio`, the studio's look (site/theme.json), its Worker config (deploy's), its games,
@@ -43,7 +44,7 @@ import { STUDIO_VERSION, packageSpec } from './version.mjs';
43
44
  /** Whole files the template writes and a studio may keep as they are: updated only while the studio never changed them. */
44
45
  export const TEMPLATE_FILES = ['AGENTS.md', 'CLAUDE.md', 'README.md', 'HANDOFF.md', 'games/README.md', 'music/README.md', 'videos/README.md', 'posts/README.md', 'site/README.md', 'site/src/worker.mjs'];
45
46
  /** Files the template adds when missing and never changes once they exist (a migration is applied; a manifest is the studio's). */
46
- export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep'];
47
+ export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep', 'site/src/rules/index.mjs'];
47
48
  const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
48
49
  /** The name and slug stand-ins the history is fingerprinted with. */
49
50
  export const STAND_IN = { name: '{{studio.name}}', slug: '{{studio.slug}}' };
@@ -124,19 +125,21 @@ function known(history, who = {}) {
124
125
  const semver = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? '')) ?? []).slice(1).map(Number);
125
126
  const newer = (a, b) => { const x = semver(a); const y = semver(b); for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); return false; };
126
127
 
127
- /** The @homie-rocks/studio version a package.json spec pins: a homie.rocks tarball, an exact version, or npm:… of one. */
128
+ /**
129
+ * The version in the address a studio made before 0.10.0 pinned (https://<directory>/npm/homie-studio-<version>.tgz),
130
+ * or null. Read for one thing only: so that studio's upgrade knows which version it is leaving. Nothing writes
131
+ * such an address; its upgrade writes the npm registry's exact version.
132
+ */
133
+ const addressPin = (spec) => /^https?:\/\/.+\/homie-studio-(\d+\.\d+\.\d+)\.tgz(?:[?#].*)?$/.exec(String(spec ?? ''))?.[1] ?? null;
134
+
135
+ /** The @homie-rocks/studio version a package.json spec pins: an exact version, npm:… of one, or an older studio's address. */
128
136
  export function pinnedVersion(spec) {
129
137
  const s = String(spec ?? '');
130
- return /homie-studio-(\d+\.\d+\.\d+)\.tgz(?:[?#].*)?$/.exec(s)?.[1] ?? /^(?:npm:@homie-rocks\/studio@)?=?(\d+\.\d+\.\d+)$/.exec(s)?.[1] ?? null;
138
+ return /^(?:npm:@homie-rocks\/studio@)?=?(\d+\.\d+\.\d+)$/.exec(s)?.[1] ?? addressPin(s);
131
139
  }
132
- /** The same kind of spec, for this version. */
133
- function specFor(spec, directory) {
134
- const s = String(spec ?? '');
135
- const tar = /^(https?:\/\/.+\/)homie-studio-\d+\.\d+\.\d+\.tgz$/.exec(s);
136
- if (tar) return `${tar[1]}homie-studio-${STUDIO_VERSION}.tgz`;
137
- if (/^npm:@homie-rocks\/studio@/.test(s)) return `npm:@homie-rocks/studio@${STUDIO_VERSION}`;
138
- if (/^=?\d+\.\d+\.\d+$/.test(s)) return STUDIO_VERSION;
139
- return packageSpec(directory);
140
+ /** The pin for this version: the npm registry's exact version (an npm: alias stays an alias). */
141
+ function specFor(spec) {
142
+ return /^npm:@homie-rocks\/studio@/.test(String(spec ?? '')) ? `npm:@homie-rocks/studio@${STUDIO_VERSION}` : packageSpec();
140
143
  }
141
144
 
142
145
  /** A line diff (the longest common run kept), for the plan: ` ` same, `- ` the studio's, `+ ` the template's. */
@@ -183,8 +186,9 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
183
186
  if (pinned && newer(pinned, STUDIO_VERSION)) {
184
187
  return { ok: false, command: 'upgrade', from: pinned, to: STUDIO_VERSION, why: `this studio pins @homie-rocks/studio ${pinned}, newer than this ${STUDIO_VERSION}: run its own copy (npx --no-install homie-studio upgrade)` };
185
188
  }
186
- if (pkg && where && pinned && newer(STUDIO_VERSION, pinned)) {
187
- changes.push({ file: 'package.json', kind: 'pin', what: `pin @homie-rocks/studio ${STUDIO_VERSION} (was ${pinned}); then npm install`, from: spec, to: specFor(spec, studio.homie?.directory) });
189
+ // An older studio's address is moved to the registry even when it names this very version: it is no longer served.
190
+ if (pkg && where && pinned && (newer(STUDIO_VERSION, pinned) || addressPin(spec))) {
191
+ changes.push({ file: 'package.json', kind: 'pin', what: `pin @homie-rocks/studio ${STUDIO_VERSION}${addressPin(spec) ? ' from the npm registry' : ''} (was ${pinned}${addressPin(spec) ? ', at an address that is no longer served' : ''}); then npm install`, from: spec, to: specFor(spec) });
188
192
  } else if (pkg && where && !pinned) {
189
193
  kept.push({ file: 'package.json', what: `@homie-rocks/studio is "${String(spec).slice(0, 80)}" (not a published version): left as it is` });
190
194
  }
package/lib/version.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The one place @homie-rocks/studio says which version it is and where that exact
3
- * version lives. A studio pins this tarball (package.json + package-lock
3
+ * version lives. A studio pins this version (package.json + package-lock
4
4
  * integrity), so a published game never changes because Homie changed.
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
@@ -8,14 +8,9 @@ import { readFileSync } from 'node:fs';
8
8
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
9
9
  export const STUDIO_VERSION = pkg.version;
10
10
 
11
- /** The pinned tarball of this version, on a Homie directory (homie.rocks by default). Kept for older studios. */
12
- export function tarballUrl(base = 'https://homie.rocks', version = STUDIO_VERSION) {
13
- return `${String(base).replace(/\/+$/, '')}/npm/homie-studio-${version}.tgz`;
14
- }
15
-
16
11
  /**
17
12
  * What a studio's package.json names: this exact version, from registry.npmjs.org (package-lock.json keeps its
18
13
  * integrity). The public registry is what Cloudflare's Workers Builds and a Claude Code cloud session reach with
19
- * their default network settings; a directory's /npm/ tarball is not.
14
+ * their default network settings.
20
15
  */
21
16
  export function packageSpec() { return STUDIO_VERSION; }