# Levels, challenges and unlockable animals: build plan

Status: design for Kyle's sign-off. Nothing is built. Mocks: https://aa-progression-review.pages.dev

## 1. Where progression data lives

Players are already Supabase users (anonymous sign-in). The game server already writes `games` and `game_players` with the service role (`apps/server/src/results.ts`). Progression extends that path, so the phone never reports its own XP.

New tables, one additive migration (test project `xdblaljsrmgkwlulzezo`):

| Table | Columns | Who writes |
|---|---|---|
| `player_progress` | `user_id` pk, `xp`, `level`, `animal` (selected), `updated_at` | `record_game` only; `animal` via `set_animal` |
| `player_challenges` | `user_id`, `period` (`d:2026-11-04`, `w:2026-W45`), `challenge_id`, `progress`, `target`, `xp`, `done_at`; pk on the first three | `record_game` only |
| `xp_events` | `user_id`, `game_id`, `source`, `amount`, `created_at`; unique (`user_id`, `game_id`, `source`) | `record_game` only. This is the ledger, and its unique key makes a retried result write award nothing twice |
| `game_players` | add `animal text` and `stats jsonb` (nullable) | server |

- **RLS:** each table reads own rows only (`(select auth.uid()) = user_id`). Players get no insert or update grants.
- **`record_game(game jsonb)`:** a security-definer function, callable only by `service_role`. In one transaction it inserts the game and its seats, then for each human seat:
  - adds the XP lines;
  - advances this period's challenges;
  - recomputes the level;
  - returns the XP lines, the old and new level, and the animals unlocked.
  It does nothing if the game id already exists.
- **Access pattern:** one call per finished game touches at most 3 humans x (1 progress row + 6 challenge rows + 4 ledger rows), all by primary key. Home reads 1 progress row plus at most 6 challenge rows by pk prefix. Nothing loops per player and nothing is unbounded.
- **Animal unlocks are derived, not stored:** unlocked = starters plus every animal whose level is at or below yours. No unlock rows, so a curve change can never strand anyone.
- **`profiles.avatar`** is an unused 4-animal CHECK. Leave it; the selected animal lives on `player_progress.animal`, validated by `set_animal` against the derived unlock set.

**Where stats come from.** The server applies every action through the engine and sees every event, so it counts each player's stats from that stream. A pure `statsFromEvents(events, playerId)` covers rounds won, card plays by id and category, attacks stopped, a minus card handed over, bankruptcy declared, Gridlock wins and point values taken. The app sends nothing but its moves.

**Offline games vs bots earn XP, capped (Kyle, 2026-11-04).** They run on the phone, so the server replays each one and only pays for games a person actually played. See section 1a.

**Known gaps:**
- Skins and roll results still live only on the device (`gacha/purse.ts`). Acorns move to the server in step 9 (section 1b), which purchases need. Skins move with the roadmap's Phase 5 store work.
- Anonymous progress is lost on reinstall until Sign in with Apple/Google ships.

## 1a. Offline games: how the replay check stops XP farming

Kyle: "we need to make sure that they are actually playing the game against the bots so they aren't just farming xp".

**How a game gets checked:**
1. **Ticket.** When an offline game starts, the app asks the server for a ticket: an id, a seed, the server's issue time and the engine version.
   - The game must use that seed, so a player can't shop for an easy deal.
   - Each ticket pays at most once. Its id is the game id, which makes `record_game` a no-op on a retry.
   - Only one ticket can be open at a time, and it expires after 2 hours.
2. **Log.** The app records every input it fed the engine with its `at` time: the player's actions, each bot action and each deadline `advance`. The engine is pure, seeded and clocked by `now`, and `botAction(state, id)` reads only the state, so the log replays exactly.
3. **Replay.** At game end the app uploads the log (200 KB max). The game server replays it through the engine, with a 2-second CPU limit per game, and checks:
   - every bot action equals `botAction` for that state, so a forged log can't make the bots throw the game;
   - every `advance` comes at or after its deadline;
   - the player's actions are legal;
   - the final state matches the upload.

   A log that fails any of these earns nothing.

**Rules a replayed game must pass to earn XP.** The thresholds are constants in the shared module:

