@indiegems/gem-web-sdk 18.1.0 → 19.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.
@@ -40,7 +40,8 @@ itself. You get an outcome.
40
40
  - [8. The Arcade Store's offers](#8-the-arcade-stores-offers)
41
41
  - [9. Buying from the Arcade Store](#9-buying-from-the-arcade-store)
42
42
  - [10. Letting the Arcade show one offer](#10-letting-the-arcade-show-one-offer)
43
- - [11. Outcomes and reasons](#11-outcomes-and-reasons)
43
+ - [11. What the player holds, and a VIP door](#11-what-the-player-holds-and-a-vip-door)
44
+ - [12. Outcomes and reasons](#12-outcomes-and-reasons)
44
45
 
45
46
  ## 1. Two surfaces, and which one is real money
46
47
 
@@ -67,7 +68,7 @@ three and never grows:
67
68
 
68
69
  `reason` is a short code that says why. The set is open: a new Arcade build may send a reason your
69
70
  game has never seen, so branch on `outcome` and treat `reason` as a hint. The codes in use today are
70
- in [§11](#11-outcomes-and-reasons).
71
+ in [§12](#12-outcomes-and-reasons).
71
72
 
72
73
  ## 3. What is available here
73
74
 
@@ -316,9 +317,9 @@ Pick by who can say what the offer is:
316
317
  - **Do not show a price on the button.** The sheet shows the right one for this player, which a
317
318
  sale or a pass can change, and `priceDisplay` does not say either of those. If you want a price on
318
319
  screen, draw the offer yourself and use `purchase`.
319
- - **You cannot tell whether the player holds a pass.** The Arcade keeps that to itself, and the
320
- sheet tells the player. So the button stays up for a holder, and that is fine: the sheet is what
321
- they see, and they choose from there.
320
+ - **The sheet does not tell you what the player holds.** It tells the player, so a button that
321
+ stays up for a holder is fine: they see what they already have, and choose from there. To change
322
+ your own screen for a holder, read `gem.me.passes` ([§11](#11-what-the-player-holds-and-a-vip-door)).
322
323
  - **Hide the button when the SKU is not in `offers()`.** A SKU that is not on sale here answers
323
324
  `unavailable` / `not_found`, and the player would have pressed a button that does nothing.
324
325
  - **Read `showOfferAvailable`, not `available`.** An older Arcade can sell through `purchase` and
@@ -332,7 +333,149 @@ closed checkout, shared with `purchase`: the next ask of either kind is `decline
332
333
  while. The press shows nothing during a back-off, so give the player something else to do on that
333
334
  screen.
334
335
 
335
- ## 11. Outcomes and reasons
336
+ ## 11. What the player holds, and a VIP door
337
+
338
+ ```js
339
+ const passes = await gem.me.passes.list();
340
+ // [{ pass: 'vip', held: true, endsAt: '2026-11-29T12:34:56Z' }]
341
+
342
+ const vip = await gem.me.passes.get('vip'); // one pass by its slug
343
+ if (vip.held) drawVipBadge();
344
+ ```
345
+
346
+ `gem.me.passes` says which store passes the player holds. **VIP is the pass `vip`**, its offering
347
+ slug. Key on the slug, which is stable and not localized, and never on a display name.
348
+
349
+ - **It lists every pass games may see, held or not.** An admin marks a pass as visible to games, and
350
+ the list has one entry for each, so its shape never says what the player bought. A pass that is not
351
+ marked is left out, and `get` reads a pass that is not listed as not held.
352
+ - **A pass is a slug, whether it is held, and when it ends.** No price, no name, no perks, nothing
353
+ about how the player got it, and nothing about a pass that has ended.
354
+ - **`endsAt` is the exact end,** grace and any queued period included, and `null` when the pass is not
355
+ held or is held with no end, which support can grant. Read `held` first: `null` alone never means
356
+ ended.
357
+ - **A guest reads as not holding.** Every pass is `held: false`, and that is no error.
358
+ - **A read that fails rejects with a `GemApiError`, as every `gem.me` read does.** That is unknown,
359
+ not "not held", so keep what you drew. It is the one call on this page that rejects.
360
+ - **It is a read over your play token,** not an ask of the Arcade. It needs no press, and works
361
+ wherever your game has a token, including where the store is off: holding a pass is not buying one.
362
+ Each call goes to the platform, so read when you draw and do not poll.
363
+
364
+ ### The browser draws, the server decides
365
+
366
+ **Never decide anything from `gem.me.passes`.** It runs in the player's browser, and a player can
367
+ make any answer come back. It is for drawing the door. What is behind the door comes from your
368
+ function, which asks the platform and answers with the contents. A level that ships in your build is
369
+ not behind a door at all, because a player can open it by editing the page.
370
+
371
+ ```js
372
+ // 1. DRAWING. Open or shut. Nothing here unlocks anything.
373
+ function drawVipDoor(passes) {
374
+ door.drawOpen(passes.some((p) => p.pass === 'vip' && p.held));
375
+ }
376
+
377
+ await gem.awaitBridge();
378
+ try {
379
+ drawVipDoor(await gem.me.passes.list());
380
+ } catch {
381
+ door.drawShut(); // unknown: the press below still asks the function
382
+ }
383
+ gem.me.passes.on('changed', drawVipDoor); // opens after a purchase, locks when VIP ends
384
+
385
+ // 2. DECIDING. The press asks the function. The browser's reading is not part of the call.
386
+ door.onPress = async () => {
387
+ let answer;
388
+ try {
389
+ answer = (await gem.functions.invoke({ door: 'vip-lounge' })).json();
390
+ } catch {
391
+ showDoorIsStuck(); // nothing decided: not "you need VIP"
392
+ return;
393
+ }
394
+ if (answer.allowed) enterLounge(answer.lounge);
395
+ else showHowToGetVip();
396
+ };
397
+
398
+ // 3. BUYING. From the press on your own "How to get VIP" button.
399
+ howToGetVipButton.onclick = () => gem.store.showOffer(VIP_SKU);
400
+ ```
401
+
402
+ The function, in Rust, because only the platform's answer counts:
403
+
404
+ ```rust
405
+ use gem::functions::host::{self, ApiRequest, Caller};
406
+
407
+ // The input says which door. It never says who is asking, or what they hold.
408
+ let context = host::get_context();
409
+ let Caller::Player(_) = context.caller else {
410
+ return Err("expected a player's call".into());
411
+ };
412
+ let read = host::call_api(&ApiRequest {
413
+ method: "GET".into(),
414
+ path: format!("/v1/games/{}/players/me/passes", context.game_id),
415
+ body: Vec::new(),
416
+ })?;
417
+ if read.status != 200 {
418
+ return Err(format!("the pass read answered {}", read.status)); // decides nothing
419
+ }
420
+ let body: serde_json::Value = serde_json::from_slice(&read.body).map_err(|e| e.to_string())?;
421
+ let vip = body["passes"]
422
+ .as_array()
423
+ .into_iter()
424
+ .flatten()
425
+ .any(|pass| pass["pass"] == "vip" && pass["held"] == true);
426
+ let answer = if vip {
427
+ serde_json::json!({ "allowed": true, "lounge": lounge_contents() })
428
+ } else {
429
+ serde_json::json!({ "allowed": false })
430
+ };
431
+ Ok(answer.to_string().into_bytes())
432
+ ```
433
+
434
+ "Me" on that route is the player who invoked the function. A function your server invoked has no
435
+ player, and the route refuses it with `403`. The body is `{ pass, held, ends_at }` for each pass.
436
+
437
+ - **If the door and the function disagree, the function is right.** The player sees "How to get VIP"
438
+ or the lounge, and either way nobody got in by editing the page. Redraw the door when the function
439
+ says no.
440
+ - **A guest has no VIP and cannot buy it from here.** Their door is shut, and `offers()` answers
441
+ `unavailable` / `sign_in_required`, so offer `gem.account.requestSignIn` instead of the button.
442
+
443
+ ### When the door moves by itself
444
+
445
+ While something listens to `on('changed')`, the SDK reads again at each of these, and calls you only
446
+ if the answer differs:
447
+
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 |
456
+
457
+ A read that fails changes nothing, and is tried again later. A read that was already on its way when
458
+ a purchase ended is never reused, so a stale answer cannot shut the door on the buyer. If you read
459
+ without a listener, call `list()` yourself at those moments; it is always fresh.
460
+
461
+ `on` never fires in a function, which has none of those moments.
462
+
463
+ ### On a dedicated server
464
+
465
+ The platform has a read for this,
466
+ `GET /v1/games/{game_id}/matches/{match_id}/players/{player_id}/passes`, with the same body, for a
467
+ player in your live match. **A server bundle cannot call it yet**, and the SDK has no method for it,
468
+ for two reasons:
469
+
470
+ - **It needs the allocation nonce.** The route requires `X-Alloc-Nonce`, which the platform's runtime
471
+ holds and never hands to your bundle. The boost read has the same limit. Reaching it takes a hook in
472
+ the runtime, like the one behind `gem.match.end()`.
473
+ - **No game server is allowed to yet.** It answers `403` `client_permission_denied` to every caller
474
+ until the platform grants game servers `passes.read`, which ships after the route itself.
475
+
476
+ Until both land, decide in a function, or leave a VIP door out of a dedicated-server match.
477
+
478
+ ## 12. Outcomes and reasons
336
479
 
337
480
  | Call | `ok` | `declined` | `unavailable` |
338
481
  | ---------------- | ------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
package/docs/changelog.md CHANGED
@@ -16,6 +16,24 @@ 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
+ ## 19.0.0
20
+
21
+ Upgrading from 18.1.1. Impact: required.
22
+
23
+ ### Rebuild
24
+
25
+ - The player scope on the game object is now typed as PlayerScope instead of EntityScope. Recompile your game. If you annotated that value, or passed it to code that expects EntityScope, change the annotation to PlayerScope. Otherwise nothing else needs to change.
26
+
27
+ ### New capability
28
+
29
+ - A game can now read which passes its player holds. Every pass the Arcade marks as visible to games is reported, held or not, with the time it ends. The read is exposed through PassesApi on PlayerScope, and it is not available on EntityScope because a character holds no pass. The result only tells you who holds a pass; your game still decides what the pass unlocks. PassesEvent lets you listen for changes, and the SDK re-reads after a store purchase completes, on resume, after a sign-in, and when a held pass ends, so a buyer's doors open without a reload. GamePass describes one entry in the result.
30
+
31
+ ## 18.1.1
32
+
33
+ Upgrading from 18.1.0. Impact: none.
34
+
35
+ Nothing in this release asks anything of a game.
36
+
19
37
  ## 18.1.0
20
38
 
21
39
  Upgrading from 18.0.0. Impact: recommended.
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "18.1.0"
53
+ "@indiegems/gem-web-sdk": "19.0.0"
54
54
  }
55
55
  ```
56
56
 
@@ -18,8 +18,8 @@
18
18
  },
19
19
  {
20
20
  "file": "arcade-and-store.md",
21
- "sha256": "7320826529435d290159c8b0707f69819124607d1b38160dbc85f63b385fdea9",
22
- "updated": "2026-10-30T13:36:37Z"
21
+ "sha256": "f5c2d57fbd28f576a6653167f0e50d12b6e4269c5f55f6020fa40d7fe2f4f3f1",
22
+ "updated": "2026-10-30T21:39:33Z"
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": "75530d611c02d513b1789d787e83a219392cfa92bfa70b1995aaa46bd233fbbd",
52
- "updated": "2026-10-30T18:08:09Z"
51
+ "sha256": "fb27142771fc12eda3370d5d2cd5128a3ed03adaa947f674c8d35e781edb55d1",
52
+ "updated": "2026-10-31T03:13:57Z"
53
53
  },
54
54
  {
55
55
  "file": "daily-challenges.md",
@@ -78,8 +78,8 @@
78
78
  },
79
79
  {
80
80
  "file": "getting-started.md",
81
- "sha256": "cefcb0179415e448ac16b5e7e23be35c48745aa1b4ac5e23e7375f58b3c8890c",
82
- "updated": "2026-10-30T18:08:09Z"
81
+ "sha256": "271469a08140655896a5127f987c183dca5bff617357645ce7dedae68610a9b2",
82
+ "updated": "2026-10-31T03:13:57Z"
83
83
  },
84
84
  {
85
85
  "file": "inventory-and-catalogs.md",
@@ -138,8 +138,8 @@
138
138
  },
139
139
  {
140
140
  "file": "server-bundles.md",
141
- "sha256": "a5bdf1ca63a3c5f9f3ca8b123cc569135779960934c74176453f2596a22da7f4",
142
- "updated": "2026-10-30T18:08:09Z"
141
+ "sha256": "1a71596efb1eca4783929912ba503d63cdb7d7e24346e836d55abcfaf751b347",
142
+ "updated": "2026-10-31T03:13:57Z"
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/v18.1.0/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v18.1.0/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v19.0.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v19.0.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -829,6 +829,12 @@ The short version: the browser half is what a player's **session** can do, and t
829
829
  what a **match** can do. `gem.rooms.join()` has no meaning to a process that exists because a match
830
830
  was already allocated.
831
831
 
832
+ **`gem.me.passes` is browser and function only**, and that is a limit rather than a principle. The
833
+ platform has a match read of which passes a player in your match holds, but your bundle cannot call
834
+ it: it requires the allocation nonce, which the runtime never hands to a bundle, as the boost read
835
+ does, and it answers `403` to every caller until game servers are granted `passes.read`.
836
+ [arcade-and-store.md](arcade-and-store.md#on-a-dedicated-server) has what to do instead.
837
+
832
838
  **Rooms are the case where those two answers differ**, so they are worth stating separately rather
833
839
  than filed under "browser-only". `gem.rooms` — join, leave, ready up, quick-join — is a player
834
840
  acting on their own membership, and your server has none. Reading and administering the room your
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "18.1.0",
3
+ "version": "19.0.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",