@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/shop/SHOP.md CHANGED
@@ -14,8 +14,7 @@ At the studio's root, reviewed in git. Nothing secret is ever in it.
14
14
  {
15
15
  "till": "stripe",
16
16
  "currency": "usd",
17
- "refundDays": 14,
18
- "capPerPlayerMonth": 5000,
17
+ "policy": { "preset": "protective" },
19
18
  "items": [
20
19
  { "id": "supporter", "kind": "supporter", "name": "Supporter", "price": 500, "days": 365,
21
20
  "gives": ["badge:supporter", "credits-name"], "badge": "Supporter" },
@@ -30,38 +29,78 @@ At the studio's root, reviewed in git. Nothing secret is ever in it.
30
29
  | Field | Meaning |
31
30
  |---|---|
32
31
  | `till` | `stripe`: the studio is the seller, Stripe Tax on (`automatic_tax`). `stripe-managed`: Stripe Managed Payments, Stripe's Link, LLC is the seller of record and files the tax (3.5% more; turn it on in Stripe first). `off`. |
33
- | `currency` | one currency with cents (`usd`, `cad`, `eur`, `gbp`, `aud`…). |
34
- | `refundDays` | 14 to 60: a player refunds an unused item themselves within it. |
35
- | `capPerPlayerMonth` | cents one player may spend here in a month: 100 to 5000. Lower it, never remove it. |
36
- | `items[].kind` | `cosmetic` (a look), `supporter` (a badge, thanks), `pass` (a season), `unlock` (a mode, a chapter, the full game), `tip` (pay what you want, `min` to `max`). |
37
- | `items[].price` | whole cents, real money. Under 300 the check warns (fees eat it). |
32
+ | `currency` | one Stripe presentment currency, including zero-decimal currencies such as `jpy`. |
33
+ | `refundDays` | Optional days for self-service refunds of unused items. No default or ceiling; omitted or null means ask the studio. |
34
+ | `capPerPlayerMonth` | Optional amount one player may spend per calendar month, in Stripe currency units. No default or ceiling; omit or null for no cap. |
35
+ | `items[].kind` | Any nonempty label chosen by the studio. `tip` uses a buyer-chosen amount, with an optional studio `max`. Other kinds use a fixed price; a label does not add recurring billing or fulfillment. |
36
+ | `items[].price` | whole Stripe currency units. No Homie maximum. Stripe minimums and currency rules apply. |
38
37
  | `items[].gives` | the entitlement keys the game reads: `skin:ember`, `badge:supporter`. A `badge:` key shows beside the player's name in rooms and on their account page. |
39
- | `items[].days` / `starts` / `ends` | how long it lasts; a season's dates (shown as dates, never a countdown). |
38
+ | `items[].days` / `starts` / `ends` | Optional duration or sale dates, without a Homie duration ceiling. Fractional days are allowed; end times are rounded to whole milliseconds and must fit safe arithmetic and the JavaScript timestamp range. |
40
39
  | `items[].game` | only in that game's shop (left out: every game's). |
41
- | `items[].advantage` | `true` when it changes how the game plays: never offered, and never counted as owned, on a beginner or kids server. |
40
+ | `items[].advantage` | `true` when it changes how the game plays. The studio policy decides whether it is offered or counted on beginner and kids servers. |
42
41
  | `items[].taxCode` | a Stripe product tax code. Managed Payments needs one: by default `txcd_10201000` (video games, downloaded, permanent) or `txcd_10201001` (limited time). |
43
42
  | `referrals` | what this studio pays a referrer for a new player's purchase (left out: nothing). |
44
43
  | `catalog` | where the items are Products in Stripe: `["test"]`, `["test", "live"]`. `homie-studio shop catalog` writes it once Stripe's catalog matches; the Worker's checkouts then name each item's Product (0.24.3). |
45
44
 
46
- `homie-studio shop check`, and every `homie-studio build`, refuse: anything that names chance, odds, random, a
47
- crate, a box, a mystery, loot, a gacha, a spin or a roll; fields for odds or drop pools; a countdown, timer or
48
- "hurry" field; gems, coins, points or any currency of the studio's own; subscriptions and paid services (later
49
- versions); a cap over 5000 or a refund window under 14 days. A studio whose studio.json says
50
- `"audience": "kids"` sells nothing at all.
45
+ ## The studio decides
51
46
 
52
- ## Who may buy
47
+ There is no Homie ceiling on item prices, tips, catalog size, entitlement count, text lengths, durations,
48
+ refund days, monthly spending or referral terms. The existing amount fields keep their meaning: a cap or
49
+ refund window already written in shop.json stays in effect until the studio edits or removes it.
50
+ No migration or rewrite is needed. `shop init` writes an explicit protective policy and no spending cap,
51
+ refund window or referral agreement. Settings are public, readable and editable in shop.json.
53
52
 
