@indiegems/gem-web-sdk 7.4.0 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +54 -0
  2. package/dist/gem-web-sdk.esm.js +1 -1
  3. package/dist/gem-web-sdk.game.js +1 -1
  4. package/dist/gem-web-sdk.min.js +1 -1
  5. package/dist/gem-web-sdk.sdk.js +1 -1
  6. package/dist/gem-web-sdk.server.js +1 -1
  7. package/docs/achievements.md +232 -0
  8. package/docs/analytics.md +271 -0
  9. package/docs/api/api-reference-game.md +15308 -0
  10. package/docs/api/api-reference-server.md +4397 -0
  11. package/docs/api/reference.json +24130 -0
  12. package/docs/arcade-economy.md +263 -0
  13. package/docs/async-opponents.md +396 -0
  14. package/docs/brand-art.md +334 -0
  15. package/docs/cross-promotion.md +137 -0
  16. package/docs/daily-challenges.md +530 -0
  17. package/docs/dedicated-server-flow.md +879 -0
  18. package/docs/deprecation-policy.md +124 -0
  19. package/docs/friends.md +233 -0
  20. package/docs/game-economy-tutorial.md +326 -0
  21. package/docs/getting-started.md +552 -0
  22. package/docs/inventory-and-catalogs.md +1035 -0
  23. package/docs/leaderboards.md +277 -0
  24. package/docs/manifest.json +135 -0
  25. package/docs/match-session.md +288 -0
  26. package/docs/p2p-and-parties.md +783 -0
  27. package/docs/platform-rules.md +372 -0
  28. package/docs/player-sign-in.md +89 -0
  29. package/docs/room-shapes.md +195 -0
  30. package/docs/rooms-and-matches.md +779 -0
  31. package/docs/saving-player-progress.md +457 -0
  32. package/docs/server-bundles.md +1041 -0
  33. package/docs/serverless-functions.md +748 -0
  34. package/docs/trading-and-gifting.md +98 -0
  35. package/docs/what-a-game-must-do.md +1231 -0
  36. package/package.json +7 -2
  37. package/schema/bridge.v1.json +5 -5
  38. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,326 @@
