@indiegems/gem-web-sdk 19.0.2 → 20.0.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.
@@ -33,7 +33,7 @@ itself. You get an outcome.
33
33
  - [1. Two surfaces, and which one is real money](#1-two-surfaces-and-which-one-is-real-money)
34
34
  - [2. Nothing rejects](#2-nothing-rejects)
35
35
  - [3. What is available here](#3-what-is-available-here)
36
- - [4. Call the acting ones from a press](#4-call-the-acting-ones-from-a-press)
36
+ - [4. Leaving takes a press; buying does not](#4-leaving-takes-a-press-buying-does-not)
37
37
  - [5. App links](#5-app-links)
38
38
  - [6. Opening another game](#6-opening-another-game)
39
39
  - [7. Similar games, as cards](#7-similar-games-as-cards)
@@ -88,20 +88,25 @@ the bridge is up. A feature can be off for reasons you
88
88
  cannot see from inside the frame: a TV, an in-app browser that cannot take payment, an app build
89
89
  where it is withheld. Do not guess; read it.
90
90
 
91
- ## 4. Call the acting ones from a press
91
+ ## 4. Leaving takes a press; buying does not
92
92
 
93
93
  Four calls put something in front of the player: `gem.arcade.openApp`, `gem.arcade.openGame`,
94
- `gem.store.purchase` and `gem.store.showOffer`. **Call each one from the player's own press handler**, in the same turn as the
95
- press. The Arcade checks that a person pressed, and refuses an ask nobody pressed for with
96
- `declined` / `busy`, showing nothing.
97
-
94
+ `gem.store.purchase` and `gem.store.showOffer`.
95
+
96
+ - **Leaving the game takes a press.** Call `openApp` and `openGame` from the player's own press
97
+ handler, in the same turn as the press. The Arcade checks that a person pressed, and refuses an
98
+ ask nobody pressed for with `declined` / `busy`, showing nothing. Never on a timer, never on
99
+ load, never in a retry loop.
100
+ - **Buying does not.** `gem.store.purchase` and `gem.store.showOffer` open their checkout or sheet
101
+ after an `await`, from a timer, or where the browser cannot report a press: the checkout is where
102
+ the player confirms, and your game cannot press it. Nothing backs off either, so the player's
103
+ next press after closing a checkout opens it again. Still ask only because the player chose to
104
+ buy.
98
105
  - **Controllers count.** A gamepad press the Arcade sees counts as a press. Do not add a keyboard or
99
106
  pointer workaround for pad players.
100
107
  - **One at a time.** Only one of these is open at once, across both surfaces and the Arcade's other
101
108
  sheets (a Gem purchase, a share, a friend challenge). A second ask while one is open is
102
109
  `declined` / `busy`. The SDK answers it for you without sending.
103
- - **Never on a timer, never on load, never in a retry loop.** None of them would have a press, so
104
- all of them would be refused.
105
110
 
106
111
  The reads (`appLinks`, `similarGames`, `offers`) need no press. A second call while one is open
107
112
  shares the first one's answer.
@@ -250,9 +255,10 @@ first: the payment provider's page on the web, the app store's own purchase shee
250
255
  apps. That checkout is where the player confirms and pays. Your game is resumed before the answer
251
256
  arrives, so your own celebration is visible.
252
257
 
253
- - **Call it from the press** ([§4](#4-call-the-acting-ones-from-a-press)). The player's press on
254
- your Buy button is what opens checkout, so make that button say plainly what it buys and for how
255
- much (`priceDisplay`).
258
+ - **Call it when the player chooses to buy** ([§4](#4-leaving-takes-a-press-buying-does-not)).
259
+ No press is needed — an `await` before it is fine — but the player's press on your Buy button is
260
+ what they expect to open checkout, so make that button say plainly what it buys and for how much
261
+ (`priceDisplay`).
256
262
  - **It has no timeout.** A payment takes as long as it takes, so the call waits for as long as the
257
263
  Arcade is connected.
258
264
  - `ok`: the player paid. **Read balances and inventory through `gem.me`**; the answer carries no
@@ -266,10 +272,11 @@ arrives, so your own celebration is visible.
266
272
  ```
267
273
 
268
274
  - `declined` / `cancelled`: the player closed the checkout without paying. Return to your screen
269
- quietly and **do not ask again unprompted**: the Arcade backs off after the player closes one, and
270
- the next ask is `declined` / `busy` for a while, longer each time. An offer the player cannot buy
271
- again yet also answers `declined` / `cancelled`: the Arcade tells the player when it can be
272
- bought, opens nothing, and does not back off.
275
+ quietly and **do not ask again unprompted**. Nothing backs off: the player's next press opens the
276
+ checkout again, however many times they closed it. An offer the player cannot buy again yet also
277
+ answers `declined` / `cancelled`: the Arcade tells the player when it can be bought, and opens
278
+ nothing.
279
+ - `declined` / `busy`: another checkout or Arcade sheet was already open. Nothing was shown.
273
280
  - `unavailable` with `store_off`, `sign_in_required` or `not_found` (a SKU that is not in
274
281
  `offers()` right now), or with a payment provider's own failure code: nothing was bought. The
275
282
  first three show nothing.
@@ -304,13 +311,13 @@ pass the player already holds, how long it has left. The sheet's Buy opens the s
304
311
 
305
312
  Pick by who can say what the offer is:
306
313
 
307
- | | **You draw it: `offers()` + `purchase()`** | **The Arcade draws it: `showOffer()`** |
308
- | --------------- | --------------------------------------------------------- | ------------------------------------------------------------ |
309
- | Use it for | Gem packs: a name, an amount and a price say it all | VIP and other passes, and bundles |
310
- | Your button | says what it buys and shows `priceDisplay` | says what it opens, such as "Get VIP" |
311
- | The press opens | the checkout, straight away | the Arcade's sheet, then the checkout if the player presses Buy |
312
- | Perks and sales | not available to you | always current, in the player's language |
313
- | A holder sees | the same Buy button | what they already have |
314
+ | | **You draw it: `offers()` + `purchase()`** | **The Arcade draws it: `showOffer()`** |
315
+ | --------------- | --------------------------------------------------- | --------------------------------------------------------------- |
316
+ | Use it for | Gem packs: a name, an amount and a price say it all | VIP and other passes, and bundles |
317
+ | Your button | says what it buys and shows `priceDisplay` | says what it opens, such as "Get VIP" |
318
+ | The press opens | the checkout, straight away | the Arcade's sheet, then the checkout if the player presses Buy |
319
+ | Perks and sales | not available to you | always current, in the player's language |
320
+ | A holder sees | the same Buy button | what they already have |
314
321
 
315
322
  - **Never write an offer's perks into your game.** They change without a new build of your game, and
316
323
  yours would go stale. Your button only needs the offer's name.
@@ -328,10 +335,8 @@ Pick by who can say what the offer is:
328
335
 
329
336
  Every rule of `purchase` holds ([§9](#9-buying-from-the-arcade-store)), and the answers are the
330
337
  same, with one addition. `declined` / `cancelled` also covers the player leaving the sheet without
331
- pressing Buy, and you are not told which of the two they closed. That starts the same back-off as a
332
- closed checkout, shared with `purchase`: the next ask of either kind is `declined` / `busy` for a
333
- while. The press shows nothing during a back-off, so give the player something else to do on that
334
- screen.
338
+ pressing Buy, and you are not told which of the two they closed. Nothing backs off: the player's
339
+ next press on either button opens it again.
335
340
 
336
341
  ## 11. What the player holds, and a VIP door
337
342
 
@@ -445,14 +450,14 @@ player, and the route refuses it with `403`. The body is `{ pass, held, ends_at
445
450
  While something listens to `on('changed')`, the SDK reads again at each of these, and calls you only
446
451
  if the answer differs:
447
452
 
448
- | Moment | Why you cannot rely on something else |
449
- | --------------------------------------------------------- | -------------------------------------------------------------------------- |
450
- | `store.purchase` or `store.showOffer` answers `ok` | `onSettled` does not fire after a plain `ok` |
451
- | Either answers `unavailable` / `error` | The outcome is unknown, and the player may have paid |
452
- | `gem.store.onSettled` fires | `ok` / `pending` still reads as not held |
453
- | Your game resumes | A purchase in the Arcade's own store, from the Gem menu, sends no event at all |
454
- | The player signs in | A guest who signs in may hold VIP |
455
- | A held pass reaches `endsAt` | VIP does not renew, so the door has to lock by itself |
453
+ | Moment | Why you cannot rely on something else |
454
+ | -------------------------------------------------- | ------------------------------------------------------------------------------ |
455
+ | `store.purchase` or `store.showOffer` answers `ok` | `onSettled` does not fire after a plain `ok` |
456
+ | Either answers `unavailable` / `error` | The outcome is unknown, and the player may have paid |
457
+ | `gem.store.onSettled` fires | `ok` / `pending` still reads as not held |
458
+ | Your game resumes | A purchase in the Arcade's own store, from the Gem menu, sends no event at all |
459
+ | The player signs in | A guest who signs in may hold VIP |
460
+ | A held pass reaches `endsAt` | VIP does not renew, so the door has to lock by itself |
456
461
 
457
462
  A read that fails changes nothing, and is tried again later. A read that was already on its way when
458
463
  a purchase ended is never reused, so a stale answer cannot shut the door on the buyer. If you read
@@ -473,15 +478,15 @@ Until then, decide in a function, or leave a VIP door out of a dedicated-server
473
478
 
474
479
  ## 12. Outcomes and reasons
475
480
 
476
- | Call | `ok` | `declined` | `unavailable` |
477
- | ---------------- | ------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
478
- | `appLinks` | listings to draw | | `unsupported`, `error` |
479
- | `openApp` | opened, or `handed_off` | `busy` | `already_in_app`, `unsupported`, `error` |
480
- | `openGame` | the next game is starting | `busy`, `same_game` | `not_found`, `unsupported`, `error` |
481
- | `similarGames` | cards, possibly none | | `unsupported`, `error` |
482
- | `store.offers` | offers, possibly none | | `sign_in_required`, `store_off`, `unsupported`, `error` |
483
- | `store.purchase` | paid, or `pending` | `cancelled`, `busy` | `sign_in_required`, `store_off`, `not_found`, the provider's reason, `unsupported`, `error` (outcome unknown) |
484
- | `store.showOffer` | paid, or `pending` | `cancelled`, `busy` | `sign_in_required`, `store_off`, `not_found`, the provider's reason, `unsupported`, `error` (outcome unknown) |
481
+ | Call | `ok` | `declined` | `unavailable` |
482
+ | ----------------- | ------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
483
+ | `appLinks` | listings to draw | | `unsupported`, `error` |
484
+ | `openApp` | opened, or `handed_off` | `busy` | `already_in_app`, `unsupported`, `error` |
485
+ | `openGame` | the next game is starting | `busy`, `same_game` | `not_found`, `unsupported`, `error` |
486
+ | `similarGames` | cards, possibly none | | `unsupported`, `error` |
487
+ | `store.offers` | offers, possibly none | | `sign_in_required`, `store_off`, `unsupported`, `error` |
488
+ | `store.purchase` | paid, or `pending` | `cancelled`, `busy` | `sign_in_required`, `store_off`, `not_found`, the provider's reason, `unsupported`, `error` (outcome unknown) |
489
+ | `store.showOffer` | paid, or `pending` | `cancelled`, `busy` | `sign_in_required`, `store_off`, `not_found`, the provider's reason, `unsupported`, `error` (outcome unknown) |
485
490
 
486
491
  Every reason is a short code of lower-case letters and underscores. The Arcade turns a payment
487
492
  provider's own code into that shape before it reaches you.
package/docs/changelog.md CHANGED
@@ -16,6 +16,18 @@ Its entries are grouped under up to five headings:
16
16
  - **Rebuild**: a type changed. Recompile against the new version; code that compiles today keeps compiling unless the entry says otherwise.
17
17
  - **New capability**: something a game can now do. Nothing changes unless you use it.
18
18
 
19
+ ## 20.0.0
20
+
21
+ Upgrading from 19.0.2. Impact: required.
22
+
23
+ ### Must change
24
+
25
+ - The parent conformance version moved from 3 to 4. If your game, test setup or tooling checks against the conformance version or runs the parent conformance checks, update it to expect version 4. Checks written for version 3 will now fail where they expect a purchase to be refused without a player press, or expect purchases to be refused after a player dismissed a checkout or sheet.
26
+
27
+ ### May stop
28
+
29
+ - Purchases no longer need a player press. A purchase asked from a timer or after an await opens its checkout, and the checkout is the confirmation. Games can drop code that holds a purchase request back until a tap or click. Games can also drop code that waits out a pause after a player dismisses a checkout or sheet, because the next request opens it again. Two things are unchanged: leaving the game still needs a press, and a purchase asked while another surface is open is still declined as busy.
30
+
19
31
  ## 19.0.2
20
32
 
21
33
  Upgrading from 19.0.1. Impact: none.
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "19.0.2"
53
+ "@indiegems/gem-web-sdk": "20.0.0"
54
54
  }
55
55
  ```
56
56
 
@@ -591,7 +591,6 @@ everything under `platform_prices` may carry a price, and a currency under neith
591
591
  all. The grant rows also carry the platform's ceilings, and a game does not declare its own; a
592
592
  `max_per_window` on a mapping is ignored, with a warning.
593
593
 
594
-
595
594
  A browser-only game cannot award a platform currency without a server **or a serverless function**.
596
595
  That is the price of the platform never trusting a client. A game with no dedicated server writes
597
596
  the source progression from a function the player calls, on a progression that opts in with
@@ -657,10 +656,10 @@ subject. [brand-art.md](brand-art.md) has both, how to draw them, and the rest o
657
656
 
658
657
  Every game can choose between two purchase owners for the same published platform price:
659
658
 
660
- | Call | Who owns confirmation | Transport | Result |
661
- | --- | --- | --- | --- |
662
- | `gem.purchases.request(...)` | the platform | postMessage bridge; platform sheet always opens | `ok`, `declined`, or `unavailable` |
663
- | `gem.me.mutations.purchase(...)` | your game | direct authenticated SDK request; no bridge or platform sheet | an order-style result or a `GemApiError` |
659
+ | Call | Who owns confirmation | Transport | Result |
660
+ | -------------------------------- | --------------------- | ------------------------------------------------------------- | ---------------------------------------- |
661
+ | `gem.purchases.request(...)` | the platform | postMessage bridge; platform sheet always opens | `ok`, `declined`, or `unavailable` |
662
+ | `gem.me.mutations.purchase(...)` | your game | direct authenticated SDK request; no bridge or platform sheet | an order-style result or a `GemApiError` |
664
663
 
665
664
  There is no approval step. Both paths are available to every game.
666
665
 
@@ -818,21 +817,21 @@ local lookup. The purchase catalog is keyed by ids. Recipes also require `catalo
818
817
  One vocabulary covers both paths: the sheet's `res.reason` and the direct path's `err.code` are
819
818
  the same strings.
820
819
 
821
- | Code | What happened | What to do |
822
- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
823
- | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS` or `GEMS`; the direct path's message lists what is active |
824
- | `currency_not_in_game_payable` | the currency exists but cannot pay for game items | price in a currency `gemctl platform config` lists under `platform_prices` |
825
- | `not_purchasable` | the target is not in the purchase catalog on this channel, or cannot be bought | send the id (not the slug); promote the config version that prices it |
826
- | `no_price_in_currency` | the target has no price in that currency | ask in the currency it is priced in, or add the price |
827
- | `busy` | a purchase is already in flight, a sheet is open, the arcade is backing off after a cancel, or no click | ask again only from a fresh player action |
828
- | `cancelled` | the player dismissed the sheet | nothing — return to the shop |
829
- | `insufficient_funds` | the player cannot afford it | say so in your shop's words; never draw a top-up screen |
830
- | `price_mismatch`, `config_version_stale` | the price or config changed since your catalog read; nothing was charged | re-read the catalog and re-render |
831
- | `platform_currency_unavailable` | the currency is not available to this purchase right now | show the store as unavailable |
832
- | `not_available` | the recipe is outside its availability window, or inactive; nothing was charged | hide or disable the row until its window opens |
833
- | `recipe_locked` | the recipe's prerequisites are not met; nothing was charged | show the lock from `recipeAvailability().lockReasons` |
834
- | `recipe_on_cooldown`, `recipe_max_uses_exceeded`, `item_max_stack_exceeded`, `recipe_costs_unmet`, `quantity_invalid` | the recipe or item refused this purchase before any charge | reflect it on the row; `recipeAvailability` has the detail |
835
- | `error` | anything else, or no reliable answer | one neutral "store unavailable"; after `unavailable`, re-read inventory |
820
+ | Code | What happened | What to do |
821
+ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
822
+ | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS` or `GEMS`; the direct path's message lists what is active |
823
+ | `currency_not_in_game_payable` | the currency exists but cannot pay for game items | price in a currency `gemctl platform config` lists under `platform_prices` |
824
+ | `not_purchasable` | the target is not in the purchase catalog on this channel, or cannot be bought | send the id (not the slug); promote the config version that prices it |
825
+ | `no_price_in_currency` | the target has no price in that currency | ask in the currency it is priced in, or add the price |
826
+ | `busy` | a purchase is already in flight, or a sheet is open | ask again only from a fresh player action |
827
+ | `cancelled` | the player dismissed the sheet | nothing — return to the shop |
828
+ | `insufficient_funds` | the player cannot afford it | say so in your shop's words; never draw a top-up screen |
829
+ | `price_mismatch`, `config_version_stale` | the price or config changed since your catalog read; nothing was charged | re-read the catalog and re-render |
830
+ | `platform_currency_unavailable` | the currency is not available to this purchase right now | show the store as unavailable |
831
+ | `not_available` | the recipe is outside its availability window, or inactive; nothing was charged | hide or disable the row until its window opens |
832
+ | `recipe_locked` | the recipe's prerequisites are not met; nothing was charged | show the lock from `recipeAvailability().lockReasons` |
833
+ | `recipe_on_cooldown`, `recipe_max_uses_exceeded`, `item_max_stack_exceeded`, `recipe_costs_unmet`, `quantity_invalid` | the recipe or item refused this purchase before any charge | reflect it on the row; `recipeAvailability` has the detail |
834
+ | `error` | anything else, or no reliable answer | one neutral "store unavailable"; after `unavailable`, re-read inventory |
836
835
 
837
836
  Reasons are for your shop's state and your logs. Branch money on `outcome` and on a re-read of
838
837
  inventory, never on a reason, and do not retry because of one.
@@ -964,7 +963,9 @@ const gems = allowances.find((a) => a.currencySlug === 'GEMS');
964
963
  // 0 or less once `resetsAt` has passed: the read is stale, so say nothing about when.
965
964
  const hours = gems?.resetsAt ? Math.ceil((gems.resetsAt - Date.now()) / 3_600_000) : 0;
966
965
  if (gems && gems.remaining > 0) {
967
- hud.setText(`You can still earn ${gems.remaining} Gems` + (hours > 0 ? `, more in ${hours}h` : ''));
966
+ hud.setText(
967
+ `You can still earn ${gems.remaining} Gems` + (hours > 0 ? `, more in ${hours}h` : ''),
968
+ );
968
969
  } else if (gems?.resetsAt) {
969
970
  // Spent for now: say when more arrives, never "0 left".
970
971
  hud.setText(hours > 0 ? `More Gems in ${hours}h` : 'More Gems soon');
@@ -1001,7 +1002,7 @@ end of the reported period when both of the platform's ceilings are spent.
1001
1002
 
1002
1003
  **Display only. Never gate gameplay or a grant on it.**
1003
1004
 
1004
- - ✅ `` hud.setText(`${gems.remaining} Gems left to earn today`) ``
1005
+ - ✅ ``hud.setText(`${gems.remaining} Gems left to earn today`)``
1005
1006
  - ❌ `if (gems.remaining > 0) awardDailyBonus()`
1006
1007
 
1007
1008
  Grant anyway: the platform trims every grant to what is left, and the receipt is the authority on
@@ -18,8 +18,8 @@
18
18
  },
19
19
  {
20
20
  "file": "arcade-and-store.md",
21
- "sha256": "aee6e00fa7e0f463cb09174a9afdffe10d66ed1036546e584b341e4c11bb1e27",
22
- "updated": "2026-10-31T19:43:30Z"
21
+ "sha256": "79bd7829d5a6577d3619d7fd831b74065df883e11eadd1b8153754d97a88474a",
22
+ "updated": "2026-11-02T18:42:37Z"
23
23
  },
24
24
  {
25
25
  "file": "arcade-avatars.md",
@@ -48,8 +48,8 @@
48
48
  },
49
49
  {
50
50
  "file": "changelog.md",
51
- "sha256": "f2139ca3d6d368b6ae42da9f8550f69036ba56dcb5e7076b16421728809ac111",
52
- "updated": "2026-11-02T18:36:05Z"
51
+ "sha256": "37f3bde69baa7c0280e7d8a58800c1509b3b61c76a684428832bf094f298478b",
52
+ "updated": "2026-11-02T19:11:02Z"
53
53
  },
54
54
  {
55
55
  "file": "daily-challenges.md",
@@ -78,13 +78,13 @@
78
78
  },
79
79
  {
80
80
  "file": "getting-started.md",
81
- "sha256": "6d1d9969076b1f44b900a9923d8e3a82ca0b93278c000a0dc3699d836f57a09c",
82
- "updated": "2026-11-02T18:36:05Z"
81
+ "sha256": "896f7d29d2017e5c42fec20386bce31eacf3f5143b7134c7607574b452c039bf",
82
+ "updated": "2026-11-02T19:11:02Z"
83
83
  },
84
84
  {
85
85
  "file": "inventory-and-catalogs.md",
86
- "sha256": "a4763fe2418c0e24639ca58c27f30ffc416f07528857402a6f9ab3fee147f4f6",
87
- "updated": "2026-11-01T21:49:36Z"
86
+ "sha256": "dab6e42f4ad5cc2a63c2adb11e85053f963cd3166d1c447394cd6f532bb6c006",
87
+ "updated": "2026-11-02T18:42:37Z"
88
88
  },
89
89
  {
90
90
  "file": "leaderboards.md",
@@ -138,8 +138,8 @@
138
138
  },
139
139
  {
140
140
  "file": "server-bundles.md",
141
- "sha256": "46bb5ff4bea22129482f314aad4ba6e89b81c853140fdb72dedb7a3bc7853a1b",
142
- "updated": "2026-11-02T18:36:05Z"
141
+ "sha256": "655a5835bf9d1c13997784de57aaf54566d72641bfe220da8a37903d5fad044c",
142
+ "updated": "2026-11-02T19:11:02Z"
143
143
  },
144
144
  {
145
145
  "file": "serverless-functions-js.md",
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
80
80
  also publishes on its download host, with the hash beside it:
81
81
 
82
82
  ```bash
83
- curl -fsSL -O https://downloads.gemarcade.com/sdk/v19.0.2/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v19.0.2/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v20.0.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v20.0.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "19.0.2",
3
+ "version": "20.0.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -3819,11 +3819,11 @@
3819
3819
  " balance a player paid for is the rest of this list, and these four are",
3820
3820
  " the ones the old argument stood in for:",
3821
3821
  "",
3822
- " - THE CONFIRMATION IS THE PARENT'S, and it costs a gesture. The sheet is",
3823
- " drawn in the parent's own chrome from platform records, and only on",
3824
- " user activation (below).",
3825
- " - A DISMISSAL BACKS OFF, and the confirm control is quiet after paint",
3826
- " (both below; the arcade holds the quiet period for 400 ms).",
3822
+ " - THE CONFIRMATION IS THE PARENT'S. The sheet is drawn in the",
3823
+ " parent's own chrome from platform records, and only the player's own",
3824
+ " press on its confirm control spends.",
3825
+ " - THE CONFIRM CONTROL IS QUIET AFTER PAINT (below; the arcade holds",
3826
+ " the quiet period for 400 ms).",
3827
3827
  " - A SHORTFALL IS NEVER A SALE. `insufficient_funds` gets no top-up, no",
3828
3828
  " upsell and no way into a store from THIS sheet. A game MAY open the",
3829
3829
  " arcade's store for one of the store's own SKUs -",
@@ -3842,18 +3842,14 @@
3842
3842
  " price, quantity and total distinctly so a 10x multiplier is never hidden",
3843
3843
  " behind one figure.",
3844
3844
  "",
3845
- " ONLY ON USER ACTIVATION. Render nothing, and answer `declined` with",
3846
- " reason `busy`, unless `navigator.userActivation.isActive` is true when",
3847
- " the ask arrives. A click or key press in the game frame activates the",
3848
- " parent page too, so a Buy button works and a timer does not. It is the",
3849
- " browser's own signal, so keyboard and assistive-technology activation",
3850
- " count. This is the control that makes `cancelled` (gem.purchaseResult)",
3851
- " safe to disclose: a prompt costs a real player's gesture.",
3852
- "",
3853
- " BACK OFF AFTER A CANCEL. After a `cancelled`, answer further asks from",
3854
- " that game `declined` / `busy` without rendering, for an interval that",
3855
- " grows with each consecutive cancel. Without it, 'ask, learn cancelled,",
3856
- " ask again' is a re-prompt loop a game can write in three lines.",
3845
+ " NO PRESS, AND NO BACK-OFF (owner decision).",
3846
+ " Arcade games are developed in-house and trusted, and a limit between",
3847
+ " a player and a purchase refused players who wanted to buy. So the",
3848
+ " parent renders the sheet for an ask from a timer or after an await as",
3849
+ " for one from a click - `navigator.userActivation` is not read - and a",
3850
+ " sheet the player closed never makes the next ask wait: it is answered",
3851
+ " `cancelled`, and the game's next ask opens a sheet. The sheet itself",
3852
+ " is the confirmation; a game cannot press it.",
3857
3853
  "",
3858
3854
  " ONE PROMPT AT A TIME, and not instantly actionable. A gem.requestPurchase",
3859
3855
  " arriving while a prompt is open is answered `declined` / `busy` without",
@@ -3960,9 +3956,9 @@
3960
3956
  "EVERY OBLIGATION ON `gem.requestPurchase` APPLIES HERE, UNCHANGED:",
3961
3957
  "payable currencies only, resolved exactly; price it yourself from",
3962
3958
  "platform records, under the player's session (the offer's price in",
3963
- "`currencySlug`); the confirmation is the parent's own sheet, on user",
3964
- "activation only; back off after a cancel; one prompt at a time, quiet",
3965
- "after paint; one purchase per ask; meter it in money, server-side;",
3959
+ "`currencySlug`); the confirmation is the parent's own sheet, with no",
3960
+ "press required and no back-off; one prompt at a time, quiet after",
3961
+ "paint; one purchase per ask; meter it in money, server-side;",
3966
3962
  "treat every field as untrusted text. A shortfall is never a sale.",
3967
3963
  "",
3968
3964
  "AND THREE OF ITS OWN:",
@@ -4056,13 +4052,14 @@
4056
4052
  "a game nothing it cannot already read.",
4057
4053
  "",
4058
4054
  "THE ONE ADDED DISCLOSURE IS `cancelled`: what the player chose in",
4059
- "platform UI. It is behavioural data, and it is what makes an instant",
4060
- "re-prompt loop cheap - ask, learn 'cancelled', ask again. It ships with",
4061
- "two parent-side controls, stated as obligations on gem.requestPurchase:",
4062
- "ONLY ON USER ACTIVATION (no prompt without a real gesture) and BACK OFF",
4063
- "AFTER A CANCEL (further asks answered `busy`, unrendered, for a growing",
4064
- "interval). A parent that cannot enforce both must not send `cancelled`;",
4065
- "it sends `error` in its place.",
4055
+ "platform UI. It first shipped behind two parent-side controls - user",
4056
+ "activation, and a back-off after a cancel - against a game that asks,",
4057
+ "learns 'cancelled' and asks again. Both were removed by owner decision:",
4058
+ "Arcade games are developed in-house and trusted,",
4059
+ "and the back-off refused players who wanted to buy. A parent sends",
4060
+ "`cancelled` when the player dismissed a sheet they could afford, and",
4061
+ "answers the game's next ask on its merits. A parent older than that",
4062
+ "decision may send `error` in its place; read both as 'not bought'.",
4066
4063
  "",
4067
4064
  "EVERY OUTCOME RESOLVES. Only a protocol failure rejects. A player tapping",
4068
4065
  "Cancel is an ordinary thing that happens in every shop, and rejecting there",
@@ -4098,7 +4095,7 @@
4098
4095
  "outcome": {
4099
4096
  "kind": "enum",
4100
4097
  "values": ["ok", "declined", "unavailable"],
4101
- "$comment": "THREE VALUES, AND THAT IS THE COMPLETE VOCABULARY OF WHAT TO DO. The WHY is `reason`, a bounded string, so the explanations can grow without this enum growing - which matters because `check-schema-freeze` treats a new enum value as breaking (an N-1 receiver refuses it, and a refused `declined` becomes a two-minute timeout and an `unavailable`). A game branches its money logic on this field and on a re-read of inventory, never on `reason`. THE TIMING CHANNEL: a refusal the parent reaches without rendering anything is separable from a human Cancel by latency. That used to be the gap the no-reason rule left open; with `reason` sent, it tells a game nothing `reason` and `gem.platform.currencies.get()` do not already say. What still matters is how fast a game can ask, and user activation plus the cancel back-off bound that at human speed."
4098
+ "$comment": "THREE VALUES, AND THAT IS THE COMPLETE VOCABULARY OF WHAT TO DO. The WHY is `reason`, a bounded string, so the explanations can grow without this enum growing - which matters because `check-schema-freeze` treats a new enum value as breaking (an N-1 receiver refuses it, and a refused `declined` becomes a two-minute timeout and an `unavailable`). A game branches its money logic on this field and on a re-read of inventory, never on `reason`. THE TIMING CHANNEL: a refusal the parent reaches without rendering anything is separable from a human Cancel by latency. That used to be the gap the no-reason rule left open; with `reason` sent, it tells a game nothing `reason` and `gem.platform.currencies.get()` do not already say. What still matters is how fast a game can ask: games are trusted, so nothing paces a game's asks beyond one prompt at a time and the rate limit."
4102
4099
  },
4103
4100
  "reason": {
4104
4101
  "kind": "string",
@@ -4138,9 +4135,8 @@
4138
4135
  " the target is not in the purchase catalog, declined not_purchasable",
4139
4136
  " or is not purchasable",
4140
4137
  " the target has no price in that currency declined no_price_in_currency",
4141
- " a sheet is already open, a purchase is in declined busy",
4142
- " flight, a cancel back-off is running, or",
4143
- " the ask arrived without user activation",
4138
+ " a sheet is already open, or a purchase is declined busy",
4139
+ " in flight",
4144
4140
  " the player dismissed a sheet they could declined cancelled",
4145
4141
  " afford",
4146
4142
  " a shortfall shown on the sheet, or gem-api declined insufficient_funds",
@@ -5013,8 +5009,9 @@
5013
5009
  "`appLinks` capability. Answered by `gem.openAppResult`.",
5014
5010
  "",
5015
5011
  "AN ACTING MESSAGE. It leaves the game, so the parent holds it to the",
5016
- "rules every acting message shares (gem.requestOpenGame and",
5017
- "gem.requestStorePurchase state them too):",
5012
+ "rules every acting message shares (gem.requestOpenGame states them",
5013
+ "too; a purchase - gem.requestStorePurchase, gem.requestPurchase -",
5014
+ "shares all of them but the press):",
5018
5015
  "",
5019
5016
  " A PERSON MUST HAVE PRESSED. Read `navigator.userActivation.isActive`",
5020
5017
  " synchronously on receipt, before any `await`, or accept a controller",
@@ -5507,10 +5504,12 @@
5507
5504
  "listing, takes money only on the payment provider's page or the",
5508
5505
  "operating system's purchase sheet, and tells the game the outcome.",
5509
5506
  "",
5510
- "AN ACTING MESSAGE, under every rule gem.requestOpenApp states: a press",
5511
- "the parent can see (a missing `userActivation` API fails closed), one",
5512
- "acting message at a time across all of them, both checked before",
5513
- "`skuId` is resolved, and the answer bound to this frame session. And:",
5507
+ "AN ACTING MESSAGE: one acting message at a time across all of them,",
5508
+ "checked before `skuId` is resolved, and the answer bound to this frame",
5509
+ "session. UNLIKE gem.requestOpenApp AND gem.requestOpenGame IT NEEDS NO",
5510
+ "PRESS (owner decision): a purchase asked from a",
5511
+ "timer or after an await opens its checkout as one asked from a click",
5512
+ "does, because the checkout is the confirmation. And:",
5514
5513
  "",
5515
5514
  " ONLY A SKU IT WOULD LIST. `skuId` must be one gem.storeOffersResult",
5516
5515
  " would list to this player right now; anything else is `unavailable`",
@@ -5528,14 +5527,13 @@
5528
5527
  " bought again yet, the parent says when, in its own chrome, and",
5529
5528
  " answers `declined` / `cancelled` - the answer a closed checkout gets,",
5530
5529
  " so the cooldown, which would reveal a recent purchase, never crosses",
5531
- " the bridge. It starts no back-off: the player turned nothing down.",
5530
+ " the bridge.",
5532
5531
  "",
5533
- " BACK OFF AFTER A DISMISSAL. `cancelled` is disclosed, so after the",
5534
- " player closes a checkout a game started, further asks from that game",
5535
- " are `declined` / `busy`, unrendered, for an interval that grows with",
5536
- " each dismissal (the Arcade: 30 s, 2 min, 10 min, then the rest of the",
5537
- " session). Only the player closing it starts the back-off: a failed",
5538
- " payment, `error` and `ok` / `pending` do not.",
5532
+ " NO BACK-OFF (owner decision). A player who",
5533
+ " closed a checkout and asks again gets the checkout again, however many",
5534
+ " times; a player who paid and buys the same SKU again gets a new",
5535
+ " checkout. Only one checkout is on screen at a time, so an ask while",
5536
+ " one is open is `busy` - the moment it ends, the next ask opens.",
5539
5537
  "",
5540
5538
  " RESUME, THEN ANSWER. The result is sent after the game has been told",
5541
5539
  " it is resumed, so the game's own celebration is visible.",
@@ -5560,7 +5558,7 @@
5560
5558
  },
5561
5559
  "rateLimit": {
5562
5560
  "perMinute": 12,
5563
- "$comment": "gem.requestPurchase's limit and its argument: it bounds a BUGGY game, not a hostile one. What bounds the player's experience is the activation check, the one-at-a-time rule and the back-off."
5561
+ "$comment": "gem.requestPurchase's limit and its argument: it bounds a BUGGY game, not a hostile one, at a rate no player buying by hand reaches. What bounds the player's experience is the one-at-a-time rule."
5564
5562
  }
5565
5563
  },
5566
5564
  {
@@ -5578,9 +5576,9 @@
5578
5576
  " the player left before it did): `gem.storePurchaseSettled`",
5579
5577
  " follows when it does, and a game re-reads on resume too.",
5580
5578
  " declined - nothing was bought: the player closed the checkout,",
5581
- " or the offer is cooling down (`cancelled`); or no",
5582
- " press, another acting message open or a back-off",
5583
- " running (`busy`).",
5579
+ " or the offer is cooling down (`cancelled`); or another",
5580
+ " acting message was open (`busy`). Either way the",
5581
+ " game's next ask is answered on its merits.",
5584
5582
  " unavailable - no sale the parent can vouch for. With `store_off`",
5585
5583
  " (the store withheld), `sign_in_required` (a guest),",
5586
5584
  " `not_found` (an unknown SKU) or the provider's own",
@@ -5659,18 +5657,17 @@
5659
5657
  "gem.requestStorePurchase opens.",
5660
5658
  "",
5661
5659
  "EVERY RULE OF gem.requestStorePurchase HOLDS, and it shares them rather",
5662
- "than having its own: an acting message (a press the parent can see, one",
5663
- "at a time across all of them, both checked before `skuId` is resolved,",
5664
- "the answer bound to this frame session); only a SKU the parent would",
5665
- "list now; an offer cooling down opens nothing and answers `declined` /",
5666
- "`cancelled`; the SAME back-off, so a game cannot escape one by switching",
5667
- "message; resume, then answer; evidence, not accounting.",
5660
+ "than having its own: an acting message (one at a time across all of",
5661
+ "them, checked before `skuId` is resolved, no press needed, the answer",
5662
+ "bound to this frame session); only a SKU the parent would list now; an",
5663
+ "offer cooling down opens nothing and answers `declined` / `cancelled`;",
5664
+ "no back-off; resume, then answer; evidence, not accounting.",
5668
5665
  "",
5669
5666
  "THE SHEET IS NOT A SECOND CONFIRMATION, and it does not change",
5670
5667
  "gem.requestStorePurchase's straight-to-checkout rule. It is the pitch;",
5671
5668
  "the checkout is still the confirmation, with its own pay control. The",
5672
5669
  "player leaving the sheet without pressing Buy is a dismissal, exactly",
5673
- "as closing a checkout is, and starts the back-off.",
5670
+ "as closing a checkout is.",
5674
5671
  "",
5675
5672
  "A PARENT THAT ADVERTISES `storeOfferSheet` ADVERTISES `store`: the",
5676
5673
  "game's SKU comes from gem.storeOffersResult, and a purchase that answers",
@@ -5693,7 +5690,7 @@
5693
5690
  },
5694
5691
  "rateLimit": {
5695
5692
  "perMinute": 12,
5696
- "$comment": "gem.requestStorePurchase's limit and its argument: it bounds a BUGGY game, not a hostile one. What bounds the player's experience is the activation check, the one-at-a-time rule and the back-off."
5693
+ "$comment": "gem.requestStorePurchase's limit and its argument: it bounds a BUGGY game, not a hostile one, at a rate no player buying by hand reaches. What bounds the player's experience is the one-at-a-time rule."
5697
5694
  }
5698
5695
  },
5699
5696
  {
@@ -5710,8 +5707,8 @@
5710
5707
  " gem.storePurchaseSettled follows when it does.",
5711
5708
  " declined - nothing was bought: the player left the sheet or closed",
5712
5709
  " the checkout, or the offer is cooling down (`cancelled`);",
5713
- " or no press, another acting message open or a back-off",
5714
- " running (`busy`).",
5710
+ " or another acting message was open (`busy`). Either way",
5711
+ " the game's next ask is answered on its merits.",
5715
5712
  " unavailable - no sale the parent can vouch for. With `store_off`,",
5716
5713
  " `sign_in_required`, `not_found` or the provider's own",
5717
5714
  " failure code, nothing was bought. With `error`, the",