54
- The Worker decides, for every item and every player (`worker/shop-rules.mjs` `wayFor`):
53
+ The studio is responsible for applicable law and its payment provider's terms wherever it sells. These settings
54
+ are choices, not a statement that a sale is legal or permitted by Stripe. This is information, not legal advice.
55
55
 
56
- | The player | What happens |
57
- |---|---|
58
- | on a kids server, or in a kids studio | no shop at all: nothing listed, `shop.open()` says nothing is sold here |
59
- | a guest (no passkey) | "make an account first" (a purchase would be lost with a cookie) |
60
- | an account that has not answered | the one neutral age question: the year they were born, no default, nothing hinting which answer opens anything; kept only as a band |
61
- | an account that said under 13 | nothing, ever; the account is treated as a kids account from then on |
62
- | 13 to 17 | "ask a grown-up": a one-time link (a week) the teen passes on; the parent opens it on their own device, sees what and for whom, ticks that they are the parent or guardian, and pays in their own name on Stripe's page; the item lands on the teen's account |
63
- | an adult | Stripe's own hosted Checkout, one item at a time, with the real-money price and its own confirm |
64
- | over the monthly cap | not until next month |
56
+ ### Policy presets
57
+
58
+ `policy.preset` selects `protective`, `adults-only` or `custom`. An absent policy uses `protective`, preserving
59
+ the existing protections. Override any row directly in `policy`; `shop init` writes all rows so they are easy
60
+ to read and edit. Explicit overrides take precedence when you change presets; remove an override to inherit
61
+ the preset value. Invalid values fail validation rather than silently relaxing a rule.
62
+
63
+ | Setting | protective (new shop default) | adults-only | custom |
64
+ |---|---|---|---|
65
+ | `withdrawalAcknowledgement` | true | true | true |
66
+ | `childAge`, `adultAge` | 13, 18 | 13, 18 | 13, 18 |
67
+ | `requireAccount` | true | true | false |
68
+ | `ageQuestion` | true | true | false |
69
+ | `children` (under 13) | deny | deny | allow |
70
+ | `teens` (13–17) | parent | deny | allow |
71
+ | `kidsStudio`, `kidsServer` | false | false | true |
72
+ | `kidsAdvantages`, `beginnerAdvantages` | false | false | true |
73
+ | `paidRandomRewards`, `countdownOffers`, `virtualCurrency` | false | false | true |
74
+ | `supporterAdvantages`, `repeatPurchases`, `refundUsedItems`, `televisionCheckout` | false | false | true |
75
+
76
+ `children` and `teens` each accept `deny`, `parent`, or `allow`. The neutral age question stores only a band,
77
+ never the birth year. `childAge` and `adultAge` set the band thresholds (13 and 18); the neutral picker spans 120 years. A parent's link lasts a week as a security expiry. Account-free purchases use a guest
78
+ session in this browser; clearing its cookie loses access, so buyers can add a passkey to keep their purchases.
79
+ The protective preset hides the shop on kids servers and kids studios, blocks purchases under 13, requires
80
+ parent checkout for teens, hides game advantages on beginner and kids servers, refuses paid randomness and
81
+ countdowns, and sends TV shoppers to a phone. Nothing relaxes those protections without a studio setting.
82
+
83
+ For example, `{ "policy": { "preset": "protective", "repeatPurchases": true } }` changes only repeat purchases.
84
+ The custom preset deliberately enables all policies; choose it only as a considered studio decision.
85
+ `kind` is descriptive, not a whitelist: services and subscription labels still use the existing one-time checkout.
86
+ Virtual-currency keys and random-reward metadata do not implement a wallet or random-reward fulfillment engine.
87
+
88
+ ### Currency and abuse protection
89
+
90
+ Amounts use Stripe's API units: `500` USD is $5; `500` JPY is ¥500. ISK and UGX retain Stripe's two-decimal API
91
+ representation but require multiples of 100. BHD, JOD, KWD, OMR and TND use scale 1000. Stripe's current currency documentation does not state a divisible-by-ten charge rule; the toolkit does not refuse those amounts and shows Stripe's error if Stripe refuses a checkout. The check warns about the documented settlement-currency minimum where
92
+ known; Stripe decides the actual minimum after conversion and any payment-method maximum at checkout.
93
+ Errors name Stripe as the source. See [Stripe currencies](https://docs.stripe.com/currencies).
94
+ Zero-price Checkout orders are supported and can be revoked without a Stripe refund. Every amount setting accepts only nonnegative whole minor units (numbers or decimal numeric strings); a fractional amount stops the build, names the setting and says what to write. No amount is rounded. All amounts must fit JavaScript safe integer arithmetic. Homie does not impose its own price ceiling or fee floor.
95
+
96
+ `purchaseAttemptsPerMinute` defaults to 6 per account and `purchaseAttemptsPerAddressPerMinute` to 600 per
97
+ address. Both accept positive whole numbers (including decimal numeric strings). The larger address rate
98
+ allows households, schools and venues to share a connection; rotating addresses still hits the account rate.
99
+ `guestBuyersPerAddressPerHour` defaults to 600 new guest buyers per address per hour, allowing a venue or school to arrive together. It must be a positive whole number; 0 cannot disable it accidentally. The existing `PLAYER_LIMIT_DAILY` also applies.
100
+ Checkout, parent links and parent payments have separate counts, per Worker instance. Referral statements use
101
+ `REFERRAL_STATEMENTS_PER_MINUTE` (30 per address), `REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE` (30 globally), and
102
+ `REFERRAL_STATEMENT_BYTES` (65536). Written values must be positive whole decimal numbers; invalid values produce a named settings error. Security
103
+ checks for origins, sessions, signed webhooks and request sizes remain. They protect the studio's money.
65
104
 
66
105
  ## In a game
67
106
 
@@ -72,10 +111,10 @@ await shop.ready; // { open, kids, screen }
72
111
  if (shop.has('skin:ember')) useEmber(); // synchronous: fine in a render loop
73
112
  shop.on('change', (owns) => redraw()); // a purchase landed, or a refund took one back
74
113
  buyButton.onclick = () => shop.open('ember-skin'); // the store sheet (a code to scan on a TV)
75
- shop.used('skin:ember'); // equipped: it leaves the player's own refund window
114
+ shop.used('skin:ember'); // marks it used; the studio refund policy applies
76
115
  ```
77
116
 
78
- Rules for the game: open the shop only from a button the player pressed, never from the play, start or wake
117
+ The protective preset recommends opening the shop only from a button the player pressed, never from the play, start or wake
79
118
  button, never on a timer; show prices in real money; never draw a countdown; never sell randomness; do nothing
80
119
  differently for a supporter on a beginner server.
81
120
 
@@ -85,7 +124,7 @@ keeps running, and the shell looks again when the tab comes back: `change` fires
85
124
 
86
125
  **Badges in rooms.** A player whose account owns a `badge:` key carries it on their seat: `net.players()` gives
87
126
  `peer.badge` ("Supporter"), set by the studio's Worker from what the account owns when the player connects (a
88
- hello can never claim one). None on a kids server, none for an AI.
127
+ hello can never claim one). Kids-server badges follow `policy.kidsServer`; AI seats have no purchases.
89
128
 
90
129
  ## The money path
91
130
 
@@ -96,8 +135,33 @@ Stripe ─ signed webhook ─► POST /api/shop/hook: order paid → entitlement
96
135
  homie.rocks: not involved at any step.
97
136
  ```
98
137
 
99
- - The order row is written before Stripe is called; the Checkout Session carries the order, the player and the
138
+ - The order row atomically reserves against the studio monthly cap before Stripe is called. Reservations are
139
+ released on confirmed expiry, failure, missing session or refund, or an explicit owner release, never merely because a webhook is late; the Checkout Session carries the order, the player and the
100
140
  item in its metadata; the hook checks the session matches the order (player, price) before it grants anything.
141
+ - A sessionless reservation stops counting after its 31-minute creation window plus a one-minute margin.
142
+ A session with no final result, including a bank payment still processing, keeps counting even past expiry.
143
+ Shop GETs never call Stripe. With a cap configured, only a purchase refused by the atomic cap check waits
144
+ for reconciliation: up to three rows in parallel, each with a two-second timeout. Successful purchases continue
145
+ reconciliation in `waitUntil` (the shop has no scheduled handler). Rows are atomically claimed before reading
146
+ [Stripe's session and expanded payment](https://docs.stripe.com/api/checkout/sessions/retrieve).
147
+ Successful reads set a per-row interval of one minute (five minutes for processing bank payments),
148
+ growing with age to one hour. Failed reads retry after a short backoff of about one minute and log the order and reason. With more than three lost rows, a refusal may need another purchase attempt.
149
+ Paid counts in the month of [the Stripe charge's creation time](https://docs.stripe.com/api/charges/object#charge_object-created);
150
+ missing payment time, pending or unreachable keeps counting. Confirmed expiry or failure releases it.
151
+ Stripe's [404 resource_missing](https://docs.stripe.com/error-codes#resource-missing) means this key's account
152
+ has no such session, not that no payment can follow: the row stops reserving the cap and is shown as `missing` in the office.
153
+ The office lists unresolved orders first, with Next orders for more. Release reservation records the confirming
154
+ owner's account (or a fingerprint of the owner's office sign-in session) and time in the office-only note, and asks Stripe to
155
+ [expire an open Checkout Session](https://docs.stripe.com/api/checkout/sessions/expire). It does not refund a payment.
156
+ A verified payment still grants the item and counts toward spending, including after a missing mark or release,
157
+ and can be refunded from the office. Duplicate events change nothing. Players see their previous unfinished
158
+ order state, never the internal mark or note.
159
+ An owner release or missing mark can free space for another purchase before a late payment counts again;
160
+ new reservations still obey the configured cap. Test orders never count toward a live
161
+ cap. Free orders remain available if an owner lowers a cap below prior spending. Partial refunds keep the whole
162
+ price counted; full refunds and lost disputes release it. No spending SUM runs when the cap is absent.
163
+ Deploy applies `0010_shop_reservations.sql` and `0011_shop_statements.sql`; readiness names these migrations
164
+ if the required schema is absent.
101
165
  - The webhook's `Stripe-Signature` is checked (HMAC-SHA256 of `<t>.<payload>` with the endpoint's `whsec_`
102
166
  secret, within five minutes); each event id is handled once; a test event never touches a live shop.
103
167
  - Paid: the entitlements are granted. Refunded: that one order's entitlements are revoked. A dispute: nothing
@@ -107,7 +171,7 @@ homie.rocks: not involved at any step.
107
171
  page lists the player's orders and badges; `/api/player/export` includes them; deleting an account asks first
108
172
  when it owns things, and keeps the order rows without the player.
109
173
  - Each Checkout Session says beside the pay button that the item is delivered at once (the EU and UK withdrawal
110
- acknowledgement) and that an unused item can still be refunded.
174
+ acknowledgement) and the studio's configured refund terms. `policy.withdrawalAcknowledgement` is true in every preset, restoring the existing wording, and is editable. Stripe caps product names at 5000 characters, descriptions at 40000 and submit wording at 1200; the build reports these provider constraints without truncation.
111
175
 
112
176
  ## Setting it up (the AI does the commands; the owner only uses Stripe's pages and one page here)
113
177
 
@@ -180,8 +244,33 @@ Approvals), and the webhook finishes it. Reconnect with a plain restricted key t
180
244
  "How are sales?": `homie-studio shop` and `shop orders` (the studio's own books, no names), and through Stripe's MCP,
181
245
  read-only, `stripe_analytics` or `stripe_api_read` on the balance, payouts and checkout sessions.
182
246
 
247
+ ## Referral pages
248
+
249
+ `/api/shop?cursor=...` serves 100 items per page and returns `nextCursor`. The shop page and in-game sheet offer the next page.
250
+ The office's `/_studio/api/shop?cursor=...` pages by referrer, with exact SQL totals and sale counts shown once per referrer and currency.
251
+ `/_studio/api/shop/lines?via=...&currency=...&cursor=...` pages the sales inside that book without repeating its totals.
252
+ Statement pages use `/_studio/api/shop/statements?period=YYYY-MM&cursor=...`; `shop statements --cursor ...` reads the next page.
253
+ One signed statement per referrer and currency has its totals on the first page only; continuation pages carry lines. A new first page replaces all previously received pages for that period and currency.
254
+ Read-only statement views read the current lines without making stored editions.
255
+ `shop statements --send` and the office's Send statements button save their progress and immutable lines in D1.
256
+ Keep the command or tab open to continue; close midway and start again to resume safely, without doubles or skipped
257
+ pages. After completion, starting again sends a fresh edition safely. Failed pages retry three times with exponential
258
+ backoff (at least a minute for rate refusals). Persistent failures name the referrer, stop its remaining pages and
259
+ continue the rest; starting a fresh run retries those referrers. The CLI reuses one office key, renewing its expiry
260
+ near ten minutes. Mark paid applies only to the selected currency and cannot change the edition being sent.
261
+ Received signed pages are available at `/_studio/api/shop/received?seller=...&period=...&cursor=...`. No row is hidden by a row cap.
262
+
263
+
264
+
183
265
  ## Referrals
184
266
 
267
+ The studio sets `rate` (a nonnegative share, including values over 100%), `windowDays`, `capPerPlayer`,
268
+ `holdDays`, `minimumInvoice` and accepted invoice methods. No ceilings or forced relation between hold and
269
+ refund days. Omitted window and per-player cap impose neither; omitted hold means no hold and omitted minimum
270
+ invoice imposes no minimum. `rate` keeps the existing 10% suggestion when referrals are explicitly enabled.
271
+ An absent `referrals` pays nothing. Published invoice terms are settled by the studio, outside Homie.
272
+
273
+
185
274
  - A link with `?via=<host>` (another studio, homie.rocks, anywhere) opened by a person whose browser has never
186
275
  been to this studio leaves a signed cookie with the host and the time (only on a studio that pays referrals;
187
276
  never a prefetch, a crawler, this site, or a server's page or room).
package/shop/shop.ts CHANGED
@@ -15,10 +15,8 @@
15
15
  * answers this module by postMessage, only for THIS game. The Worker decides: an item bought with the studio's own
16
16
  * Stripe, paid (the signed webhook said so), not refunded, not ended. A game never sees a card, an email or an order.
17
17
  *
18
- * THE KIDS RULES (the kit's, not the game's to change): on a kids server there is no shop (`open` is false, `kids` is
19
- * true, `open()` shows "nothing is sold here"); on a beginner or kids server anything with a play advantage counts as
20
- * NOT owned; on a television `open()` shows only a code to scan and buy on a phone. A game never shows a countdown, a
21
- * random reward for money, or a buy button on its play button.
18
+ * STUDIO POLICIES: the shell follows shop.json. The protective preset closes kids shops, hides advantages on
19
+ * beginner and kids servers, and shows a code to buy on a phone from a TV. The studio can edit each policy.
22
20
  *
23
21
  * Without a shell (a game opened as a file, a dev server, a studio with no shop) everything is empty and `open()` does
24
22
  * nothing, so the same code runs everywhere. It has no imports and uses only erasable TypeScript.
@@ -57,14 +55,14 @@ export interface Shop {
57
55
  /** Show the store sheet, at one item if given. Call it from a button the player pressed, never on a timer. */
58
56
  open(item?: string): void;
59
57
  close(): void;
60
- /** The player used something (equipped it, played with it): it leaves the player's own refund window. */
58
+ /** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
61
59
  used(key: string): Promise<boolean>;
62
60
  on(ev: 'change', fn: (owns: string[]) => void): () => void;
63
61
  on(ev: 'closed', fn: () => void): () => void;
64
62
  }
65
63
 
66
64
  type Msg = Record<string, unknown> & { t?: string; q?: number; ev?: string; ok?: boolean };
67
- const KEY = /^[a-z0-9][a-z0-9_.:-]{0,63}$/;
65
+ const KEY = /^[^\u0000-\u001f\u007f]+$/;
68
66
 
69
67
  export function createShop(opts: ShopOptions = {}): Shop {
70
68
  const g = globalThis as unknown as { HOMIE_NET?: { shop?: boolean }; parent?: unknown; addEventListener?: typeof addEventListener };
@@ -87,9 +85,9 @@ export function createShop(opts: ShopOptions = {}): Shop {
87
85
  };
88
86
  const setOwns = (next: unknown): boolean => {
89
87
  const list = Array.isArray(next) ? next.map(String).filter((k) => KEY.test(k)) : [];
90
- const before = owns.slice().sort().join(',');
88
+ const before = JSON.stringify(owns.slice().sort());
91
89
  owns = list;
92
- return list.slice().sort().join(',') !== before;
90
+ return JSON.stringify(list.slice().sort()) !== before;
93
91
  };
94
92
  if (target && g.addEventListener) {
95
93
  g.addEventListener('message', (e: MessageEvent) => {
package/site/SITE.md CHANGED
@@ -398,8 +398,7 @@ A studio with a `shop.json` ([../shop/SHOP.md](../shop/SHOP.md)) has `/shop/` (i
398
398
  one neutral age question, Buy on Stripe's own page, or a link for a parent), `/shop/thanks`, `/shop/refunds/` (the
399
399
  refund policy every seller publishes) and `/shop/parent/<link>` (what a parent opens). The account page lists the
400
400
  player's purchases and badges, with the self-serve refund of an unused item. A play page whose game the studio
401
- sells something for carries the store sheet (the game's `shop.open()`, and Shop in the room sheet); on a kids server
402
- there is none, and the big screen's sheet is a code to buy on a phone. With a `shop.json`, `/shop/` belongs to the
401
+ sells something for carries the store sheet (the game's `shop.open()`, and Shop in the room sheet); the protective shop policy hides it on kids servers and sends big-screen shoppers to a phone. The studio can edit these policies in shop.json. With a `shop.json`, `/shop/` belongs to the
403
402
  shop (a `site/pages/shop/` is not served); without one it is the studio's own. The directory manifest says only
404
403
  whether the shop is open and its till.
405
404
 
@@ -474,3 +473,121 @@ posts to a `/cdn-cgi/rum` a Worker does not serve). Generated pages are never fr
474
473
  strict-origin-when-cross-origin` (another studio's stats see which site sent a visitor, never the path). The
475
474
  play page and the game may be framed by the site itself and by the https origins studio.json lists in
476
475
  `"site": { "frameAncestors": ["https://…"] }`.
476
+
477
+ ## Play from a post
478
+
479
+ Post a public game's landing link (`/<id>/`) or Play link (`/<id>/play`) on X.
480
+ When X shows a player card, its phone apps show the game's picture with a play
481
+ button; one tap opens `/<id>/play/embed` full screen inside X's in-app browser.
482
+ The player goes straight into a public room with a guest name and seat. On X's
483
+ website, third-party game cards open the posted link in a new tab. Post the player
484
+ address itself when that new tab should go straight into the game.
485
+
486
+ The player address uses the same Play code with a layout flag. Opened as a page
487
+ of its own on a phone, the game, the room button and the room sheet keep to the
488
+ screen's safe area; cross-origin frames also work when the host permits them. Open
489
+ Graph tags keep their previous picture for other platforms. An owner's hand-written
490
+ Twitter tags on a custom landing take precedence as a set.
491
+
492
+ The switch is on by default. The tags go out on an HTTPS site for a game with a
493
+ local picture the build has measured: it tries the wide landing still, the landing
494
+ cover, the game's cover, then the studio's social picture, and takes the first one
495
+ X's reference says will render: JPG, PNG, WEBP or GIF, at least 68,600 pixels
496
+ (262×262, or 350×196), under 5 MB. A picture that fails one of those, or that the
497
+ build cannot read whole, produces a warning naming the file and the rule, and the
498
+ page keeps the picture card it had. Remote pictures cannot be measured by the build.
499
+ Starters do not include a cover: supply one before expecting player tags.
500
+
501
+ The player is advertised as 480×480. That is Homie's choice, not a rule of X's: the
502
+ two game pages whose posts showed a play button when this was written both
503
+ advertise 480×480, and a square player at a phone's post width (about 358 pixels)
504
+ leaves a framed game room for its own dialogs, where a 16:9 one is 201 pixels tall.
505
+ X's reference says the picture "should" have the player's dimensions; it does not
506
+ make that a condition, and both of those pages use wider pictures (1200×628 and
507
+ 1200×800). So any shape gets the card: a standard 1200×630 social picture, a 16:9
508
+ still or a square. X's website draws the picture as a small square thumbnail
509
+ whatever its shape.
510
+
511
+ `"twitterSite": "@yourstudio"` in `studio.json`'s `site` object adds `twitter:site`
512
+ to the tags. It is optional: X's reference lists the tag, neither of those two
513
+ pages carries it, and Homie never withholds a card for the lack of it.
514
+
515
+ Play, Watch and the player address carry the card's tags (and Open Graph tags beside
516
+ them) only when the game has a player card; without one they carry no social tags.
517
+
518
+ To switch it off, set `"playerCard": false` inside `studio.json`'s `site` object,
519
+ or in `games/<id>/game.json` for one game. Remove the switch or set it to `true`
520
+ to turn it back on; studio-wide off wins. A television game with no single-screen
521
+ view must set `"screen": { "singleScreen": false }` (keep its other settings).
522
+ Private and invite-only games never advertise or serve the player address.
523
+
524
+ `site.playerCardOrigins` replaces the embed's allowed HTTPS ancestors, including
525
+ intermediate frames. The default is
526
+ `["https://x.com", "https://*.x.com", "https://twitter.com", "https://*.twitter.com"]`.
527
+ An empty list denies framing. At most eight valid origins are kept. Wildcards must
528
+ name a domain; paths, credentials, invalid ports and arbitrary CSP text are refused.
529
+ This changes neither ordinary pages nor `site.frameAncestors`. The player's CSP
530
+ is Play's policy with the frame ancestors changed; its inner game remains isolated
531
+ from the studio's cookies and storage.
532
+
533
+ Blocked or cleared storage still permits a guest seat. Opened as a page of its
534
+ own, and while the tab's storage works, a reload, Back or Forward comes back to the
535
+ same room, seat and guest name; in a frame every instance is a new guest (two posts
536
+ of one game on a page are two players). If the lobby does not answer, the player
537
+ joins the room `main`, exactly as Play does. Saves may not follow a guest back to
538
+ the studio site. Sound starts on the first press, including when a game requested
539
+ sound during startup. Pointer lock, fullscreen and gamepads depend on the host's
540
+ permissions; use keys or touch if unavailable.
541
+
542
+ The room button reads **Room N · Site**, and the first row of its sheet is **Open on
543
+ the studio's site**, a 44-pixel link carrying the room to Play: it is in view when
544
+ the sheet opens, in a frame 201 pixels tall too. The sheet scrolls in short frames
545
+ and says when there is more below. In a frame, account, purchases, Game and
546
+ big-screen links open a tab of their own, and words about tabs and "this post" are
547
+ shown only there; as a page of its own the same links navigate the tab. Signing in
548
+ from the player comes back to Play in the same room, never to the guests-only
549
+ player. No purchase starts in the player. If popups are blocked, the sheet shows
550
+ the address to copy. A host that removes same-origin permission gets a clear
551
+ connection message and exit instead of an endless loader.
552
+
553
+ With `homie-studio dev` running, open `/<id>/play/preview` locally. It uses a different
554
+ loopback hostname and X's observed sandbox: `allow-scripts allow-same-origin
555
+ allow-popups allow-popups-to-escape-sandbox`, `allow="autoplay; fullscreen; web-share"`,
556
+ and `scrolling="no"`. Preview-only loopback ancestors never apply to deployed sites.
557
+ Also open `/<id>/play/embed` directly at phone and desktop sizes.
558
+
559
+ From the toolkit checkout,
560
+ `CHROME_PATH=/path/to/chrome node --test packages/studio/test/embed*.test.mjs`
561
+ runs the five starters at the player address in real Chrome, as a page of its own
562
+ and framed. The starter run also needs Wrangler: installed in the toolkit's
563
+ `node_modules`, or `WRANGLER_PATH` pointing to an installed
564
+ `wrangler/bin/wrangler.js`. Without `CHROME_PATH` the browser checks skip, and
565
+ without Wrangler the starter run skips; all bindings run locally without
566
+ credentials.
567
+
568
+ **Evidence and limits (2026-10-08):**
569
+
570
+ - X's archived player-card reference (developer.x.com, "Cards: player card")
571
+ documents the phone-app picture/play button and top-level in-app browser, the
572
+ tags (`twitter:site` among them), HTTPS, and the picture conditions above. Its
573
+ written policy reserves this card for linear audio/video and explicitly excludes
574
+ gaming and sign-in requirements. This is not a supported game API. The URL may
575
+ now redirect; the
576
+ [official sample](https://github.com/xdevplatform/cards-player-samples/blob/main/player/page.html)
577
+ also shows the tags.
578
+ - X's [public web-client bundle](https://abs.twimg.com/x-web/x-web/assets/article-card-DEH89WAV.js),
579
+ release `8063dc2e55869d8eae1c30e18f913da88bca1582`, only sets a frame URL for
580
+ YouTube, SoundCloud and Periscope player hosts. Other hosts get a new-tab link
581
+ to the posted URL. That observation is from code, not a signed-in session;
582
+ server flags could vary by account. The same code supplies the sandbox above,
583
+ enables fullscreen, and clamps tall player ratios to square.
584
+ - Two live game pages, read as X's crawler: `snekarena.com/r/main` and a game page
585
+ on `www.spawn.co`. Both carry the player tags with a 480×480 player, pictures of
586
+ 1200×628 and 1200×800, and no `twitter:site`; the first allows the X and Twitter
587
+ domain families as frame ancestors. They show what X accepted that week; they do
588
+ not prove X frames arbitrary games. No exhaustive current official ancestor list
589
+ was found.
590
+
591
+ X decides what it shows, may cache tags, and may change or stop this behavior.
592
+ Local success does not prove that X will display a card. The studio can use the
593
+ off switch above if it does not want to offer a game through a video-oriented card.
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "coin-dash",
3
+ "name": "Coin Dash",
4
+ "blurb": "Run for the coins before the others do. Sixty-second rounds, and the score is kept on the server, where nobody can change it.",
5
+ "players": { "min": 1, "max": 8 },
6
+ "entry": "src/view.ts",
7
+ "example": true,
8
+ "netplay": { "v": 1, "public": true },
9
+ "room": { "host": "server" },
10
+ "landing": { "pitch": "Run for the coins. First one there takes it.", "controls": { "phone": "Drag to run.", "computer": "WASD or arrows to run." } }
11
+ }
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
6
+ <title>Coin Dash</title>
7
+ <link rel="icon" href="data:,">
8
+ <style>
9
+ html, body { margin: 0; height: 100%; background: #0b1020; overflow: hidden; overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
10
+ canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <canvas id="c"></canvas>
15
+ <script type="module" crossorigin src="./assets/main.js"></script>
16
+ </body>
17
+ </html>
@@ -0,0 +1,16 @@
1
+ {
2
+ "bounds": { "min": [-12, -7], "max": [12, 7] },
3
+ "boxes": [
4
+ { "min": [-6.5, 1.5], "max": [-4.5, 3.5] },
5
+ { "min": [4.5, -3.5], "max": [6.5, -1.5] },
6
+ { "min": [-1, -0.5], "max": [1, 0.5] }
7
+ ],
8
+ "circles": [
9
+ { "at": [5.5, 3], "r": 1 },
10
+ { "at": [-5.5, -3], "r": 1 }
11
+ ],
12
+ "spots": {
13
+ "start": [[-10, 5], [10, -5], [-10, -5], [10, 5], [0, 5.5], [0, -5.5], [-10, 0], [10, 0]],
14
+ "coins": [[-8, 2.5], [-8, -2.5], [8, 2.5], [8, -2.5], [-3, 4.5], [3, 4.5], [-3, -4.5], [3, -4.5], [-2.5, 0], [2.5, 0], [0, 2.5], [0, -2.5], [-6, 0], [6, 0], [-9, 6], [9, -6]]
15
+ }
16
+ }
@@ -0,0 +1,27 @@
1
+ /*
2
+ * COIN DASH — how a runner moves. Its own file, because two places run it: the server, for every body it moves, and
3
+ * each player's browser, for that player's own body. So it reads only what both have: the body's own position,
4
+ * velocity, heading and `motion`, this step's input, the static map and the public tunables (`ctx.tune`). It sees no
5
+ * other entity, no score and no dice.
6
+ */
7
+ import { defineMove } from '@homie-rocks/studio/rules';
8
+
9
+ export const move = defineMove({
10
+ runner(body, input, ctx) {
11
+ const M = ctx.math;
12
+ // A runner stands still through the break between rounds (the rules set `frozenUntil`).
13
+ const speed = ctx.tick < body.motion.frozenUntil ? 0 : ctx.tune.speed;
14
+ // A diagonal is no faster: the stick is held to length 1.
15
+ body.vel = M.scale(M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1), speed);
16
+ if (speed > 0 && M.len(body.vel) > 0.5) body.heading = M.norm(body.vel);
17
+ const step = M.scale(body.vel, ctx.dt);
18
+ const from = body.pos;
19
+ const wall = ctx.map.sweep(body, step); // moves body.pos, and stops at the first wall
20
+ if (wall) {
21
+ // Slide: what is left of the step, along the wall.
22
+ const whole = M.len(step);
23
+ const left = whole > 0 ? 1 - M.len(M.sub(body.pos, from)) / whole : 0;
24
+ ctx.map.sweep(body, M.scale(M.sub(step, M.scale(wall.normal, M.dot(step, wall.normal))), left));
25
+ }
26
+ },
27
+ });
@@ -0,0 +1,86 @@
1
+ /*
2
+ * COIN DASH — the rules: the example of a game whose rules run on the server.
3
+ *
4
+ * What is true in the game is decided here, in one place, on the studio's own Cloudflare: who took which coin, every
5
+ * score, when a round starts and ends. A player's browser sends what the player presses and draws what it is told
6
+ * (src/view.ts); it cannot change a score.
7
+ *
8
+ * HOW RULES ARE WRITTEN. Every entity owns its own state, in declared fields. A handler may write only the entity it
9
+ * runs for (`self`); everything else it reads is read-only. To affect another entity it sends an event, which arrives
10
+ * on a later tick. So a runner does not take a coin: it asks the coin (`take`), the coin answers the first one that
11
+ * asked (`score`) and is gone, and a second `take` finds nobody there.
12
+ *
13
+ * In this release a person's runner moves as today: its own browser says where it is, and the server holds each claim
14
+ * to the body's top speed (`move: 'owner'`). The server moves the bots with the same code (src/move.ts).
15
+ */
16
+ import { defineRules, f } from '@homie-rocks/studio/rules';
17
+ import { move } from './move';
18
+
19
+ export default defineRules({
20
+ contract: 2,
21
+ space: { dims: 2 },
22
+ move,
23
+ shapes: {
24
+ events: { take: { by: f.ref() }, score: {} },
25
+ commands: {},
26
+ effects: { ding: {} },
27
+ },
28
+ entities: {
29
+ runner: {
30
+ player: { away: 'think', leave: 'bot' },
31
+ fields: { score: f.u16({ score: true }) },
32
+ motion: { frozenUntil: f.tick() },
33
+ input: { ax: f.i8(), ay: f.i8() },
34
+ body: { shape: 'circle', radius: 0.5, maxSpeed: 6, move: 'owner' },
35
+ tick(world, self) {
36
+ if (world.round.phase !== 'live') return;
37
+ // A second `take` sent before the coin is gone is dropped: the coin no longer exists.
38
+ for (const coin of world.near(self.pos, 1, 'coin')) world.send(coin.id, 'take', { by: self.id });
39
+ },
40
+ think(world, self) { // a bot, and a player who is away
41
+ const coin = world.near(self.pos, 64, 'coin')[0];
42
+ if (!coin) return { ax: 0, ay: 0 };
43
+ const d = world.math.norm(world.math.sub(coin.pos, self.pos));
44
+ return { ax: Math.round(d.x * 127), ay: Math.round(d.y * 127) };
45
+ },
46
+ on: {
47
+ score(world, self) { self.score += 1; },
48
+ arrive(world, self) { // joined during a break: stand still with the others
49
+ if (world.round.phase === 'over') self.motion.frozenUntil = world.round.endsAt;
50
+ },
51
+ },
52
+ onRoom: {
53
+ roundStart(world, self) {
54
+ const spots = world.map.spots('start');
55
+ self.score = 0;
56
+ self.motion.frozenUntil = 0;
57
+ world.place(self, spots[self.seat % spots.length]);
58
+ },
59
+ roundOver(world, self) { self.motion.frozenUntil = world.round.endsAt; }, // stand still through the break
60
+ },
61
+ },
62
+ coin: {
63
+ on: {
64
+ take(world, self, e) { // first taker wins: a despawned coin hears nothing more this tick
65
+ world.send(e.by, 'score', {});
66
+ world.emit('ding', self.pos, {});
67
+ world.despawn(self);
68
+ },
69
+ },
70
+ onRoom: { roundOver(world, self) { world.despawn(self); } },
71
+ },
72
+ },
73
+ shared: {},
74
+ room: {
75
+ rounds: { seconds: 60, breakSeconds: 8 },
76
+ bots: { keep: 4 },
77
+ join(ctx, player) {
78
+ const spots = ctx.map.spots('start');
79
+ return { kind: 'runner', at: spots[player.seat % spots.length] };
80
+ },
81
+ on: {
82
+ roundStart(world) { for (const spot of world.map.spots('coins')) world.spawn('coin', spot, {}); },
83
+ },
84
+ },
85
+ map: './map',
86
+ });