# DrawAndRoll Backend API Schema (v1)

Handover reference for whoever implements the backend. Stack-agnostic REST/JSON — nothing here assumes a particular framework or language. The Unity client's implementation of this contract lives in `Assets/Api/` (DTOs in `Assets/Api/Dto/`, calls in `Assets/Api/Services/`); those files are the source of truth for exact field names if this doc and the code ever drift.

## Conventions

- All endpoints are versioned under `/v1/`.
- Every response uses the same envelope:
  ```json
  { "success": true, "data": { ... }, "error": null }
  { "success": false, "data": null, "error": { "code": "AUTH_INVALID_TOKEN", "message": "..." } }
  ```
- `Authorization: Bearer <session_token>` is required on every endpoint except the two login endpoints.
- An app-identifying static header, `X-App-Token`, is sent on every request (not a per-user credential — just distinguishes "is this a legitimate build of our app").
- Standard HTTP status codes: `200` success, `201` created, `400` validation error, `401` missing/invalid/expired session token, `403` forbidden, `404` not found, `409` conflict (e.g. duplicate purchase, already claimed), `422` semantic validation, `429` rate limited, `500` server error.
- **Item IDs are permanent and dense per category.** `marble`/`board`/`background` catalog `id` values are `0..N-1`, and the client's local prefab/asset arrays are indexed by the exact same integer. New items are only ever appended at the next integer. Items are never renumbered or deleted — deprecate via `is_active: false` instead (still ownable/visible to players who already own it, just hidden from the "for sale" list).

---

## 1. Auth

| Method | Path | Auth | Body |
|---|---|---|---|
| POST | `/v1/auth/guest` | none | `{ device_id, device_type }` |
| POST | `/v1/auth/social` | none | `{ provider, provider_token, name?, email? }` |
| POST | `/v1/auth/refresh` | none (refresh token in body) | `{ refresh_token }` — optional, backend's call whether to issue refresh tokens at all |
| POST | `/v1/auth/logout` | bearer | — |
| DELETE | `/v1/account` | bearer | — |

**`DELETE /v1/account`** — permanent, in-app account deletion (required by Google Play and the App Store
for any app that creates accounts; Google Play also requires a web deletion link in the store listing).
Response `data`: `{ "deleted": true }`. It must:
- delete the profile, ownership, stats, challenge progress and daily-reward state for the bearer's user;
- revoke **every** session and refresh token of that user (all devices), so later calls return `401`;
- unlink the guest `device_id` and any social identities, so the next guest/social login on that identity
  creates a **brand-new** account (`is_new_account: true`, `bootstrap_required: true`);
- keep processed IAP transaction ids (the purchase ledger), so a deleted account's receipts can't be replayed.

A second call with the same (now revoked) token returns `401`. The client clears its session only after a
successful response, then wipes its local profile cache so the next account doesn't bootstrap-upload it.

`provider` is one of `"google" | "apple" | "facebook"`. The server must verify `provider_token` server-side with the provider (Google tokeninfo endpoint / Apple JWKS / Facebook Graph API `debug_token`) — the client never self-asserts identity.

**Guest/social login response** (`data`):
```json
{
  "user_id": "u_123",
  "session_token": "…",
  "refresh_token": "…",
  "is_new_account": true,
  "bootstrap_required": true,
  "profile": { /* same shape as GET /v1/profile, see section 2 */ }
}
```
`bootstrap_required` is the authoritative "first login ever for this account" signal — the client uses it (not any local flag) to decide whether to call `POST /v1/profile/bootstrap`, since a local flag wouldn't survive a reinstall.

---

## 2. Profile

| Method | Path | Auth | Body |
|---|---|---|---|
| GET | `/v1/profile` | bearer | — |
| PATCH | `/v1/profile` | bearer | `{ name?, avatar_index? }` |
| POST | `/v1/profile/bootstrap` | bearer | see 2.1 |

**`GET /v1/profile` response** (`data`):
```json
{
  "user_id": "u_123",
  "name": "P1",
  "avatar_index": 2,
  "coins": 10450,
  "score": 320,
  "games_played": 12,
  "wins": 5,
  "losses": 7,
  "selected_marble_id": 3,
  "selected_board_id": 0,
  "selected_background_id": 1,
  "owned_marble_ids": [0, 3, 7],
  "owned_board_ids": [0],
  "owned_background_ids": [0, 1],
  "challenges_completed_total": 14,
  "rank": 342
}
```