| Rule | Threshold | Stops |
|---|---|---|
| Real decisions | At least 15 actions the player made before the clock ran out (bid, check, fold, card, reaction, choice). Turn-clock timeouts and the engine's auto-picks don't count | Letting the clock play the game |
| No AFK games | At most 30% of the player's turns end on the turn clock, and never 5 of their turns in a row | Idling through a game |
| Minimum length | At least 3 finished rounds and 6 minutes of game time | Instant-loss throwaway games |
| Real time passed | Server time between ticket and upload is at least the game time the replay measured | A modified app compressing a game into seconds |
| Not the bot playing for you | Fewer than 90% of the player's decisions match `botAction` for that state, over 20 or more decisions | A modified app auto-playing the human seat with our own bot |
| Daily cap | 5 offline games a day earn XP and count toward challenges, reset with the daily challenges at 08:00 UTC | Grinding past a normal day |
| Same engine | The log's engine version must match the server's | Replays that could drift between releases |

- **Online games follow the same play rules.** The server sees them live, so for the turn rules it counts turns lost to the clock or to the bot covering a disconnected seat. Online games have no daily cap: they run in real time with real opponents.
- **The cap is the backstop.** If every other check were fooled, the most a farmer gets is about 5 games of XP a day (about 750 XP).
- **Tuning before enforcing.** The "bot playing for you" and timeout thresholds run in log-only mode on the playtest for the first week. We record each check's result per game and set the cutoffs from real play, so honest players aren't rejected.
- **What the player sees.** No new screens. A game that earns nothing shows one line in the existing XP box: "Daily limit for bot games", "Too many timed-out turns", or "Start online to earn XP" when the game began without a connection and so has no ticket. Copy goes through the 8 locales.

## 1b. Acorns on the server, and buying animals early

Kyle: animals can also be bought early with acorns. Acorns live only on the phone today, so a purchase there could be faked. Purchases stay hidden until acorns live on the server (step 9).

**Tables.** These are added in step 3's migration and stay unused until step 9:

| Table | Columns | Who writes |
|---|---|---|
| `player_wallet` | `user_id` pk, `acorns` (never below 0), `updated_at` | ledger functions only |
| `acorn_events` | `user_id`, `source` (start, game, level, refund, roll, buy, import), `ref`, `amount`; unique (`user_id`, `source`, `ref`) | ledger functions only. The unique key means each game, level, refund or purchase posts once |
| `player_animals` | `user_id`, `animal`, `price`, `bought_at`; pk (`user_id`, `animal`) | `buy_animal` only |

**What changes for acorns:**
- **Earning:** `record_game` credits game acorns (50 for finishing, 100 more for winning, as today) and level acorns into the ledger. The phone stops minting them.
- **Spending:**
  - `buy_animal(animal)` is a security-definer function. In one transaction it checks the animal is locked for you, reads the price from the shared table, checks your balance, debits it and adds the `player_animals` row.
  - `spend_acorns(kind, ref)` debits a roll before the phone runs it. Roll results and owned skins stay on the device until Phase 5.
- **Unlocked animals:** starters, plus animals at or below your level, plus animals you bought. The server's check at join reads all three.
- **Moving existing balances:** on the first sync the phone sends its balance, and the server credits it once, up to 5,000 (one `import` event). A phone balance can't be trusted, so the cap bounds a forged one. This is a test deployment, so nothing real is lost.
- **Starting acorns:** new players get the 1,000 starting acorns as a `start` event.
- **RLS:** players read their own wallet, ledger and bought animals. All writes go through the functions.

**Prices.** 150 acorns per unlock level:

| Animal | Price |
|---|---|
| Cat | 300 |
| Bunny | 600 |
| Duck | 900 |
| Frog | 1,350 |
| Horse | 1,800 |
| Koala | 2,250 |
| Bear | 2,700 |
| Penguin | 3,300 |
| Capybara | 3,900 |
| Dragon | 4,500 |

Why this is fair next to leveling:
- A new player's 1,000 starting acorns reach Duck at most.
- A regular player earns about 9,600 acorns on the way to level 30: 75 a game on average over about 88 games, plus 3,000 from level rewards. Dragon bought early costs about half of that, or 45 rolls, so buying is a real trade against skins, not a free skip.
- Reaching the level of an animal you already bought pays back half its price (a `refund` event), so no level reward is ever dead.
- If acorns later go on sale for money, animal prices may need a second look so the game doesn't read as pay-to-skip.

## 2. XP and the level curve

| Source | XP |
|---|---|
| Finishing a game | 100 |
| Each round you win | 25 |
| Winning the game | 75 |
| Daily challenge | 100 |
| Weekly challenge | 400 to 500 |

