@indiegems/gem-web-sdk 7.3.1 → 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.
- package/README.md +54 -0
- package/dist/game.d.ts +156 -22
- package/dist/gem-web-sdk.bridge.js +1 -1
- package/dist/gem-web-sdk.esm.js +2 -2
- package/dist/gem-web-sdk.game.js +2 -2
- package/dist/gem-web-sdk.min.js +2 -2
- package/dist/gem-web-sdk.sdk.js +2 -2
- package/dist/gem-web-sdk.server.js +2 -2
- package/dist/index.d.ts +89 -6
- package/dist/sdk.d.ts +156 -22
- package/docs/achievements.md +232 -0
- package/docs/analytics.md +271 -0
- package/docs/api/api-reference-game.md +15308 -0
- package/docs/api/api-reference-server.md +4397 -0
- package/docs/api/reference.json +24130 -0
- package/docs/arcade-economy.md +263 -0
- package/docs/async-opponents.md +396 -0
- package/docs/brand-art.md +334 -0
- package/docs/cross-promotion.md +137 -0
- package/docs/daily-challenges.md +530 -0
- package/docs/dedicated-server-flow.md +879 -0
- package/docs/deprecation-policy.md +124 -0
- package/docs/friends.md +233 -0
- package/docs/game-economy-tutorial.md +326 -0
- package/docs/getting-started.md +552 -0
- package/docs/inventory-and-catalogs.md +1035 -0
- package/docs/leaderboards.md +277 -0
- package/docs/manifest.json +135 -0
- package/docs/match-session.md +288 -0
- package/docs/p2p-and-parties.md +783 -0
- package/docs/platform-rules.md +372 -0
- package/docs/player-sign-in.md +89 -0
- package/docs/room-shapes.md +195 -0
- package/docs/rooms-and-matches.md +779 -0
- package/docs/saving-player-progress.md +457 -0
- package/docs/server-bundles.md +1041 -0
- package/docs/serverless-functions.md +748 -0
- package/docs/trading-and-gifting.md +98 -0
- package/docs/what-a-game-must-do.md +1231 -0
- package/package.json +7 -2
- package/schema/bridge.v1.json +440 -7
- 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.
|