### 2.1 Bootstrap upload — one-time merge for a brand-new account

Called only when the login response said `bootstrap_required: true`. Request:
```json
{
  "local_coins": 9800,
  "local_score": 150,
  "local_games_played": 4,
  "local_wins": 2,
  "local_losses": 2,
  "local_owned_marble_ids": [0, 3],
  "local_owned_board_ids": [0],
  "local_owned_background_ids": [0]
}
```
Response: same shape as `GET /v1/profile`, reflecting the merge result.

**Merge policy (needs explicit backend/economy sign-off — this is a proposed default, not a mandate):** ownership = union of local + server-default; `games_played`/`wins`/`losses`/`score` = additive (they're historical counters, not balances). Coins are the sensitive one — a naive `max(local, server_default)` could let a reinstalled/spoofed local save hand a fresh account free currency, so lean toward capping the uploaded value (e.g. `min(local_coins, server_default_starting_coins)` or a hard-coded bootstrap ceiling) rather than trusting it outright.

The server must reject a second bootstrap call once `bootstrap_required` is already false for that account (`409`) — this is a one-time operation, not idempotent-by-design.

---

## 3. Shop catalog

| Method | Path | Auth | Body |
|---|---|---|---|
| GET | `/v1/shop/catalog` | bearer | — |
| POST | `/v1/shop/purchase` | bearer | `{ category, item_id }` |

**`GET /v1/shop/catalog` response** (`data`):
```json
{
  "marbles":      [ { "id": 0, "price": 0,   "is_default": true, "is_active": true }, { "id": 1, "price": 200, "is_active": false }, ... ],
  "boards":       [ { "id": 0, "price": 0,   "is_default": true }, ... ],
  "backgrounds":  [ { "id": 0, "price": 0,   "is_default": true }, ... ]
}
```

**Marble catalogue — 39 items (was 25). The live server must be updated.** The client refuses to buy
or select any index `>= ` the catalogue length it receives, and a login profile sync resets a selection
that is out of range. So while the server still returns 25 marbles, **ids 25–38 cannot be bought, and a
player who selects one is reset to the default marble on their next login.** This was observed against
the live dev server.

| ids | status |
|---|---|
| 0, 6–13, 19, 24 | active, unchanged |
| 1, 2, 3, 4, 5, 14, 15, 16, 17, 18, 20, 21, 22, 23 | **deprecated** — return `is_active: false`. Keep them in the list: ids never shift, and players who already own one keep ownership. Refuse new purchases of them. |
| 25–38 | **new**, `is_active: true`, price 500 unless you decide otherwise |

Ids 25–38 are single-design marbles with no per-player colour variations. The client shows them only to
their owner and shows everyone else Classic in that player's seat colour — no server involvement, but
worth knowing when a player reports "my opponent's marble looks plain".

The `own_all_marbles` challenge (§7.1) must count **active** marbles only; counting deprecated ones makes
it impossible for anyone who didn't already own them.

`POST /v1/shop/purchase` is a **best-effort ledger mirror**: the client already applied the purchase optimistically and locally (coins deducted, item marked owned) before calling this — it's a fire-and-forget call to keep the server's record in sync, not a blocking authorization gate. The server should record/upsert ownership + a coin debit for this `(user_id, category, item_id)` idempotently (repeat calls are harmless). Any drift between client and server state self-heals on the player's next login via `GET /v1/profile`, which is always authoritative.

"Unlock All" is **not** a catalog row — it's an IAP product (section 4) whose effect is "mark every current item in a category owned." Don't try to represent it as a priced catalog item.

---

## 4. IAP / purchases

| Method | Path | Auth | Body |
|---|---|---|---|
| GET | `/v1/iap/coin-packages` | bearer | — |
| POST | `/v1/iap/validate` | bearer | `{ platform, product_id, receipt_payload, transaction_id }` |

**`GET /v1/iap/coin-packages` response** (`data`, one list covering both coin packs and Unlock-All SKUs):
```json
[
  { "id": 1,  "coins": 1000, "unlock_all_category": null,     "price_usd": 0.99, "google_product_id": "coins_1000",         "apple_product_id": "com.metafroliclabs.drawandroll.coins1000" },
  { "id": 90, "coins": 0,    "unlock_all_category": "marble", "price_usd": 4.99, "google_product_id": "unlock_all_marbles", "apple_product_id": "com.metafroliclabs.drawandroll.unlockallmarbles" }
]
```
`unlock_all_category` is one of `"marble" | "board" | "background"` for unlock-all SKUs, `null` for plain coin packs.

**`POST /v1/iap/validate`** — called after Unity IAP reports a completed purchase locally with a signed receipt. **The server must validate the receipt against the Google Play Developer API / Apple App Store Server API before granting anything** — this is a hard security requirement, not optional. Dedupe by `transaction_id`; a replayed/already-processed transaction returns `409`. Response (`data`):
```json
{ "granted_coins": 1000, "unlocked_category": null, "profile": { /* full updated profile, section 2 shape */ } }
```

---

## 5. Leaderboard

| Method | Path | Auth |
|---|---|---|
| GET | `/v1/leaderboard?scope=global&limit=50` | bearer |

`scope` is optional, defaults to `global`. Values `weekly`/`monthly` are reserved for a possible future addition — the MVP client only ever requests `global` (no friends-graph feature exists in this game today). Response (`data`):
```json
{
  "entries": [ { "rank": 1, "user_id": "u_9", "name": "P9", "avatar_index": 3, "score": 5200 }, ... ],
  "my_rank": 342
}
```

---

## 6. Daily reward

| Method | Path | Auth |
|---|---|---|
| GET | `/v1/daily-reward` | bearer |
| POST | `/v1/daily-reward/claim` | bearer |

**`GET` response** (`data`):
```json
{
  "current_streak_day": 3,
  "can_claim_today": true,
  "next_reset_utc": "2026-08-25T00:00:00Z",
  "reward_track": [
    { "day": 1, "coins": 50 }, { "day": 2, "coins": 75 }, { "day": 3, "coins": 100 },
    { "day": 4, "coins": 150 }, { "day": 5, "coins": 200 }, { "day": 6, "coins": 300 }, { "day": 7, "coins": 500 }
  ]
}
```
The reward track is entirely server-config-driven so tuning never needs a client update.

**`POST /claim` response** (`data`): `{ "coins_granted": 100, "new_streak_day": 4, "profile": { ... } }`. Returns `409` if already claimed today.

---

## 7. Challenges (daily + lifetime)

| Method | Path | Auth | Body |
|---|---|---|---|
| GET | `/v1/daily-challenges` | bearer | — |
| POST | `/v1/daily-challenges/{challenge_id}/claim` | bearer | — |
| POST | `/v1/game-results` | bearer | `{ match_id, result, board_id, game_mode, events, jokers_played, duration_seconds, opponent_user_ids, forfeit }` |

There are **two kinds of challenge, returned in ONE list** by the same endpoint (the path keeps its
`daily-challenges` name for compatibility — it now serves both):

| `type` | Behaviour |
|---|---|
| `daily` | Regenerated every UTC day; progress resets. Ids are date-scoped (`c_<date>_<key>`). |
| `lifetime` | Persistent achievements. Ids are stable (`lc_<key>`). **Never regenerate or reset these on the daily roll** — "Win 100 Games" would be unachievable if you did. |

The client renders one row per item and does not group them, so ordering is the server's call
(the reference implementation returns daily first). `type` is supplied so a future client build can
split or group them without another contract change.

**`GET` response** (`data`):
```json
[
  { "id": "c_2026-08-24_win3", "type": "daily",    "description": "Win 3 games today",     "target": 3,   "progress": 1, "reward_coins": 200,  "is_completed": false, "is_claimed": false },
  { "id": "lc_win100_4p",      "type": "lifetime", "description": "Win 100 games (4 player)", "target": 100, "progress": 7, "reward_coins": 1500, "is_completed": false, "is_claimed": false }
]
```

### 7.1 Challenge catalogue (25) and their rules

Seed data. `rule` is what the server evaluates; `mode` (2/4/6) narrows a rule to a seat count.

**Daily (3)** — `win` (win N today), `play` (play N today), `board` (N distinct boards today).

**Lifetime (22)** — all scored from `/v1/game-results`; the nine `event_*` rules read its in-match fields (below):

| id | description | target | rule | mode | condition |
|---|---|---|---|---|---|
| `lc_play_streak_7` | Play 7 consecutive days | 7 | `play_streak` | — | counter |
| `lc_play_streak_30` | Play 30 consecutive days | 30 | `play_streak` | — | counter |
| `lc_win10_2p` / `4p` / `6p` | Win 10 games (N player) | 10 | `wins_mode` | 2/4/6 | counter |
| `lc_win50_2p` / `4p` / `6p` | Win 50 games (N player) | 50 | `wins_mode` | 2/4/6 | counter |
| `lc_win100_2p` / `4p` / `6p` | Win 100 games (N player) | 100 | `wins_mode` | 2/4/6 | counter |
| `lc_win_streak_10` | Win 10 consecutive games | 10 | `win_streak` | — | counter |
| `lc_own_all_marbles` | Purchase all marbles | 1 | `own_all_marbles` | — | counter |
| `lc_own_all_boards` | Purchase all boards | 1 | `own_all_boards` | — | counter |
| `lc_eight_to_start` | Use the 8 to send an opponent back to start | 1 | `event_eight_to_start` | — | `"eight_to_start"` in `events` |
| `lc_win_no_joker_2p` | Win a match without playing a Joker | 1 | `event_win_no_joker` | 2 | win, not forfeit, `jokers_played == 0` |
| `lc_first_send_home` | Be the first to send your opponent back to home | 1 | `event_first_send_home` | 4 | `"first_send_home"` in `events` |
| `lc_move_under_3s` | Make your move within 3 seconds | 1 | `event_fast_move` | 4 | `"fast_move"` in `events` |
| `lc_triple_sevens` | Triple 7's — use three 7's in a row | 1 | `event_triple_sevens` | 4 | `"triple_sevens"` in `events` |
| `lc_all_five_out` | Have all 5 marbles out of start at once | 1 | `event_all_five_out` | 4 | `"all_five_out"` in `events` |
| `lc_beat_higher_rank` | Win against an opponent ranked higher than you | 1 | `event_beat_higher_rank` | 2 | win, not forfeit, an opponent's rank better than yours |
| `lc_win_under_15min` | Win a game in under 15 minutes | 1 | `event_win_under_15min` | 2 | win, not forfeit, `0 < duration_seconds < 900` |

**The nine `event_*` rules.** The client detects the in-match moments for the local human player and
reports them as FACTS; the server applies every rule — the `mode` must equal the report's `game_mode`,
and each rule adds its own condition (table above). A matching report adds 1 to `progress`.

| field | type | meaning |
|---|---|---|
| `events` | string[] | Moments this player achieved in the match: `eight_to_start` (an 8 card sent an opponent back to start), `first_send_home` (made the match's first capture), `fast_move` (committed a card within 3 s of a turn starting), `triple_sevens` (used a 7 on three consecutive turns), `all_five_out` (all 5 marbles out of start at once). Ignore unknown keys. |
| `jokers_played` | int | Jokers this player played (including discarded or timer-played ones). |
| `duration_seconds` | int | Match length. |
| `opponent_user_ids` | string[] | Account ids of the other team's human players (bots have none). |
| `forfeit` | bool | The result came from the opponent leaving. |

- **Rank comparison is server-side.** For `event_beat_higher_rank` look up each opponent's rank from
  your own data at the time of the report, **before** applying this result. "Higher" means a strictly
  better (lower-numbered) rank; ties don't count. Never accept a rank from the client.
- **Forfeits never satisfy the win-conditional rules** (`win_no_joker`, `win_under_15min`,
  `beat_higher_rank`) — an opponent quitting is not a clean win.
- **Missing fields fail closed.** A report from an older client has no `jokers_played`; treat that as
  "unknown", not 0, so it can't earn `win_no_joker`.
- The same trust caveat as `result` applies: these are client-observed. Plausibility checks (e.g.
  `duration_seconds` far below any real match) are worthwhile.

Counters the computable rules need per user: `wins_by_mode{2,4,6}`, `win_streak` (reset to 0 on any
loss), `play_streak` + `last_played_date` (increment only when the previous play date was yesterday),
and current ownership counts vs the catalogue size for the two `own_all_*` rules. Ownership rules
should also be re-evaluated on `GET /v1/daily-challenges`, so a purchase reflects without waiting
for the next match.

**`POST /v1/game-results`** — the client reports every finished match (called once per match from the win/loss RPC handler, `result` is `"win"|"loss"`). **`game_mode` is the seat count of the match (2, 4 or 6)** and is REQUIRED for the per-mode win challenges — without it nine of the lifetime challenges cannot be scored at all. The client takes it from the Photon room's `MaxPlayers` (the authority: the live connection count shrinks when a seat is replaced by AI). A `0` means the report came from a client older than this field. This is what drives challenge progress and lifetime stat computation server-side, rather than trusting purely client-tallied counters. **Accepted MVP risk:** this game has no server-authoritative match validation today (Photon Realtime is P2P via PUN) — a "report your own result" endpoint is inherently client-trusted to a degree. Consider plausibility checks (rate limits, cross-referencing `match_id` against a Photon webhook if that's ever added) but full server-authoritative validation is out of scope for this pass.

**`POST /v1/game-results` response** (`data`):
```json
{ "recorded": true, "coins_granted": 100, "profile": { /* full updated profile, section 2 shape */ } }
```
`coins_granted` is the **win reward, decided server-side** - currently 100 for a `"win"`, 0 for a `"loss"`. The client applies whatever comes back (it prefers the returned `profile`, and only falls back to adding `coins_granted` if the profile is omitted), so the amount, and any future multipliers such as the GDD's watch-an-ad-to-double/triple option, can change without a client update. Keep the profile in the response: it is what makes the client's coin display authoritative immediately after a match.

**`POST /claim` response** (`data`): `{ "coins_granted": 200 }`.

---

## 8. Minimal DB schema sketch

```
users(id, user_type, guest_device_id, email, name, avatar_index, login_type, provider_id,
      bootstrap_required, created_at, updated_at)
user_sessions(id, user_id, session_token, refresh_token, expires_at, created_at)
user_profiles(user_id pk, coins, score, games_played, wins, losses,
              selected_marble_id, selected_board_id, selected_background_id, updated_at)
shop_items(category, item_id, price, is_default, is_active)                          -- pk(category, item_id)
user_owned_items(user_id, category, item_id, acquired_at, acquired_via)              -- pk(user_id, category, item_id)
                 -- acquired_via: 'purchase' | 'unlock_all' | 'bootstrap' | 'default'
coin_packages(id, coins, unlock_all_category, price_usd, google_product_id, apple_product_id, is_active)
iap_transactions(id, user_id, coin_package_id, platform, transaction_id unique,
                  receipt_payload, status, created_at)                                -- status: pending|validated|failed
leaderboard_scores(user_id, score, rank_cache, period, updated_at)
daily_rewards_state(user_id pk, current_streak_day, last_claimed_date, updated_at)
daily_reward_track(day pk, coins)                                                     -- global config, not per-user
challenges(id, type, challenge_date, description, target, reward_coins, rule_key, mode, is_active)
           -- type: 'daily' | 'lifetime'
           -- challenge_date: the day a DAILY row belongs to; NULL for lifetime rows.
           --   Lifetime rows are seeded once and never regenerated - see section 7.
           -- mode: 2|4|6 to narrow a rule to a seat count, NULL/0 = any
user_challenge_progress(user_id, challenge_id, progress, is_completed, is_claimed, updated_at) -- pk(user_id, challenge_id)
user_challenge_counters(user_id pk, wins_2p, wins_4p, wins_6p, win_streak,
                        play_streak, last_played_date, updated_at)
           -- the running totals the lifetime rules read; cheaper and more reliable than
           -- re-aggregating game_results on every match report
game_results(id, user_id, match_id, result, board_id, game_mode, created_at)
```

`guest_device_id` is kept as its own nullable column (not conflated with social identity) so a future "link this guest account to a social login" flow can be added without a breaking schema change — not needed for this pass, just don't paint the schema into a corner.

## 9. Open items for the backend team to own

1. **Bootstrap coin-merge policy** (section 2.1) — needs explicit sign-off, this doc only proposes a default.
2. **Social token verification** against each provider's server SDK — security-critical, not detailed here beyond "must happen server-side."
3. **Refresh token issuance** (`/v1/auth/refresh`) — included speculatively; whether to use short session + refresh vs. one long-lived session token is your call.
4. **Lifetime challenge seeding** — the 22 rows in section 7.1 are seed data, not user data. Seed them once and give existing accounts their rows on first read (the reference implementation back-fills lazily so accounts created before the feature still get them).
5. **In-match telemetry for the nine `event_*` challenges** — needs a design decision on both sides: what the client is allowed to assert about its own match, and how much the server trusts it. The same MVP caveat as `game-results` applies, but the surface is much wider (per-move timing, card usage sequences), so it is worth deciding deliberately rather than extending the results payload ad hoc.
6. **Challenge progress is server-computed, by design.** The client never submits progress — it reports match outcomes and the server derives everything. Keep it that way; a client-submitted progress field would make every challenge trivially forgeable.
4. **`game-results` trust model** — accepted as client-reported for MVP; harden later if abuse shows up.