- A typical game (median 6 rounds) pays about 150 XP.
- Level 2 costs 150 XP, so every first game unlocks Cat. Each level after that costs 50 more than the last (250, 300, 350 and so on). From 30 on, each level costs a flat 1,650.
- The curve and reward table live in one shared pure module, used by the server, the SQL function's generated constants and the app.

Unlock order is Kyle's (2026-11-04): bunny, duck, frog, horse, koala, bear, penguin, capybara, dragon. Cat stays first at level 2, since his list left it out; to confirm.

| Animal | Level | Total XP | Buy early (acorns) | Regular (600 XP/day) | Casual (300 XP/day) |
|---|---|---|---|---|---|
| Cat | 2 | 150 | 300 | first game | first game |
| Bunny | 4 | 700 | 600 | day 2 | day 3 |
| Duck | 6 | 1,450 | 900 | day 3 | day 5 |
| Frog | 9 | 2,950 | 1,350 | day 5 | day 10 |
| Horse | 12 | 4,900 | 1,800 | day 9 | day 17 |
| Koala | 15 | 7,300 | 2,250 | day 13 | day 25 |
| Bear | 18 | 10,150 | 2,700 | day 17 | day 34 |
| Penguin | 22 | 14,650 | 3,300 | day 25 | day 49 |
| Capybara | 26 | 19,950 | 3,900 | day 34 | day 67 |
| Dragon | 30 | 26,050 | 4,500 | day 44 | day 87 |

