@homie-rocks/studio 0.23.0 → 0.24.2

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.
@@ -1000,6 +1000,84 @@
1000
1000
  }
1001
1001
  }
1002
1002
  }
1003
+ },
1004
+ "0.24.0": {
1005
+ "files": {
1006
+ "CLAUDE.md": "ddf2b2a0376202f6",
1007
+ "README.md": "684b3cca8001673c",
1008
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1009
+ "games/README.md": "5ad32fe80c61f117",
1010
+ "music/README.md": "a3dc042dadd39df8",
1011
+ "videos/README.md": "16babc3437d5545b",
1012
+ "posts/README.md": "37841aae476bd540",
1013
+ "site/README.md": "ca131cd0472464fb",
1014
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1015
+ },
1016
+ "sections": {
1017
+ "AGENTS.md": {
1018
+ "(intro)": "214aa46eb3a895d5",
1019
+ "## Layout": "8baf49e4291e7e88",
1020
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1021
+ "## The site": "ab922bb6bde23293",
1022
+ "## Making games": "a8a893eeada33bf5",
1023
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1024
+ "## Art direction and models": "124772c374de7390",
1025
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1026
+ "## Servers and AI seats": "0a332b07e22d3fce",
1027
+ "## Selling things (the shop)": "9d5c3c58d446bfa5",
1028
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1029
+ "## Rules": "7e8458adc1b81ece",
1030
+ "## Beta": "9d02638207a26e73"
1031
+ }
1032
+ },
1033
+ "named": {
1034
+ "Homie Arcade": {
1035
+ "sections": {
1036
+ "AGENTS.md": {
1037
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ },
1043
+ "0.24.2": {
1044
+ "files": {
1045
+ "CLAUDE.md": "ddf2b2a0376202f6",
1046
+ "README.md": "684b3cca8001673c",
1047
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1048
+ "games/README.md": "5ad32fe80c61f117",
1049
+ "music/README.md": "a3dc042dadd39df8",
1050
+ "videos/README.md": "16babc3437d5545b",
1051
+ "posts/README.md": "37841aae476bd540",
1052
+ "site/README.md": "ca131cd0472464fb",
1053
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1054
+ },
1055
+ "sections": {
1056
+ "AGENTS.md": {
1057
+ "(intro)": "214aa46eb3a895d5",
1058
+ "## Layout": "8baf49e4291e7e88",
1059
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1060
+ "## The site": "ab922bb6bde23293",
1061
+ "## Making games": "a8a893eeada33bf5",
1062
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1063
+ "## Art direction and models": "124772c374de7390",
1064
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1065
+ "## Servers and AI seats": "0a332b07e22d3fce",
1066
+ "## Selling things (the shop)": "9d5c3c58d446bfa5",
1067
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1068
+ "## Rules": "7e8458adc1b81ece",
1069
+ "## Beta": "9d02638207a26e73"
1070
+ }
1071
+ },
1072
+ "named": {
1073
+ "Homie Arcade": {
1074
+ "sections": {
1075
+ "AGENTS.md": {
1076
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1003
1081
  }
1004
1082
  }
1005
1083
  }
@@ -349,7 +349,9 @@ A socket that is kicked for silence is closed with code 4000, and the helper rec
349
349
  Record types:
350
350
 
351
351
  ```ts
352
- Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts }
352
+ Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts, badge?: string }
353
+ (badge: a word the studio's Worker verified from what the player's account owns, such as a supporter's
354
+ "Supporter"; a hello can never set it; never on a kids server, never for an AI. @homie-rocks/studio 0.24.0)
353
355
  RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
354
356
  RoundResult = { slot, seat: number|null, name, score, bot, place, agent?: true }
355
357
  Slot = { slot, seat: number|null, name, bot, agent?: { seat: number|null, role, hands } }
@@ -269,6 +269,12 @@ export interface Peer {
269
269
  watch?: boolean;
270
270
  /** Revision 6: an AI in a seat (always named "<label> · AI"). */
271
271
  agent?: AgentFacts;
272
+ /**
273
+ * A badge the player's account owns on this studio (a supporter's, from the shop: @homie-rocks/studio 0.24.0), as
274
+ * a word to show beside their name. Only the studio's Worker sets it, from what the account bought; a hello cannot.
275
+ * Never on a kids server, never for an AI.
276
+ */
277
+ badge?: string;
272
278
  }
273
279
 
274
280
  /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.23.0",
3
+ "version": "0.24.2",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -30,6 +30,10 @@
30
30
  "types": "./saves/saves.ts",
31
31
  "default": "./saves/saves.ts"
32
32
  },
33
+ "./shop": {
34
+ "types": "./shop/shop.ts",
35
+ "default": "./shop/shop.ts"
36
+ },
33
37
  "./agents": {
34
38
  "types": "./agents/agents.ts",
35
39
  "default": "./agents/agents.ts"
@@ -53,6 +57,7 @@
53
57
  "worker/",
54
58
  "mcp/",
55
59
  "saves/",
60
+ "shop/",
56
61
  "agents/",
57
62
  "lab/",
58
63
  "assets/",
@@ -0,0 +1,83 @@
1
+ # Selling things in your games: what you are taking on
2
+
3
+ Plain words for a studio's owner, from the Homie studio kit. **This is not legal or tax advice.** If you will
4
+ sell for real, ask an accountant where you must register for tax, and a lawyer anything this page leaves you
5
+ unsure about.
6
+
7
+ ## The short version
8
+
9
+ You sell with **your own Stripe account**. Money goes straight from your players to you. Homie never sees it,
10
+ never holds it, and takes no cut. That also means **you are the shop**: you set the prices, you answer refund
11
+ requests, you deal with card disputes, and the tax on your sales is yours to handle.
12
+
13
+ ## Who does what
14
+
15
+ | | You (the studio) | Stripe | Homie |
16
+ |---|---|---|---|
17
+ | Prices, what you sell | yes | | |
18
+ | Taking the card, the receipt email | | yes | |
19
+ | Refunds | yes: one tap in your office | carries them out | |
20
+ | Card disputes (chargebacks) | yes: you answer them in Stripe and carry the loss if you lose | runs the process | |
21
+ | Sales tax and VAT | yes, with Stripe Tax's help (or Managed Payments, below) | calculates and collects | |
22
+ | Your players' data | yes: you are responsible for it | keeps the card and email | stores nothing |
23
+ | The kids rules | built into the kit, and you can be stricter | | wrote the kit |
24
+
25
+ ## Refunds
26
+
27
+ - A player can refund an **unused** item themselves within your refund window (at least 14 days) from their
28
+ account page. Fewer people turn to card disputes when a refund is easy.
29
+ - Anything else, you refund with **one tap** on your office's shop page. Your AI can only *ask* you to refund; it
30
+ can never do it by itself.
31
+ - Stripe does not give back its processing fee on a refund. That is the cost of a happy player.
32
+
33
+ ## Disputes (chargebacks)
34
+
35
+ - A buyer's bank can reverse a payment. Stripe charges a dispute fee (about US$15) whether you win or lose, and
36
+ you lose the sale's money if the bank decides for the buyer. On a US$5 item one dispute costs more than the
37
+ sale, which is why the kit makes refunds easy and suggests items of US$3 or more.
38
+ - **A dispute never deletes or locks the player's account.** While it is open nothing changes. If you lose it,
39
+ that one item is taken back, as a refund would.
40
+
41
+ ## Tax
42
+
43
+ You choose who the seller is when you connect your Stripe (`homie-studio shop connect` shows both):
44
+
45
+ - **You are the seller** (`"till": "stripe"`): Stripe Tax is on. It works out and collects tax **only where you
46
+ have told Stripe you are registered**, and it does not file your returns. Some places expect a foreign seller
47
+ of digital goods to register from the first sale (the EU and the UK do). Cheapest; the most work.
48
+ - **Stripe is the seller** (`"till": "stripe-managed"`, Stripe Managed Payments): **3.5% more** a sale. Stripe's
49
+ Link, LLC becomes the seller of record and registers, collects, files and pays sales tax and VAT in 80+
50
+ countries, runs fraud checks and answers disputes for you. You still carry the money of a lost dispute, and
51
+ Stripe may refund a buyer within 60 days. You turn it on in Stripe first (after Stripe's eligibility review).
52
+
53
+ ## Kids
54
+
55
+ The kit keeps you out of the worst trouble, and you cannot switch these off in it (you can be stricter):
56
+
57
+ - nothing is sold on a kids server, or anywhere in a studio made for children (`"audience": "kids"`);
58
+ - spending is off on every account until one neutral question (the year you were born) says the player is an
59
+ adult; the answer is kept only as "adult", "teen" or "child";
60
+ - nothing is ever sold to an account that said it is under 13;
61
+ - a 13 to 17 year old can only pass a link to a parent, who pays in their own name on Stripe's page;
62
+ - no loot boxes or anything random for money, nothing that changes the game on beginner servers, no countdowns;
63
+ - every price is in real money, every purchase goes through Stripe's own page, one at a time, with a monthly cap;
64
+ - the television never sells: it shows a code to buy on a phone.
65
+
66
+ Even so, if children play your games you are the one responsible to the law for them (for example COPPA in the
67
+ US, the Children's Code in the UK, GDPR in the EU). Keep what you collect small, and keep chat with strangers off
68
+ on kids servers.
69
+
70
+ ## Referrals
71
+
72
+ If another studio (or homie.rocks, or a creator) sends you a new player who buys something, you owe them the
73
+ referral share you published in `shop.json` (10% if you kept the suggestion; 0 if you turn it off). Your office
74
+ shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
75
+ yourself.** Nothing moves through Homie.
76
+
77
+ ## Before the first real sale
78
+
79
+ 1. Make (or pick) your Stripe account, verify it, and add a bank account.
80
+ 2. Test everything in **test mode** first: buy your own item with a Stripe test card, refund it from the office.
81
+ 3. Fill in your public business details in Stripe (name, support email, the refund page `/shop/refunds/`).
82
+ 4. Talk to an accountant about where you must register for tax, or choose Managed Payments.
83
+ 5. Then, and only then, connect a live key (`homie-studio shop connect --live`).
package/shop/SHOP.md ADDED
@@ -0,0 +1,154 @@
1
+ # The shop: selling in a studio's games with the studio's own Stripe
2
+
3
+ `@homie-rocks/studio` 0.24.0. A studio sells items for its games through **its own Stripe account**. The studio
4
+ is the seller; homie.rocks never holds, routes or settles its money, is not a Stripe Connect platform, and takes
5
+ no cut. There is no shared currency between studios. `SELLING.md` beside this file is the owner's plain-words
6
+ note on what selling means for them (refunds, disputes, tax, kids; not legal advice).
7
+
8
+ ## shop.json
9
+
10
+ At the studio's root, reviewed in git. Nothing secret is ever in it.
11
+
12
+ ```json
13
+ {
14
+ "till": "stripe",
15
+ "currency": "usd",
16
+ "refundDays": 14,
17
+ "capPerPlayerMonth": 5000,
18
+ "items": [
19
+ { "id": "supporter", "kind": "supporter", "name": "Supporter", "price": 500, "days": 365,
20
+ "gives": ["badge:supporter", "credits-name"], "badge": "Supporter" },
21
+ { "id": "ember-skin", "kind": "cosmetic", "game": "orbit", "name": "Ember hull", "price": 300, "gives": ["skin:ember"] },
22
+ { "id": "season-1", "kind": "pass", "name": "Season 1", "price": 800, "ends": "2027-01-31", "gives": ["track:season-1"] },
23
+ { "id": "tip", "kind": "tip", "name": "Buy the studio a coffee", "price": "choose", "min": 200, "max": 5000 }
24
+ ],
25
+ "referrals": { "rate": 0.10, "windowDays": 30, "capPerPlayer": 1000, "holdDays": 30, "minimumInvoice": 2500 }
26
+ }
27
+ ```
28
+
29
+ | Field | Meaning |
30
+ |---|---|
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`. |
32
+ | `currency` | one currency with cents (`usd`, `cad`, `eur`, `gbp`, `aud`…). |
33
+ | `refundDays` | 14 to 60: a player refunds an unused item themselves within it. |
34
+ | `capPerPlayerMonth` | cents one player may spend here in a month: 100 to 5000. Lower it, never remove it. |
35
+ | `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`). |
36
+ | `items[].price` | whole cents, real money. Under 300 the check warns (fees eat it). |
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. |
38
+ | `items[].days` / `starts` / `ends` | how long it lasts; a season's dates (shown as dates, never a countdown). |
39
+ | `items[].game` | only in that game's shop (left out: every game's). |
40
+ | `items[].advantage` | `true` when it changes how the game plays: never offered, and never counted as owned, on a beginner or kids server. |
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). |
42
+ | `referrals` | what this studio pays a referrer for a new player's purchase (left out: nothing). |
43
+
44
+ `homie-studio shop check`, and every `homie-studio build`, refuse: anything that names chance, odds, random, a
45
+ crate, a box, a mystery, loot, a gacha, a spin or a roll; fields for odds or drop pools; a countdown, timer or
46
+ "hurry" field; gems, coins, points or any currency of the studio's own; subscriptions and paid services (later
47
+ versions); a cap over 5000 or a refund window under 14 days. A studio whose studio.json says
48
+ `"audience": "kids"` sells nothing at all.
49
+
50
+ ## Who may buy
51
+
52
+ The Worker decides, for every item and every player (`worker/shop-rules.mjs` `wayFor`):
53
+
54
+ | The player | What happens |
55
+ |---|---|
56
+ | on a kids server, or in a kids studio | no shop at all: nothing listed, `shop.open()` says nothing is sold here |
57
+ | a guest (no passkey) | "make an account first" (a purchase would be lost with a cookie) |
58
+ | 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 |
59
+ | an account that said under 13 | nothing, ever; the account is treated as a kids account from then on |
60
+ | 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 |
61
+ | an adult | Stripe's own hosted Checkout, one item at a time, with the real-money price and its own confirm |
62
+ | over the monthly cap | not until next month |
63
+
64
+ ## In a game
65
+
66
+ ```ts
67
+ import { createShop } from '@homie-rocks/studio/shop';
68
+ const shop = createShop();
69
+ await shop.ready; // { open, kids, screen }
70
+ if (shop.has('skin:ember')) useEmber(); // synchronous: fine in a render loop
71
+ shop.on('change', (owns) => redraw()); // a purchase landed, or a refund took one back
72
+ buyButton.onclick = () => shop.open('ember-skin'); // the store sheet (a code to scan on a TV)
73
+ shop.used('skin:ember'); // equipped: it leaves the player's own refund window
74
+ ```
75
+
76
+ Rules for the game: open the shop only from a button the player pressed, never from the play, start or wake
77
+ button, never on a timer; show prices in real money; never draw a countdown; never sell randomness; do nothing
78
+ differently for a supporter on a beginner server.
79
+
80
+ The play shell around the game answers these calls (the game never sees a card, an email, an order or a
81
+ session). The room button's sheet has a **Shop** button too. A purchase opens Stripe's page in a new tab, the game
82
+ keeps running, and the shell looks again when the tab comes back: `change` fires once it is paid.
83
+
84
+ **Badges in rooms.** A player whose account owns a `badge:` key carries it on their seat: `net.players()` gives
85
+ `peer.badge` ("Supporter"), set by the studio's Worker from what the account owns when the player connects (a
86
+ hello can never claim one). None on a kids server, none for an AI.
87
+
88
+ ## The money path
89
+
90
+ ```
91
+ phone (signed in) ─ POST /api/shop/buy ─► studio Worker ─ Checkout Session (the studio's restricted key) ─► Stripe
92
+ ◄──────────────── Stripe's hosted page (new tab): the player pays ─────────────────────────────────┘
93
+ Stripe ─ signed webhook ─► POST /api/shop/hook: order paid → entitlements → referral line (held)
94
+ homie.rocks: not involved at any step.
95
+ ```
96
+
97
+ - The order row is written before Stripe is called; the Checkout Session carries the order, the player and the
98
+ item in its metadata; the hook checks the session matches the order (player, price) before it grants anything.
99
+ - The webhook's `Stripe-Signature` is checked (HMAC-SHA256 of `<t>.<payload>` with the endpoint's `whsec_`
100
+ secret, within five minutes); each event id is handled once; a test event never touches a live shop.
101
+ - Paid: the entitlements are granted. Refunded: that one order's entitlements are revoked. A dispute: nothing
102
+ changes until it is decided; lost: that one item goes, as a refund would; won: it stays. **The account is never
103
+ locked or deleted over a dispute.**
104
+ - Receipts come from Stripe by email (the buyer types it on Stripe's page, never on the studio's). The account
105
+ page lists the player's orders and badges; `/api/player/export` includes them; deleting an account asks first
106
+ when it owns things, and keeps the order rows without the player.
107
+ - Each Checkout Session says beside the pay button that the item is delivered at once (the EU and UK withdrawal
108
+ acknowledgement) and that an unused item can still be refunded.
109
+
110
+ ## Setting it up (Claude does the commands; the owner only uses pages)
111
+
112
+ 1. `npx --no-install homie-studio shop init --supporter` (shop.json and SELLING.md), then `shop check`, commit,
113
+ `npm run deploy` (it applies migration `0008_studio_shop.sql`; the shop stays closed).
114
+ 2. In Stripe (the owner): a restricted key with **Checkout Sessions: Write, Charges: Write (refunds),
115
+ PaymentIntents: Read, Disputes: Read**, and a webhook endpoint at `https://<site>/api/shop/hook` for
116
+ `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`,
117
+ `checkout.session.expired`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`,
118
+ `charge.dispute.closed`.
119
+ 3. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes)
120
+ with those steps, both fields and the seller choice (the studio, or Stripe Managed Payments, with what each
121
+ costs and does). The key and the secret go straight to the Worker secrets `STRIPE_KEY` and
122
+ `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input. Test keys only, unless `--live`.
123
+ 4. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
124
+ refund it from `/_studio/office/shop`.
125
+
126
+ Never ask for a key in a chat, never put one in a file, an argument, a log or a commit.
127
+
128
+ ## The office
129
+
130
+ `/_studio/office/shop` (the owner's browser) and `/_studio/api/shop` (an office key): open or what is missing,
131
+ the last 30 days, every order with its Stripe page, Refund (one tap), disputes, payouts, balance and tax as links
132
+ to the studio's own Stripe Dashboard, a CSV for the accountant, and the referral books. From an office key a
133
+ refund (`POST /_studio/api/shop/refund`, `homie-studio shop refund <order>`) is always an ASK the owner confirms
134
+ with one tap; so is marking a referrer paid.
135
+
136
+ ## Referrals
137
+
138
+ - A link with `?via=<host>` (another studio, homie.rocks, anywhere) opened by a person whose browser has never
139
+ been to this studio leaves a signed cookie with the host and the time (only on a studio that pays referrals;
140
+ never a prefetch, a crawler, this site, or a server's page or room).
141
+ - A paid order by an account made after that arrival, within `windowDays`, writes a referral line in THIS
142
+ studio's D1, if the referrer's own `/.well-known/homie-studio.json` says it takes statements. Held for
143
+ `holdDays`; a refund or a lost dispute inside the hold voids it, after it a clawback nets off the next statement.
144
+ - `homie-studio shop statements [--send]` signs each referrer's statement with the studio's Ed25519 key (the
145
+ public half is in the manifest's `referrals.key`) and POSTs it to the referrer's `/api/referrals/statement`,
146
+ which checks the signature against the seller's own manifest. No player, name or email is in a statement.
147
+ - Paying stays outside Homie: the referrer invoices the seller from its own Stripe; the seller pays and marks it
148
+ paid in the office.
149
+
150
+ ## Testing without money
151
+
152
+ The kit's tests run the Worker against `stripe-mock` (Stripe's open-source API mock, which checks every parameter
153
+ against Stripe's own API description) and sign webhooks with a test secret. `STRIPE_API_BASE` (a loopback address
154
+ only) points a Worker at such a mock; it is never honoured for anything but 127.0.0.1 or localhost.
package/shop/shop.ts ADDED
@@ -0,0 +1,130 @@
1
+ /*
2
+ * shop.ts — what a player owns in a game on a Homie studio (@homie-rocks/studio/shop). SHOP.md beside it is the guide.
3
+ * =============================================================================
4
+ *
5
+ * import { createShop } from '@homie-rocks/studio/shop';
6
+ * const shop = createShop();
7
+ * await shop.ready;
8
+ * if (shop.has('skin:ember')) hull.material = ember; // what the player bought, by the key shop.json gives
9
+ * shop.on('change', () => redrawSkins()); // a purchase landed (or a refund took one back)
10
+ * button.onclick = () => shop.open('ember-skin'); // the store sheet; on a TV, a code to buy on a phone
11
+ * shop.used('skin:ember'); // equipped: it is no longer refundable by the player
12
+ *
13
+ * WHERE IT COMES FROM. The game runs in a sandboxed frame that can see no cookie and send no request as the site. The
14
+ * play shell around it (on the studio's own origin) asks the studio's Worker what the signed-in player owns and
15
+ * answers this module by postMessage, only for THIS game. The Worker decides: an item bought with the studio's own
16
+ * Stripe, paid (the signed webhook said so), not refunded, not ended. A game never sees a card, an email or an order.
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.
22
+ *
23
+ * Without a shell (a game opened as a file, a dev server, a studio with no shop) everything is empty and `open()` does
24
+ * nothing, so the same code runs everywhere. It has no imports and uses only erasable TypeScript.
25
+ * =============================================================================
26
+ */
27
+
28
+ export const SHOP_VERSION = 1;
29
+
30
+ export interface ShopState {
31
+ /** The studio sells something in this game, and this room may show it. */
32
+ open: boolean;
33
+ /** A kids server: nothing is sold or shown. */
34
+ kids: boolean;
35
+ /** This is the big screen: `open()` shows a code to buy on a phone. */
36
+ screen: boolean;
37
+ }
38
+ export type ShopEvent = 'change' | 'closed';
39
+
40
+ export interface ShopOptions {
41
+ /** Tests and custom shells: where messages go (default: the parent frame). */
42
+ target?: { postMessage: (m: unknown, origin: string) => void } | null;
43
+ /** How long to wait for the shell's first answer before treating the game as having no shop (ms). */
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ export interface Shop {
48
+ /** Resolves once the shell answered (or after `timeoutMs` with no shell: an empty, closed shop). */
49
+ readonly ready: Promise<ShopState>;
50
+ readonly state: ShopState;
51
+ /** Whether the player owns this entitlement key now ("skin:ember", "badge:supporter"). Synchronous, for a render loop. */
52
+ has(key: string): boolean;
53
+ /** Every key the player owns now. */
54
+ entitlements(): string[];
55
+ /** Ask the shell for a fresh answer (the shell also looks again by itself after a purchase). */
56
+ refresh(): Promise<string[]>;
57
+ /** Show the store sheet, at one item if given. Call it from a button the player pressed, never on a timer. */
58
+ open(item?: string): void;
59
+ close(): void;
60
+ /** The player used something (equipped it, played with it): it leaves the player's own refund window. */
61
+ used(key: string): Promise<boolean>;
62
+ on(ev: 'change', fn: (owns: string[]) => void): () => void;
63
+ on(ev: 'closed', fn: () => void): () => void;
64
+ }
65
+
66
+ type Msg = Record<string, unknown> & { t?: string; q?: number; ev?: string; ok?: boolean };
67
+ const KEY = /^[a-z0-9][a-z0-9_.:-]{0,63}$/;
68
+
69
+ export function createShop(opts: ShopOptions = {}): Shop {
70
+ const g = globalThis as unknown as { HOMIE_NET?: { shop?: boolean }; parent?: unknown; addEventListener?: typeof addEventListener };
71
+ const inShell = Boolean(g.HOMIE_NET?.shop);
72
+ const target = opts.target !== undefined ? opts.target : (inShell && g.parent && g.parent !== g ? (g.parent as { postMessage: (m: unknown, o: string) => void }) : null);
73
+ const listeners = new Map<string, Set<(x: unknown) => void>>();
74
+ const emit = (ev: string, x: unknown): void => { for (const fn of listeners.get(ev) ?? []) { try { fn(x); } catch { /* the game's */ } } };
75
+ let owns: string[] = [];
76
+ const state: ShopState = { open: false, kids: false, screen: false };
77
+ let seq = 0;
78
+ const waiting = new Map<number, (m: Msg) => void>();
79
+ const send = (m: Record<string, unknown>): Promise<Msg> => {
80
+ if (!target) return Promise.resolve({ ok: false });
81
+ const q = ++seq;
82
+ return new Promise((resolve) => {
83
+ waiting.set(q, resolve);
84
+ try { target.postMessage({ t: 'homie-shop', q, ...m }, '*'); } catch { waiting.delete(q); resolve({ ok: false }); }
85
+ setTimeout(() => { if (waiting.has(q)) { waiting.delete(q); resolve({ ok: false, error: 'timeout' }); } }, 8000);
86
+ });
87
+ };
88
+ const setOwns = (next: unknown): boolean => {
89
+ const list = Array.isArray(next) ? next.map(String).filter((k) => KEY.test(k)) : [];
90
+ const before = owns.slice().sort().join(',');
91
+ owns = list;
92
+ return list.slice().sort().join(',') !== before;
93
+ };
94
+ if (target && g.addEventListener) {
95
+ g.addEventListener('message', (e: MessageEvent) => {
96
+ const m = e.data as Msg;
97
+ if (!m || typeof m !== 'object' || m.t !== 'homie-shop') return;
98
+ if (typeof m.q === 'number' && waiting.has(m.q)) { const r = waiting.get(m.q)!; waiting.delete(m.q); r(m); return; }
99
+ if (m.ev === 'owns') { if (setOwns(m.owns)) emit('change', owns.slice()); }
100
+ if (m.ev === 'closed') emit('closed', undefined);
101
+ });
102
+ }
103
+ const ready: Promise<ShopState> = target
104
+ ? Promise.race([
105
+ send({ op: 'hello', v: SHOP_VERSION }).then((m) => {
106
+ state.open = Boolean(m.ok && m.open); state.kids = Boolean(m.kids); state.screen = Boolean(m.screen);
107
+ if (setOwns(m.owns)) emit('change', owns.slice());
108
+ return { ...state };
109
+ }),
110
+ new Promise<ShopState>((r) => setTimeout(() => r({ ...state }), opts.timeoutMs ?? 4000)),
111
+ ])
112
+ : Promise.resolve({ ...state });
113
+
114
+ const shop: Shop = {
115
+ ready,
116
+ get state() { return { ...state }; },
117
+ has: (key) => owns.includes(String(key)),
118
+ entitlements: () => owns.slice(),
119
+ refresh: async () => { const m = await send({ op: 'owns' }); if (m.ok && setOwns(m.owns)) emit('change', owns.slice()); return owns.slice(); },
120
+ open: (item) => { void send({ op: 'open', ...(typeof item === 'string' ? { item } : {}) }); },
121
+ close: () => { void send({ op: 'close' }); },
122
+ used: async (key) => (KEY.test(String(key)) && owns.includes(String(key)) ? Boolean((await send({ op: 'used', key })).ok) : false),
123
+ on: ((ev: ShopEvent, fn: (x: unknown) => void) => {
124
+ if (!listeners.has(ev)) listeners.set(ev, new Set());
125
+ listeners.get(ev)!.add(fn);
126
+ return () => { listeners.get(ev)?.delete(fn); };
127
+ }) as Shop['on'],
128
+ };
129
+ return shop;
130
+ }
package/site/SITE.md CHANGED
@@ -266,6 +266,17 @@ everything. The play page of such a game carries the saves bridge and a "who is
266
266
  and shows a passkey sheet over the game when the game asks (`saves.signIn()`); its landing links the account page.
267
267
  `/account/` belongs to the site: `site/pages/account/` and `site/public/account/` are refused.
268
268
 
269
+ ## The shop: `/shop/` (0.24.0)
270
+
271
+ A studio with a `shop.json` ([../shop/SHOP.md](../shop/SHOP.md)) has `/shop/` (its items in real money, sign in, the
272
+ one neutral age question, Buy on Stripe's own page, or a link for a parent), `/shop/thanks`, `/shop/refunds/` (the
273
+ refund policy every seller publishes) and `/shop/parent/<link>` (what a parent opens). The account page lists the
274
+ player's purchases and badges, with the self-serve refund of an unused item. A play page whose game the studio
275
+ sells something for carries the store sheet (the game's `shop.open()`, and Shop in the room sheet); on a kids server
276
+ 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
277
+ shop (a `site/pages/shop/` is not served); without one it is the studio's own. The directory manifest says only
278
+ whether the shop is open and its till.
279
+
269
280
  ## The arcade knock
270
281
 
271
282
  A game made with Homie's arcade controls asks its page's origin for a Homie box (`GET /__homie/call`), and a score
@@ -160,6 +160,10 @@ export function accountPage(cat, { origin = '', next = '/account/', owner = null
160
160
  <label for="acct-email">Email</label><input id="acct-email" data-email type="email" autocomplete="email">
161
161
  <div class="row"><button class="ghost" type="button" data-email-save>Send a confirmation</button><button class="ghost" type="button" data-email-remove hidden>Remove it</button></div>
162
162
  </div>` : ''}
163
+ <div class="card shop-card" data-shop-card hidden>
164
+ <h2>Your purchases</h2>
165
+ <div data-shop-body></div>
166
+ </div>
163
167
  <div class="card" data-servers-card hidden>
164
168
  <h2>Your servers</h2>
165
169
  <p>The servers you belong to in this studio's games. Your home server is where Play takes you.</p>
@@ -193,8 +197,9 @@ ${back}
193
197
  </details>
194
198
  </section>
195
199
  <script type="application/json" id="account-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
196
- <script src="/_homie/account.js" defer></script>`;
197
- return layout(cat, { title: `Your account · ${studio}`, description: `Your player account on ${studio}.`, origin, path: '/account/', page: 'account', head: `<style>${ACCOUNT_CSS}</style><meta name="robots" content="noindex">`, main });
200
+ <script src="/_homie/account.js" defer></script>
201
+ <script src="/_homie/shop.js" defer></script>`;
202
+ return layout(cat, { title: `Your account · ${studio}`, description: `Your player account on ${studio}.`, origin, path: '/account/', page: 'account', head: `<style>${ACCOUNT_CSS}.shop-card .badge{display:inline-block;padding:3px 10px;border-radius:999px;background:var(--hot,#ffcf5a);color:var(--hot-ink,#0b0b10);font:800 12px/1.3 ui-sans-serif,system-ui,sans-serif;margin-right:6px}.shop-card ul{margin:8px 0 0;padding-left:18px}.shop-card li{margin:4px 0}</style><meta name="robots" content="noindex">`, main });
198
203
  }
199
204
 
200
205
  /** /_homie/account.js */
@@ -343,6 +348,13 @@ export const ACCOUNT_JS = `${PASSKEY_JS}
343
348
  var typed = prompt('This deletes your account and everything it kept on ' + boot.studio + ', in every game. It cannot be undone. Type DELETE to go ahead.');
344
349
  if (!typed || typed.trim().toUpperCase() !== 'DELETE') { say('Nothing was deleted.'); return; }
345
350
  P.api('POST', '/api/player/delete', { confirm: 'delete' }).then(function (r) {
351
+ if (!r.ok && r.error === 'purchases') {
352
+ var again = prompt(r.message + ' Type DELETE again to give them up and delete the account.');
353
+ if (!again || again.trim().toUpperCase() !== 'DELETE') return { ok: false, message: 'Nothing was deleted.' };
354
+ return P.api('POST', '/api/player/delete', { confirm: 'delete', purchases: 'forfeit' });
355
+ }
356
+ return r;
357
+ }).then(function (r) {
346
358
  if (!r.ok) throw new Error(r.message);
347
359
  me = null; P.remember(null); render();
348
360
  say('Your account and everything it kept are deleted. You can also remove the passkey from your device\\'s passwords.', 'good');