# Building a shop, step by step

This builds a working shop: a currency, an item, a purchase, and the screen a player buys from. It
is the safe version — the one where the server does the arithmetic — and it is written to be
followed in order.

Read [`inventory-and-catalogs.md`](inventory-and-catalogs.md) first for the concepts; this is the
recipe.

**Before you start**, you need two things this document assumes:

- **The `gemctl` command-line tool.** It installs from the agent-plugin marketplace, not from npm —
  `npm install gemctl` finds nothing. If you do not have it, steps 1 and 2 cannot be done; get it
  first.
- **A developer account with your game registered.** Uploading config needs developer permission on
  the game. A `403` means the account lacks it, and signing in again will not fix it.

Everything here targets the **`dev` channel** — the one only your team sees. The last step says why
you must not change that.

## The budget, so you know when you have it right

A shop screen costs **three requests to render, one when the player selects a row, and one to
execute the purchase.** If you find yourself calling the server once per shop row, you have taken a
wrong turn — go back to step 4. The three-to-render are: load the config, read the player's
inventory snapshot, and read recipe usage for cooldowns. Everything a row shows — its price,
whether the player can afford it, its cooldown, uses remaining, base odds — comes from those,
computed on the client.

## 1. Author the config

Your currency, item and shop are config, not code. This is a minimal illustrative shape — run
`gemctl config schema` for the authoritative field list, and do not copy a field list out of any
document, because the schema is versioned and a copied list goes stale.

```yaml
currencies:
  - id: coins
    display_name: Coins
    min_balance: 0
    max_balance: 999999

items:
  - id: healing-potion
    display_name: Healing Potion
    fungible: true

catalogs:
  - id: general-store
    recipes:
      - id: buy-potion
        recipe_type: purchase
        costs:
          - currency_id: coins
            amount: 25
        grants:
          - item_definition_id: healing-potion
            quantity: 1
```

A **purchase recipe** is the whole point: it names what it costs and what it grants, and the server
enforces both. You will run it by name; you will never send the price yourself.

## 2. Upload and promote to the dev channel

```bash
gemctl config schema                          # the authoritative field list
gemctl config upload --wait                    # registers a version; validation is ASYNC
gemctl config promote <version> --channel dev  # THIS is what makes it live
```