**Player assumptions:**
- **Regular:** 2 games a day (the roadmap's figure), 2 of 3 dailies and 2 of 3 weeklies done.
- **Casual:** 1 game and 1 or 2 dailies.

**Levels between the animals pay:**
- 100 to 300 acorns;
- a free Lucky Star roll at levels 5, 10, 20 and 25;
- past 30: 250 acorns a level and a roll every 5 levels.

## 3. Challenges and rotation

- **The pool lives in code** (the shared module). Each entry has an id, cadence, metric, target, XP and card art. Metrics are the per-game counters from `statsFromEvents`.
- **Rotation needs no cron and no stored schedule.** The period key (daily resets 08:00 UTC, which is 2:00 in Mexico City and 3:00 in New York; weekly resets Monday 08:00 UTC) is hashed with a salt to pick 3 dailies and 3 weeklies, one from each difficulty band. The server and the app compute the same picks, and changing the salt reshuffles everyone.
- **Progress rows are created lazily** on a player's first game of the period.
- **There is no Claim button.** A finished challenge pays on that game's end screen (mock 2). Rerolls and streaks are out of scope.

**Draft pool:**
- **Daily:**
  - Win 3 rounds
  - Play Steal 2 times
  - Play Pickpocket or Highway Robbery 2 times
  - Hand a minus card to a rival
  - Stop an attack with Void
  - Dodge or Reflect a card
  - Play 3 Defense cards
  - Take a point card worth 6 or more
  - Check when you hold the highest stake 3 times
  - Fold 3 times and still finish in the top 2
  - Play Double Value on a card you win
  - Win a round with a bid of 15 or more
- **Weekly:**
  - Win 3 games
  - Win 15 rounds
  - Stop 5 attacks with Void, Dodge or Reflect
  - Win a round during Gridlock
  - Play 20 Grief cards
  - Win a game after declaring bankruptcy
  - Survive Bleeding or Vampire to win that round
  - Collect 40 points

Every metric maps to an engine event that already exists; `statsFromEvents` tests prove each one.

## 4. How levels gate the picker, Collection and the table

- **The engine gives each player their own animal.** `ANIMAL_BY_SEAT` is replaced by an `animal` on each player in the state and in `PublicPlayer`, and `emoteLengths` reads the animal. Two players may pick the same animal.
- **The server checks every animal choice at join.** Quick Match and Private both send the chosen animal. The server looks up the player's level once (the room caches it) and seats them with that animal if it's unlocked. If not, it uses their last valid pick, then Pig. The phone's picker is only a convenience.
- **Home picker and the Collection Use button** both call `set_animal` and cache the choice on the device for offline play.
- **Locked animals show their level and price.** Tapping one in either place opens its sheet with "Buy for N acorns". The button is greyed out, with how many acorns you still need, when you're short. Buying calls `buy_animal`. The price and Buy button appear only once step 9 ships.
- **Collection's Animals tab** lists the 14 animals. Each animal's skins move into its own sheet.

## 5. How bots pick animals

- Each bot draws from all 14 without replacement, minus the humans' picks, so bots never copy each other or you. The draw is seeded from the game seed, so an offline replay matches.
- In offline games, at least one bot wears an animal you haven't unlocked while any remain.
- Only animals whose 3D model has shipped are in the draw. The level road shows a not-yet-built animal at its level as a silhouette, and it unlocks for everyone at or past that level the day it ships (unlocks are derived, section 1).

## 6. Ripple list

Source: `docs/new-animal-ripple-list.md` on `docs/animals-cast-recipe` (PR #225), 397 files.

- **This feature changes the identity path only** (about 25 files):
  - engine `emotes.ts` (`ANIMAL_BY_SEAT`, `DEFAULT_EMOTE_MS`) and `types.ts`
  - server `table.ts` seats and `results.ts`
  - mobile `ui/table/animals.ts` (`animalOf(seat)` becomes `player.animal`) and `ui/table/emotes.ts`
  - mobile `castIcons.ts` (`CastAnimal`), `assets.ts`, `gacha/catalog.ts` (`Animal`, `ANIMALS`, `ITEMS`), `gacha/purse.ts` (`STARTER_LOOKS`, per-species `equipped`) and `gacha/art.ts`
  - mobile `collection.tsx` (`ANIMAL_NAME`), `(hub)/index.tsx` and `ui/hub/parts.tsx` (both read a fixed pig face)
  - mobile `EntryKit.tsx` (`PlayingAs`), `homeWorld.ts` (`KINDS`, `HOMES`), `scene/cast.ts`
  - mobile `game/localTable.ts`, `onlineTable.ts` and `connection.ts`
  - the 8 locale files
  - tests that enumerate the four species: `castIcons.test`, `glbValidator.test`, `assets.test`
- **Left alone:** the 52 per-animal tuning files and the 3D registry rows belong to each animal's own build PR. This feature only reads "which animals have shipped".
- **Unions to unify:** four separate animal unions exist (engine, `castIcons`, catalog, `v5/skins/types`). Step 1 makes the engine's `AnimalKind` the one source and has the others import it.

## 7. PR-sized steps

1. `feat(engine): give each player their own animal`
   - Per-player `animal` in state and view, and `emoteLengths` by animal.
   - Every consumer updated in the same PR. Seats still default to today's animals, so play doesn't change.
2. `feat(engine): add the level curve and challenge pool`
   - The shared pure module: curve, rewards, pool, rotation hash and `statsFromEvents`, with tests.
3. `feat(supabase): add player progress tables`
   - The migration, `record_game`, `set_animal` and RLS.
   - Also the wallet tables from section 1b, so step 9 needs no second migration.
   - High risk: an `interrogate` review plus a security reviewer. The coordinator applies it after merge.
4. `feat(server): award xp from finished games`
   - `results.ts` calls `record_game` and sends each human a `progress` message.
   - The server checks each animal choice at join, and bots draw their animals.
5. `feat(mobile): pick your animal on home and in collection`
   - The picker sheet, the Collection Animals tab and its sheet, the Use button.
   - The chosen animal is threaded into offline and online tables. Copy in 8 locales.
6. `feat(mobile): show levels and challenges`
   - The Home level strip, the Progress page (Challenges and Road tabs) and the end-of-game XP box.
7. `feat(mobile): add the animal unlock celebration`
   - The full-screen splash, built like `RevealSplash` with each animal's render, then its 3D intro emote on Use now.
8. `feat(server): verify offline games by replay`
   - The ticket endpoint, the upload and replay, the section 1a rules (log-only for the first week), and the one-line no-XP reasons in the XP box.
   - On the app side: game logging plus fetching the ticket at start.
9. `feat(wallet): keep acorns on the server`
   - The ledger functions (`buy_animal`, `spend_acorns`, the one-time import) and `record_game` crediting acorns.
   - The app reads its balance from the server and stops minting acorns.
   - The price and Buy button on locked animals.
   - High risk because it moves currency: an `interrogate` review plus a security reviewer. No new migration (step 3 made the tables).

**Dependency order:**
- 1 comes before 4 and 5.
- 2, then 3, then 4, then 6.
- 7 comes after 6.
- 8 comes after 4.
- 9 comes after 3 and 5. Buying stays hidden until 9 ships.
- Each new animal joins the draw and the road through its own build PR.

## Open for Kyle

1. Cat wasn't in your unlock list. Keep it as the first unlock at level 2?
2. Buying early: does 150 acorns per unlock level (Bunny 600, Dragon 4,500) feel right, with half the price back if you later reach that level anyway?
