@homie-rocks/studio 0.32.1 → 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.
- package/CHANGELOG.md +57 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +50 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- 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
|
-
"
|
|
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
|
|
34
|
-
| `refundDays` |
|
|
35
|
-
| `capPerPlayerMonth` |
|
|
36
|
-
| `items[].kind` |
|
|
37
|
-
| `items[].price` | whole
|
|
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` |
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
|
64
|
-
|
|
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'); //
|
|
114
|
+
shop.used('skin:ember'); // marks it used; the studio refund policy applies
|
|
76
115
|
```
|
|
77
116
|
|
|
78
|
-
|
|
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).
|
|
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
|
|
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
|
|
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=...¤cy=...&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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
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 = /^[
|
|
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()
|
|
88
|
+
const before = JSON.stringify(owns.slice().sort());
|
|
91
89
|
owns = list;
|
|
92
|
-
return list.slice().sort()
|
|
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
|
|
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
|
+
});
|