**Uploading is not shipping.** Until `gemctl config promote` puts the version on the channel your
game runs, the shop does not exist for that channel and every read of it comes back empty. This is
the same rule save slots follow; see
[`saving-player-progress.md`](saving-player-progress.md#1-declare-the-slot-in-your-game-config).

## 3. Load the config and the snapshot, once

In the game, at shop-open, read the three things the whole screen is built from:

```js
const config = await gem.gameConfig.load(); // the shop's definitions
const snap = await gem.me.inventory.snapshot(); // what the player holds
const usage = await gem.me.mutations.recipeUsageAll(); // cooldowns, one read for every recipe
```

**`config` can be `null`.** That means this channel has no config yet — the version was not
promoted, or the game is running on a different channel than the one you promoted to. Render an
"shop unavailable" state; it is a real condition, not an error to swallow.

## 4. Render the shop from what you already have — no per-row call

Walk the catalog's recipes and render each from the config and the snapshot. **Do not call the
server inside this loop.** The price is on the recipe; affordability is the player's balance
against it; the cooldown is in the usage you already read.

One shape gotcha before the code: the config document is the raw published data,
so its fields are **snake_case** — `display_name`, `currency_id`,
`item_definition_id`. The player-side reads (`recipeUsage`, `gem.me.currencies`,
`recipeAvailability`) are the SDK's own shapes and are camelCase. Do not mix
them.

```js
const store = config.catalogs.find((c) => c.id === 'general-store');
const balance = (await gem.me.currencies.get('coins')).total ?? 0; // from the snapshot, not a fourth request

for (const recipe of store.recipes) {
  const cost =
    recipe.costs?.find((c) => 'currency_id' in c && c.currency_id === 'coins')?.amount ?? 0;
  const affordable = balance >= cost;
  const used = usage.usages.find((u) => u.recipeId === recipe.id); // camelCase: SDK shape
  const onCooldown = used?.isOnCooldown ?? false;

  const grant = recipe.grants?.[0];
  const name =
    grant && 'item_definition_id' in grant
      ? config.item(grant.item_definition_id)?.display_name
      : recipe.id;

  const row = document.createElement('button');
  row.textContent = `${name} — ${cost}`; // textContent, never innerHTML
  row.disabled = !affordable || onCooldown;
  row.onclick = () => onBuy(recipe); // remember which recipe this row is — steps 5-6 need it
  shopEl.append(row);
}
```

**Render with `textContent`, never `innerHTML`.** A display name or an item's freeform properties
is text you did not author — an item can arrive from another player by trade — so building markup
from it is a cross-player script-injection sink. The same goes for any `img.src` or `a.href` you
might set from a config or item string: don't. See
[`inventory-and-catalogs.md`](inventory-and-catalogs.md#8-items).

## 5. When the player picks a row, check that one recipe

The `onBuy(recipe)` the row wired up above runs the rest. Now — and only now, for the one recipe
the player chose — ask the server whether it can run. This is the single per-selection call, and it
carries the cooldown so you need nothing else:

```js
async function onBuy(recipe) {
  const av = await gem.me.mutations.recipeAvailability('general-store', recipe.id);
  if (!av.canExecute) {
    showReason(av.reasons, av.cooldownExpiresAt); // e.g. "on cooldown until …"
    return;
  }
  // ... step 6 continues here
}
```

A 20-row shop that called this per row would be 20 requests for a screen that needs none until a
click. Check availability on selection, not on render.

## 6. Execute the recipe — the server does the arithmetic

Still inside `onBuy`, with the same `recipe`: run it by name. You send no price, no balance, no
total — the server charges the cost and grants the item, and a modified client cannot change what
it charges:

`executeRecipe` resolves with the server's own result — the order id, what was
consumed and granted, and any loot rolls. It is the raw response shape, so its
fields are snake_case like the config:

```js
try {
  const result = await gem.me.mutations.executeRecipe('general-store', recipe.id);
  // result.order_id, result.status, result.granted, result.loot_results
  await confirmPurchase(result.order_id); // step 7
} catch (err) {
  handlePurchaseError(err);
}
```

Three failures to handle, and getting them right is the difference between a shop and a
data-corruption bug:

- **A `412` and a timeout are opposite.** You are not sending `ifMatch` here — a purchase is a delta
  the server bounds, so it needs no condition — so a `412` should not arise. But the general rule
  matters the moment you do send one elsewhere: a `412` proves the write did _not_ happen; a
  **timeout proves nothing** and the purchase may have applied. **Never resend a timed-out
  purchase.** Read the order back (step 7) to find out.
- **A rate-limit refusal with no wait hint is not retried.** A game on the arcade's sandboxed origin
  cannot read the server's `Retry-After` header — the browser does not expose it across origins — so
  if a refusal carries no wait time, treat it as "stop," not "retry immediately." Retrying blind is
  how one slow moment becomes a storm.
- **A `402` means the player cannot afford it.** Re-read the balance, re-render, and do not resend.

## 7. Confirm it landed

The answer to "did my purchase go through" is the order, read back by its id:

```js
const order = await gem.me.orders.get(result.order_id);
// order.status tells you what happened; the grant is in the player's next snapshot
```

This is also the answer after a timeout: you got no result back, so list recent orders and look for
the one you attempted, rather than resending the purchase.

## Rewarding a player — still a recipe

When your game _gives_ a player something — "you earned 100 coins," a quest reward — reach for a
recipe too, not a direct grant. A reward flow is the same client-authored economy as a shop under a
friendlier name, and the same rule applies: if the value matters, the server has to be the one that
awards it. Author a reward recipe in your config and execute it exactly as above.

## The one thing you must not do next

Everything here is on the **`dev` channel** on purpose. This shop is not safe to put in front of
real players as-is, and the reason is not a bug you can fix by promoting it:

> Within your own game's scope there is **no client-side integrity boundary**. Any balance, item or
> progression a browser client can author, a modified browser client can author differently.
> Recipes move the arithmetic to the server and are the only thing that does. Do not attach anything
> of value — a leaderboard, a tournament, a payout, a cross-player market — to a number a client
> authored.

Recipes are what make this shop honest: the server charges the cost. But a shop is only as safe as
the flows around it, and promoting a config to the channel every player sees is a decision to make
deliberately, not a step in a tutorial. Keep this on `dev` until you have decided the whole economy
around it holds up.

## What to read next

- [`inventory-and-catalogs.md`](inventory-and-catalogs.md) — the full concept reference for everything used here.
- [`trading-and-gifting.md`](trading-and-gifting.md) — moving items and currency between players.
