@homie-rocks/studio 0.32.0 → 0.33.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 (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  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/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. 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;
@@ -9,6 +9,10 @@
9
9
  * standalone build <game> [--for …] [--release] [--site <address>] [--build <n>]
10
10
  * builds the game for the web, then every target this computer can make; one it cannot is skipped with why.
11
11
  * standalone run <game> [--for ios|android] the built copy, started here (none: this computer's desktop app)
12
+ * standalone run <game> --for ios --device built for the one iPhone or iPad plugged in, signed for the person's
13
+ * Apple team (HOMIE_APPLE_TEAM, else the keychain's only one), installed over the cable, started, and then looked
14
+ * for among the phone's running programs. --device is the yes to what it changes: the phone is added to the Apple
15
+ * team's list of development devices.
12
16
  * standalone steam <game> Steam's build files from game.json's numbers; uploads nothing
13
17
  * standalone ci <game> .github/workflows/standalone.yml: every target on GitHub's machines
14
18
  *
@@ -35,7 +39,9 @@ export async function standaloneCommand(root, sub, positional, flags, { log = ()
35
39
  switch (sub ?? 'plan') {
36
40
  case 'plan': return told(await standalonePlan(root, id, opts));
37
41
  case 'build': return told(await standaloneBuild(root, id, opts));
38
- case 'run': return standaloneRun(root, id, { for: flags.has('for') ? flags.get('for') : null });
42
+ // --device: onto the one iPhone or iPad that is plugged in. It is the person's yes to what that changes outside
43
+ // this computer (DEVICE_ADDS), and the result carries its own words like a build's.
44
+ case 'run': { const r = await standaloneRun(root, id, { for: flags.has('for') ? flags.get('for') : null, device: flags.has('device'), site: str('site'), log, ...on }); return r?.device ? { ...r, say: standaloneLines(r) } : r; }
39
45
  case 'steam': return standaloneSteam(root, id);
40
46
  case 'ci': return standaloneCi(root, id);
41
47
  default: return { ok: false, command: 'standalone', why: `"${sub}" is not a standalone command: ${STANDALONE_VERBS.join(', ')}` };
@@ -52,8 +58,30 @@ export function missingLines(missing) {
52
58
  return ['What the standalone game does not have (v1):', ...missing.map((m) => ` - ${m.what}: ${m.why}.`)];
53
59
  }
54
60
 
61
+ /** A run on a real phone: what happened, in the order a person needs it, with what it changed outside this computer. */
62
+ function deviceLines(r) {
63
+ const L = [];
64
+ const on = r.phone?.model ? `the ${r.phone.model}` : 'the phone';
65
+ if (r.ok) {
66
+ L.push(`${r.name} is on ${on} and running: it was built, installed over the cable, started, and then found among the phone's running programs.`,
67
+ ` Signed for ${r.team.from === 'HOMIE_APPLE_TEAM' ? 'the team HOMIE_APPLE_TEAM names' : r.team.from}, as ${r.appId}${r.appIdFrom === 'derived' ? ' (a made-up id for trying a build)' : ''}.`,
68
+ ` ${r.adds}`, ...r.notes.map((n) => ` ${n}`));
69
+ } else {
70
+ L.push(`NOT on ${r.phone?.model ? on : 'a phone'}: ${r.why}.`);
71
+ if (r.fix) L.push(` ${r.fix}`);
72
+ if (r.installed) L.push(` The game IS installed on ${on}; it was not seen running.`);
73
+ else if (r.built) L.push(' The app was built and signed; it is not on the phone.');
74
+ // What it may already have changed outside this computer, said even when the rest did not work.
75
+ if (r.team && r.built) L.push(' The build was signed: the phone is on your Apple team\'s list of development devices now, if it was not before.');
76
+ else if (r.team && r.stage === 'build-failed') L.push(' The build stopped before the app was made; it may have added the phone to your Apple team\'s list of development devices on the way.');
77
+ }
78
+ for (const w of r.warnings ?? []) L.push(`Warning: ${w}`);
79
+ return L;
80
+ }
81
+
55
82
  export function standaloneLines(r) {
56
83
  const L = [];
84
+ if (r.command === 'standalone run' && r.device) return deviceLines(r);
57
85
  // A refusal with something to do about it (a release with no id yet, Steam with no numbers) still says it all.
58
86
  if (r.ok === false && !r.targets && !r.rows) { L.push(r.why ?? 'failed'); if (r.instead) L.push('', r.instead); return L; }
59
87
  switch (r.command) {
@@ -89,7 +117,7 @@ export function standaloneLines(r) {
89
117
  break;
90
118
  }
91
119
  case 'standalone run':
92
- L.push(`${r.game} (${r.target}) ended.`);
120
+ if (r.device) L.push(...deviceLines(r)); else L.push(`${r.game} (${r.target}) ended.`);
93
121
  break;
94
122
  case 'standalone steam':
95
123
  L.push(`Steam's build file for app ${r.app}: ${r.dir}/${r.wrote.join(', ')} (each depot is that system's release build, out/<system>/release/).`,
@@ -11,20 +11,25 @@
11
11
  * Nothing is ever uploaded to Steam or a store from here, and no signing key is taken in chat: a release reads the
12
12
  * environment only. What the standalone game does not have is said to the person as it is, every time.
13
13
  */
14
+ import { readFileSync } from 'node:fs';
14
15
  import { join } from 'node:path';
15
16
  import { SAY_MISSING, standaloneLines } from './standalone-cli.mjs';
16
- import { STANDALONE_SINCE, standalonePlan, toolkitNote } from './standalone.mjs';
17
+ import { compareVersions } from './changelog.mjs';
18
+ import { DEVICE_ADDS, STANDALONE_SINCE, standalonePlan, toolkitNote } from './standalone.mjs';
19
+ import { pinnedVersion } from './upgrade.mjs';
17
20
  import { TARGETS } from './standalone-files.mjs';
18
21
  import { PACKAGE_ROOT } from './studio.mjs';
19
22
 
20
23
  export { SAY_MISSING };
24
+ /** The first toolkit with `standalone run --device`. */
25
+ export const DEVICE_SINCE = '0.32.1';
21
26
 
22
27
  export function standaloneToolDefs(ctx, h) {
23
28
  const { ok, fail, cli, stillRunning, whyOf, needsInstall, str, STUDIO_ARG } = h;
24
29
  return [
25
30
  {
26
31
  name: 'game_standalone', title: 'Make a standalone game',
27
- description: `Make one of the studio's games into an app of its own: a desktop app for macOS, Windows and Linux (the kind of build Steam accepts for upload) and a phone app for iOS and Android (the files the App Store and Google Play accept for upload; each store's review, fees and rules are its own, and nothing here makes an app pass). It is the same web build of the game in a thin shell (Electron on computers, Capacitor on phones); multiplayer still goes through the studio's own deployed site, and with no connection the game plays offline with its bots. Use it when the person asks for a Steam build, a desktop app, a download, an iPhone or Android app, or "a standalone version". ALWAYS call it with plan: true first and tell the person what it says: which targets this computer can build and what the others need, the address that gets built into every copy, and what a standalone copy does NOT have in this version (no player accounts, no cloud saves: saves stay on the device and are lost when the app is uninstalled, no shop, no chat). Then, when they say go, call it again without plan: it builds as a background job that takes minutes (the first build downloads the wrapper tools into the studio's .studio/ folder): it answers with a job, and studio_job { "job": … } gives the result in words when it is done; read that result to the person, including its list of what the standalone game does not have. A target whose tool is missing on this computer is skipped with its fix and the others still build; only the desktop build for this computer's own system is started once to see that the game loads, and every other build says "built, not started on this computer". It never uploads anything and never signs with a key from the chat: a release (release: true) signs from environment variables the person sets themselves, needs the app's id written in game.json first (the tool gives the exact lines), and says UNSIGNED when it could not sign. The studio must be on @homie-rocks/studio ${STANDALONE_SINCE} or later AND deployed before a copy can find rooms.`,
32
+ description: `Make one of the studio's games into an app of its own: a desktop app for macOS, Windows and Linux (the kind of build Steam accepts for upload) and a phone app for iOS and Android (the files the App Store and Google Play accept for upload; each store's review, fees and rules are its own, and nothing here makes an app pass). It is the same web build of the game in a thin shell (Electron on computers, Capacitor on phones); multiplayer still goes through the studio's own deployed site, and with no connection the game plays offline with its bots. Use it when the person asks for a Steam build, a desktop app, a download, an iPhone or Android app, or "a standalone version". ALWAYS call it with plan: true first and tell the person what it says: which targets this computer can build and what the others need, the address that gets built into every copy, and what a standalone copy does NOT have in this version (no player accounts, no cloud saves: saves stay on the device and are lost when the app is uninstalled, no shop, no chat). Then, when they say go, call it again without plan: it builds as a background job that takes minutes (the first build downloads the wrapper tools into the studio's .studio/ folder): it answers with a job, and studio_job { "job": … } gives the result in words when it is done; read that result to the person, including its list of what the standalone game does not have. A target whose tool is missing on this computer is skipped with its fix and the others still build; only the desktop build for this computer's own system is started once to see that the game loads, and every other build says "built, not started on this computer". It never uploads anything and never signs with a key from the chat: a release (release: true) signs from environment variables the person sets themselves, needs the app's id written in game.json first (the tool gives the exact lines), and says UNSIGNED when it could not sign. In a copy, Quick play finds a room only when the studio's live site is on @homie-rocks/studio ${STANDALONE_SINCE} or later AND deployed since; a room made or joined by its code works with an older site too (seen against 0.31.0, not promised for every older version). ON THE PERSON'S OWN iPHONE OR iPAD: device: true builds the game for the one phone plugged in to this Mac, signs it for their Apple team, installs it over the cable, starts it, and then looks for it among the phone's running programs (it says "running" only when it is there). ${DEVICE_ADDS} So call it with device: true only after the person said yes to that, in those words; it needs a Mac with Xcode signed in to their Apple account, the phone unlocked and trusting the Mac, and Developer Mode on (Settings, Privacy & Security, Developer Mode; the phone restarts). It is a background job too. There is no such run for an Android phone in this version.`,
28
33
  inputSchema: {
29
34
  type: 'object',
30
35
  properties: {
@@ -32,6 +37,7 @@ export function standaloneToolDefs(ctx, h) {
32
37
  for: { type: 'array', items: { type: 'string', enum: [...TARGETS] }, description: 'Which to build (default: every one this computer can make)' },
33
38
  plan: { type: 'boolean', description: 'Only say what would be built and what is missing; build nothing, install nothing. Do this first' },
34
39
  release: { type: 'boolean', description: 'A build for a store or Steam: needs "standalone.appId" in the game\'s game.json, and signs from the environment. Only when the person asked for a release' },
40
+ device: { type: 'boolean', description: 'Put the game on the one iPhone or iPad plugged in to this Mac and start it. It adds the phone to the person\'s Apple team\'s list of development devices: only after they said yes to that' },
35
41
  ...STUDIO_ARG,
36
42
  },
37
43
  required: ['game'],
@@ -50,6 +56,18 @@ export function standaloneToolDefs(ctx, h) {
50
56
  }
51
57
  const need = needsInstall(ctx, root);
52
58
  if (need) return need;
59
+ if (a.device === true) {
60
+ // The run on a phone arrived in 0.32.1: a studio whose own toolkit is older has no such flag (and would
61
+ // read the words as a different command), so this toolkit's runs.
62
+ let pinned = null;
63
+ try { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); pinned = pinnedVersion(pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio']); } catch { pinned = null; }
64
+ const ours = pinned && compareVersions(pinned, DEVICE_SINCE) < 0;
65
+ const r = await cli(ctx, root, `standalone run ${game} on a phone`, ['standalone', 'run', game, '--for', 'ios', '--device'], ours ? { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs') } : {});
66
+ if (!r.ended) return stillRunning(r.job, `Putting ${game} on the phone (it takes minutes the first time; its last lines say where it is)`);
67
+ if (!r.result?.device) return fail(`Not on the phone: ${whyOf(r.job)}`, r.result ? { kind: 'standalone-device', ...r.result } : undefined);
68
+ const text = (r.result.say ?? standaloneLines(r.result)).join('\n');
69
+ return r.result.ok ? ok(text, { kind: 'standalone-device', ...r.result }) : fail(text, { kind: 'standalone-device', ...r.result });
70
+ }
53
71
  const args = ['standalone', 'build', game, ...(only ? ['--for', only] : []), ...(a.release === true ? ['--release'] : [])];
54
72
  const r = await cli(ctx, root, `standalone build ${game}`, args, old ? { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs') } : {});
55
73
  // The words are in the result itself (`say`), so studio_job says them too when the build outlasts this call.