1
+ # Building a shop, step by step
2
+
3
+ This builds a working shop: a currency, an item, a purchase, and the screen a player buys from. It
4
+ is the safe version — the one where the server does the arithmetic — and it is written to be
5
+ followed in order.
6
+
7
+ Read [`inventory-and-catalogs.md`](inventory-and-catalogs.md) first for the concepts; this is the
8
+ recipe.
9
+
10
+ **Before you start**, you need two things this document assumes:
11
+
12
+ - **The `gemctl` command-line tool.** It is not on npm — `npm install gemctl` finds nothing. Ask
13
+ the platform team where to get it. If you do not have it, steps 1 and 2 cannot be done; get it
14
+ first.
15
+ - **A developer account with your game registered.** Uploading config needs developer permission on
16
+ the game. A `403` means the account lacks it, and signing in again will not fix it.
17
+
18
+ Everything here targets the **`dev` channel** — the one only your team sees. The last step says why
19
+ you must not change that.
20
+
21
+ ## The budget, so you know when you have it right
22
+
23
+ A shop screen costs **three requests to render, one when the player selects a row, and one to
24
+ execute the purchase.** If you find yourself calling the server once per shop row, you have taken a
25
+ wrong turn — go back to step 4. The three-to-render are: load the config, read the player's
26
+ inventory snapshot, and read recipe usage for cooldowns. Everything a row shows — its price,
27
+ whether the player can afford it, its cooldown, uses remaining, base odds — comes from those,
28
+ computed on the client.
29
+
30
+ ## 1. Author the config
31
+
32
+ Your currency, item and shop are config, not code. This is a minimal illustrative shape — run
33
+ `gemctl config schema` for the authoritative field list, and do not copy a field list out of any
34
+ document, because the schema is versioned and a copied list goes stale.
35
+
36
+ ```yaml
37
+ currencies:
38
+ - id: coins
39
+ display_name: Coins
40
+ min_balance: 0
41
+ max_balance: 999999
42
+
43
+ items:
44
+ - id: healing-potion
45
+ display_name: Healing Potion
46
+ fungible: true
47
+
48
+ catalogs:
49
+ - id: general-store
50
+ recipes:
51
+ - id: buy-potion
52
+ recipe_type: purchase
53
+ costs:
54
+ - currency_id: coins
55
+ amount: 25
56
+ grants:
57
+ - item_definition_id: healing-potion
58
+ quantity: 1
59
+ ```
60
+
61
+ A **purchase recipe** is the whole point: it names what it costs and what it grants, and the server
62
+ enforces both. You will run it by name; you will never send the price yourself.
63
+
64
+ **`recipe_type` is one of five values: `purchase`, `exchange`, `claim`, `craft` or `loot_box`.** It
65
+ defaults to `purchase`, so a shop recipe can leave it out. Any other value — `standard`, for one —
66
+ is refused at upload, naming the five.
67
+
68
+ ## 2. Upload and promote to the dev channel
69
+
70
+ ```bash
71
+ gemctl config schema # the authoritative field list
72
+ gemctl config upload --wait # registers a version; validation is ASYNC
73
+ gemctl config promote <version> --channel dev # THIS is what makes it live
74
+ ```
75
+
76
+ **Uploading is not shipping.** Until `gemctl config promote` puts the version on the channel your
77
+ game runs, the shop does not exist for that channel and every read of it comes back empty. This is
78
+ the same rule save slots follow; see
79
+ [`saving-player-progress.md`](saving-player-progress.md#1-declare-the-slot-in-your-game-config).
80
+
81
+ ## 3. Load the config and the snapshot, once
82
+
83
+ In the game, at shop-open, read the three things the whole screen is built from:
84
+
85
+ ```js
86
+ const config = await gem.gameConfig.load(); // the shop's definitions
87
+ const snap = await gem.me.inventory.snapshot(); // what the player holds
88
+ const usage = await gem.me.mutations.recipeUsageAll(); // cooldowns, one read for every recipe
89
+ ```
90
+
91
+ **`config` can be `null`.** That means this channel has no config yet — the version was not
92
+ promoted, or the game is running on a different channel than the one you promoted to. Render an
93
+ "shop unavailable" state; it is a real condition, not an error to swallow.
94
+
95
+ ## 4. Render the shop from what you already have — no per-row call
96
+
97
+ Walk the catalog's recipes and render each from the config and the snapshot. **Do not call the
98
+ server inside this loop.** The price is on the recipe; affordability is the player's balance
99
+ against it; the cooldown is in the usage you already read.
100
+
101
+ One shape gotcha before the code: the config document is the raw published data,
102
+ so its fields are **snake_case** — `display_name`, `currency_id`,
103
+ `item_definition_id`. The player-side reads (`recipeUsage`, `gem.me.currencies`,
104
+ `recipeAvailability`) are the SDK's own shapes and are camelCase. Do not mix
105
+ them.
106
+
107
+ ```js
108
+ const store = config.catalogs.find((c) => c.id === 'general-store');
109
+ const balance = (await gem.me.currencies.get('coins')).total; // from the snapshot, not a fourth request
110
+ // `null` means no record — decide at the render what that shows, never `?? 0` at the read.
111
+
112
+ for (const recipe of store.recipes) {
113
+ const cost =
114
+ recipe.costs?.find((c) => 'currency_id' in c && c.currency_id === 'coins')?.amount ?? 0;
115
+ const affordable = balance !== null && balance >= cost; // null is "no record", and null >= 0 is true in JS
116
+ const used = usage.usages.find((u) => u.recipeId === recipe.id); // camelCase: SDK shape
117
+ const onCooldown = used?.isOnCooldown ?? false;
118
+
119
+ const grant = recipe.grants?.[0];
120
+ const name =
121
+ grant && 'item_definition_id' in grant
122
+ ? config.item(grant.item_definition_id)?.display_name
123
+ : recipe.id;
124
+
125
+ const row = document.createElement('button');
126
+ row.textContent = `${name} — ${cost}`; // textContent, never innerHTML
127
+ row.disabled = !affordable || onCooldown;
128
+ row.onclick = () => onBuy(recipe); // remember which recipe this row is — steps 5-6 need it
129
+ shopEl.append(row);
130
+ }
131
+ ```
132
+
133
+ **Render with `textContent`, never `innerHTML`.** A display name or an item's freeform properties
134
+ is text you did not author — an item can arrive from another player by trade — so building markup
135
+ from it is a cross-player script-injection sink. The same goes for any `img.src` or `a.href` you
136
+ might set from a config or item string: don't. See
137
+ [`inventory-and-catalogs.md`](inventory-and-catalogs.md#8-items).
138
+
139
+ ## 5. When the player picks a row, check that one recipe
140
+
141
+ The `onBuy(recipe)` the row wired up above runs the rest. Now — and only now, for the one recipe
142
+ the player chose — ask the server whether it can run. This is the single per-selection call, and it
143
+ carries the cooldown so you need nothing else:
144
+
145
+ ```js
146
+ async function onBuy(recipe) {
147
+ const av = await gem.me.mutations.recipeAvailability('general-store', recipe.id);
148
+ if (!av.canExecute) {
149
+ showReason(av.reasons, av.cooldownExpiresAt); // e.g. "on cooldown until …"
150
+ return;
151
+ }
152
+ // ... step 6 continues here
153
+ }
154
+ ```
155
+
156
+ A 20-row shop that called this per row would be 20 requests for a screen that needs none until a
157
+ click. Check availability on selection, not on render.
158
+
159
+ ## 6. Execute the recipe — the server does the arithmetic
160
+
161
+ Still inside `onBuy`, with the same `recipe`: run it by name. You send no price, no balance, no
162
+ total — the server charges the cost and grants the item, and a modified client cannot change what
163
+ it charges:
164
+
165
+ `executeRecipe` resolves with a typed result — the order id, the status, what it
166
+ granted, what it consumed (the cost side), and any loot rolls:
167
+
168
+ ```js
169
+ try {
170
+ const result = await gem.me.mutations.executeRecipe('general-store', recipe.id);
171
+ // result.orderId, result.status, result.granted, result.consumed, result.lootResults
172
+ await confirmPurchase(result.orderId); // step 7
173
+ } catch (err) {
174
+ handlePurchaseError(err);
175
+ }
176
+ ```
177
+
178
+ For a loot box, `result.lootResults` is what makes a "you got …" screen possible — it names which
179
+ entry dropped and at what rarity, which the inventory snapshot alone does not.
180
+
181
+ Three failures to handle, and getting them right is the difference between a shop and a
182
+ data-corruption bug:
183
+
184
+ - **A `412` and a timeout are opposite.** You are not sending `ifMatch` here — a purchase is a delta
185
+ the server bounds, so it needs no condition — so a `412` should not arise. But the general rule
186
+ matters the moment you do send one elsewhere: a `412` proves the write did _not_ happen; a
187
+ **timeout proves nothing** and the purchase may have applied. **Never resend a timed-out
188
+ purchase.** Read the order back (step 7) to find out.
189
+ - **Retry only what `err.retryable` says is retryable.** `rate_limited` and `unavailable` are; wait
190
+ `err.retryAfterSeconds` when the refusal carries one, and your own backoff when it does not —
191
+ absence means the platform gave no estimate, not "stop". `insufficient_funds`,
192
+ `precondition_failed`, `invalid` and `too_large` are never retryable. Retrying blind is how one
193
+ slow moment becomes a storm.
194
+ - **A `402` means the player cannot afford it.** Re-read the balance, re-render, and do not resend.
195
+
196
+ ## 7. Confirm it landed
197
+
198
+ The answer to "did my purchase go through" is the order, read back by its id:
199
+
200
+ ```js
201
+ const order = await gem.me.orders.get(result.orderId);
202
+ // order.status tells you what happened; the grant is in the player's next snapshot
203
+ ```
204
+
205
+ This is also the answer after a timeout: you got no result back, so list recent orders and look for
206
+ the one you attempted, rather than resending the purchase.
207
+
208
+ ## Selling a cosmetic for Tickets
209
+
210
+ Your shop can also sell for the arcade's own currencies, and the platform draws one line through
211
+ them: **Tickets buy cosmetics, Gems buy anything.** Each platform currency has a spend scope — Tickets'
212
+ is `cosmetic_only`, Gems' is `any`. Mark a cosmetic in the
213
+ config, and price it in `ARCADE_TICKETS`:
214
+
215
+ ```yaml
216
+ items:
217
+ - id: wizard-hat
218
+ display_name: Wizard Hat
219
+ category: cosmetic # it changes how the player looks, never what they can do
220
+ platform_prices:
221
+ - currency: ARCADE_TICKETS # Tickets
222
+ price: 40
223
+ - id: dragon-skin
224
+ display_name: Dragon Skin
225
+ category: cosmetic # no price of its own: it is sold through the recipe below
226
+ ```
227
+
228
+ `category` defaults to `progression`, so an item is a cosmetic only when you say so. The upload
229
+ refuses a price in a `cosmetic_only` currency on anything that is not: an item that is not
230
+ `category: cosmetic`, a recipe that grants a currency, a progression, a loot-table roll or a
231
+ non-cosmetic item. The refusal names the currency's scope and the item or the grant, so the fix is in
232
+ the message. Progression — an extra life, an upgrade —
233
+ is priced in `GEMS`.
234
+
235
+ To make a cosmetic wait for progress — "reach level 10 to unlock the dragon skin" — sell it through a
236
+ recipe, because an item's own price cannot carry a prerequisite. Give the item no price of its own,
237
+ or it can be bought directly and the lock means nothing:
238
+
239
+ ```yaml
240
+ catalogs:
241
+ - id: cosmetics
242
+ recipes:
243
+ - id: buy-dragon-skin
244
+ prerequisites:
245
+ - prerequisite_type: progression_level
246
+ reference_id: '…' # your `mastery` progression's id
247
+ reference_value: 10
248
+ grants:
249
+ - item_definition_id: dragon-skin
250
+ quantity: 1
251
+ platform_prices:
252
+ - currency: ARCADE_TICKETS
253
+ price: 120
254
+ ```
255
+
256
+ In the shop, ask about that row the way step 5 does, and show the lock rather than a button:
257
+
258
+ ```js
259
+ const av = await gem.me.mutations.recipeAvailability('cosmetics', 'buy-dragon-skin');
260
+ if (av.locked) {
261
+ const [lock] = av.lockReasons; // { prerequisiteType: 'progression_level', required: 10, current: 7, … }
262
+ row.textContent = `Reach level ${lock.required} to unlock`;
263
+ row.disabled = true;
264
+ }
265
+ ```
266
+
267
+ The platform checks the prerequisite **before** it takes any Tickets: a locked player who buys
268
+ anyway is refused `recipe_locked` and charged nothing. See
269
+ [`inventory-and-catalogs.md`](inventory-and-catalogs.md#16-selling-for-a-platform-currency) for the
270
+ two purchase paths and the full rule.
271
+
272
+ ### Buying it, and reading a refusal
273
+
274
+ With the arcade's purchase sheet, the platform confirms and charges; your game asks and reads the
275
+ outcome. A refusal carries a `reason` that says why:
276
+
277
+ ```js
278
+ const res = await gem.purchases.request({
279
+ kind: 'recipe',
280
+ catalogId: 'cosmetics',
281
+ targetId: 'buy-dragon-skin', // the recipe's id, never its slug
282
+ currencySlug: 'ARCADE_TICKETS',
283
+ });
284
+ if (res.outcome === 'ok' || res.outcome === 'unavailable') {
285
+ gem.me.inventory.refresh(); // `unavailable` means no answer arrived, not that nothing happened
286
+ } else if (res.reason === 'recipe_locked' || res.reason === 'insufficient_funds') {
287
+ updateShopRow(res.reason); // your shop's own words: "Locked", "Not enough Tickets"
288
+ } else if (res.reason !== 'cancelled' && res.reason !== 'busy') {
289
+ console.warn('purchase declined:', res.reason); // unknown_currency, no_price_in_currency … are yours to fix
290
+ }
291
+ ```
292
+
293
+ With your own purchase screen (`gem.me.mutations.purchase`), the same strings arrive as `err.code`
294
+ — see [`inventory-and-catalogs.md`](inventory-and-catalogs.md#why-a-purchase-was-refused).
295
+
296
+ **Never draw a top-up or payment screen of your own** — no "get more Tickets", no checkout, nothing
297
+ shaped like the arcade's purchase sheet. A player short of Tickets earns more by playing; your shop
298
+ says "Not enough Tickets" and stops there.
299
+
300
+ ## Rewarding a player — still a recipe
301
+
302
+ When your game _gives_ a player something — "you earned 100 coins," a quest reward — reach for a
303
+ recipe too, not a direct grant. A reward flow is the same client-authored economy as a shop under a
304
+ friendlier name, and the same rule applies: if the value matters, the server has to be the one that
305
+ awards it. Author a reward recipe in your config and execute it exactly as above.
306
+
307
+ ## The one thing you must not do next
308
+
309
+ Everything here is on the **`dev` channel** on purpose. This shop is not safe to put in front of
310
+ real players as-is, and the reason is not a bug you can fix by promoting it:
311
+
312
+ > Within your own game's scope there is **no client-side integrity boundary**. Any balance, item or
313
+ > progression a browser client can author, a modified browser client can author differently.
314
+ > Recipes move the arithmetic to the server and are the only thing that does. Do not attach anything
315
+ > of value — a leaderboard, a tournament, a payout, a cross-player market — to a number a client
316
+ > authored.
317
+
318
+ Recipes are what make this shop honest: the server charges the cost. But a shop is only as safe as
319
+ the flows around it, and promoting a config to the channel every player sees is a decision to make
320
+ deliberately, not a step in a tutorial. Keep this on `dev` until you have decided the whole economy
321
+ around it holds up.
322
+
323
+ ## What to read next
324
+
325
+ - [`inventory-and-catalogs.md`](inventory-and-catalogs.md) — the full concept reference for everything used here.
326
+ - [`trading-and-gifting.md`](trading-and-gifting.md) — moving items and currency between players.