# Live Battle — Analysis & Improvement Plan (+ WhatsApp Friend Invites)

> Status: Analysis / proposal. Nothing in this document is implemented yet.
> Scope: `BattleController`, battle events/jobs, matchmaking, scoring, Elo, and a
> new **invite-a-specific-user** feature delivered over WhatsApp (Fonnte).

---

## 1. How Live Battle works today

### 1.1 Data model

| Table | Key columns | Notes |
|-------|-------------|-------|
| `battles` | `status` (`waiting`/`playing`/`finished`), `winner_id`, `deck_id` | One row per match. |
| `battle_user` (pivot) | `battle_id`, `user_id`, `score`, `elo_change`, unique(`battle_id`,`user_id`) | 2 rows per battle. |
| `users` | `elo_rating` (default 1200), `battles_played` | Updated on `endBattle`. |

Per-round state (round number, current question, answers, used cards, per-player
cumulative speed, lifecycle locks) lives **entirely in `Cache`** keyed by
`battle:{id}:*`, never in the DB.

### 1.2 Lifecycle

1. **Queue** — [`queue()`](../app/Http/Controllers/BattleController.php#L111): a player picks a deck (≥10 cards required). Finds an existing `waiting` battle on the same deck they're not already in → joins and flips to `playing`, broadcasting `MatchFound` to both. Otherwise creates a new `waiting` lobby.
2. **Ready handshake** — [`ready()`](../app/Http/Controllers/BattleController.php#L67): each player POSTs ready; cached for 120s. When all are ready, a cache lock (`starting_lock`) guarantees exactly one caller invokes `startNextRound`.
3. **Round** — [`startNextRound()`](../app/Http/Controllers/BattleController.php#L151): picks a non-repeated correct card + 3 distractors, caches the question (with `correct_id`), broadcasts `RoundStarted` (`expires_in: 10`), and dispatches a `RoundTimeoutJob` delayed 12s.
4. **Answer** — [`answer()`](../app/Http/Controllers/BattleController.php#L212): validates membership/active state, dedupes per player, computes correctness + server-side time, applies dynamic scoring `max(10, 100 − time×4.4)`, tracks cumulative speed, broadcasts `PlayerAnswered`. When both answered, an atomic `Cache::add` lock reveals `RoundEnded` and schedules `StartNextRoundJob` (+3s).
5. **Timeout** — [`RoundTimeoutJob`](../app/Jobs/RoundTimeoutJob.php): if the round hasn't advanced, force-fills missing answers as wrong and advances. Same atomic `round_{n}_ended` lock prevents double-advance.
6. **End** — after round 10 or deck exhaustion, [`endBattle()`](../app/Http/Controllers/BattleController.php#L357): resolves winner (score, then cumulative-speed tiebreaker), applies standard Elo (K=32), persists `elo_change`/`battles_played`, caches a results payload, broadcasts `BattleEnded`, clears all cache keys.
7. **Surrender / Rematch** — opponent-wins surrender; rematch request/accept/decline create a fresh `playing` battle.

### 1.3 Real-time + supporting infra

- **Reverb** broadcasts 9 events. Per-user events go to `App.Models.User.{id}`; gameplay could use `battle.{battleId}` (channel authorized in [`channels.php`](../routes/channels.php)).
- `broadcast()` is always wrapped in [`safeBroadcast()`](../app/Http/Controllers/BattleController.php#L429) so a Reverb outage never 500s an HTTP request.
- [`CleanupBattles`](../app/Console/Commands/CleanupBattles.php) deletes `waiting` battles older than 10 min.
- Requires **`queue:work`** (delayed jobs) and **`reverb:start`** running.

---

## 2. Strengths

- **Concurrency-safe round progression.** Atomic `Cache::add` locks on `round_{n}_ended`, `starting_lock`, `surrendering_lock`, and `rematch_accepted` correctly prevent the classic double-advance / double-create races when answer and timeout overlap.
- **Server-authoritative scoring & timing.** Time is measured server-side from `round_started_at`; `correct_id` is stripped from `BattleResource` until the player answers — clients can't cheat speed or peek the answer.
- **Resilient broadcasting.** `safeBroadcast` + `ShouldBroadcastNow` keep gameplay responsive and decoupled from Reverb availability.
- **Non-blocking workers.** `StartNextRoundJob` replaced a `sleep(3)`, so a worker isn't pinned per battle.
- **Correct Elo.** Standard expected-score formula, symmetric, persisted per side.

---

## 3. Weaknesses, risks & gaps

### 3.1 Correctness / robustness

1. **No reconnection snapshot for an in-flight round.** `BattleResource` exposes round state, but there's no documented client resync flow; a refresh mid-round risks a desync until the next event.
2. **Cache is the single source of truth for live state.** If the cache store evicts a key (memory pressure, restart, or `CACHE_STORE=array` per worker), an active battle can softlock. Round/answer state should ideally be backed by the DB or a durable store (Redis with persistence).
3. **`current()` only resumes `playing`/`waiting`.** A `RematchAccepted` battle is created as `playing` with no ready handshake — if a client misses the event, there's no REST fallback to discover the new battle id.
4. **Deck mutation mid-battle.** `startNextRound` re-queries `deck->cards()` each round; if an admin edits the deck mid-battle, distractors/used-card logic can behave oddly. Cards aren't snapshotted at battle start.
5. **Two-player assumption is hard-coded** (`count($answers) >= 2`, `$users[0]/[1]`). Fine today, but blocks any future 1vN.
6. **Timeout magic numbers.** `expires_in:10`, job delay `12`, scoring slope `4.4` are scattered literals — should be named config.

### 3.2 Matchmaking / fairness

7. **No Elo-based matchmaking.** `queue()` pairs the first waiting opponent on the deck regardless of rating — a 1200 can be matched against a 1900.
8. **No queue cancellation endpoint.** A player who backs out of `waiting` leaves a stale lobby until `CleanupBattles` runs (≤10 min); meanwhile they can be matched.
9. **Self-match across devices / abandoned-lobby reuse** is only loosely guarded.

### 3.3 Architecture / maintainability

10. **God controller.** `BattleController` owns REST, matchmaking, round engine, scoring, Elo and cleanup (~480 lines). `StartNextRoundJob` even news-up the controller to call `startNextRound`. This should be a `BattleService` / dedicated actions (the job already references a "Phase 6 refactor").
11. **Magic cache keys duplicated** across controller, jobs and resource — no single key registry, so a typo silently breaks state.
12. **No structured battle history.** `history()` returns raw models; round-by-round detail is lost once cache clears (no `battle_rounds` table for analytics/replays).

### 3.4 Observability / ops

13. Logging is `Log::info` string interpolation; no metrics on match wait time, abandonment, timeout rate, or broadcast failures.
14. `RoundTimeoutJob` / `StartNextRoundJob` have no `tries`/`backoff`/failure handling defined.

---

## 4. Improvement plan (prioritized)

### P0 — Correctness & resilience
- **Durable live state.** Mandate Redis (persistent) for the battle cache; document `CACHE_STORE` requirement; add a guard in `startNextRound`/`answer` that, if expected keys are missing on a `playing` battle, fails the battle gracefully and notifies both players rather than softlocking.
- **Resync endpoint.** Formalize `GET /battles/{battle}` as the reconnect snapshot and have the client call it on WS reconnect; ensure `current()` also surfaces freshly-created rematch battles.
- **Job hardening.** Add `public $tries`, `backoff`, and `failed()` to `RoundTimeoutJob`/`StartNextRoundJob`.

### P1 — Fairness & UX
- **Elo-banded matchmaking** with a widening band over wait time (e.g. ±100 → ±300 after 30s).
- **`POST /battles/queue/cancel`** to leave a `waiting` lobby cleanly.
- **Config object** (`config/battle.php`) for rounds, timers, scoring slope, Elo K, matchmaking bands.

### P2 — Architecture
- Extract `BattleService` + `StartNextRoundAction`/`EndBattleAction`; controller becomes thin.
- Centralize cache keys in a `BattleCacheKeys` helper.
- Add a `battle_rounds` table (battle_id, round, card_id, per-user correctness/time) for history, replays and analytics.

### P3 — Observability
- Pulse/Telescope custom metrics: avg match wait, abandonment %, timeout %, broadcast failure count.

---

## 5. NEW FEATURE — Invite a specific user via WhatsApp

**Goal:** Instead of (or in addition to) random matchmaking, a player can invite a
specific friend. The friend receives a **WhatsApp message with a deep link** that
drops them straight into the battle lobby.

### 5.1 Why this fits cleanly
- WhatsApp delivery already exists: [`FonnteService`](../app/Services/FonnteService.php) + [`FonnteWhatsAppChannel`](../app/Channels/FonnteWhatsAppChannel.php), notifications use a `toFonnte()` method, and `users.whatsapp_number` is already populated/verified (`whatsapp_verified_at`).
- `FRONTEND_URL` is configured (`config('services.frontend_url')`) for building the join link.
- Reverb per-user channels already let us push an in-app invite alongside the WhatsApp message.

### 5.2 Design

#### Data model — new `battle_invites` table
```
id
battle_id        -> battles.id (the waiting lobby created by the inviter)
inviter_id       -> users.id
invitee_id       -> users.id (nullable; null if invited purely by phone number)
invitee_phone    -> string nullable (for non-registered / by-number invites)
token            -> uuid, unique (used in the join link, not the battle id)
status           -> enum: pending | accepted | declined | expired | cancelled
expires_at       -> timestamp (e.g. now()+10 min, matches lobby cleanup window)
timestamps
```
Using an opaque `token` (not the raw battle id) in the link avoids enumeration and
lets us expire/revoke an invite independently of the battle.

#### Flow
1. **Create invite** — `POST /api/v1/battles/invite`
   Body: `{ deck_id, invitee_id? , invitee_phone? }`.
   - Validate deck (≥10 cards) and that inviter has a verified WhatsApp number.
   - Create a `waiting` battle (inviter attached) — reuses existing lobby logic.
   - Create `battle_invites` row with a fresh `token` and `expires_at`.
   - Dispatch `BattleInviteNotification` (queued) to the invitee:
     - `FonnteWhatsAppChannel` → WhatsApp message with the join link.
     - `WebPushChannel` + Reverb `BattleInvited` event if the invitee is a registered, online user (in-app accept).
   - Return `{ battle_id, token, status: 'waiting' }`.
2. **Join link** — `FRONTEND_URL/battle/join/{token}`.
   Frontend route loads invite via `GET /api/v1/battles/invite/{token}` →
   `{ status, deck, inviter, battle_id, expires_at }`. If the visitor isn't
   authenticated, send them through login/register first, preserving the token.
3. **Accept** — `POST /api/v1/battles/invite/{token}/accept` (auth required):
   - Guard `status === pending` and not expired; guard invitee identity (if
     `invitee_id` set, must match; if phone-only, bind to current user and
     optionally verify their number matches).
   - Attach invitee to the battle, flip to `playing`, broadcast `MatchFound` to
     both (exactly the existing matched path), mark invite `accepted`.
4. **Decline / Cancel / Expire** — `POST .../decline`, `POST .../cancel` (inviter),
   and an expiry sweep folded into `CleanupBattles` (expire `pending` invites and
   their orphaned `waiting` battles).

#### The WhatsApp message (`BattleInviteNotification::toFonnte`)
```
⚔️ *Battle Challenge!*

{inviter_name} challenged you to a Japanese battle on Manabou!
Deck: {deck_name}

Tap to join (expires in 10 min):
{FRONTEND_URL}/battle/join/{token}
```
Mirror the existing `ReviewReminderNotification` structure: implement `via()`
(WhatsApp when `whatsapp_number` present, WebPush when subscribed), `toFonnte()`,
and `toWebPush()`.

#### New routes (append to [`routes/v1/battle.php`](../routes/v1/battle.php))
```php
Route::post('/battles/invite',                  [BattleInviteController::class, 'create']);
Route::get('/battles/invite/{token}',           [BattleInviteController::class, 'show']);   // public-ish (no membership yet)
Route::post('/battles/invite/{token}/accept',   [BattleInviteController::class, 'accept']);
Route::post('/battles/invite/{token}/decline',  [BattleInviteController::class, 'decline']);
Route::post('/battles/invite/{token}/cancel',   [BattleInviteController::class, 'cancel']);
```

### 5.3 Security & abuse considerations
- **Rate-limit invites** (e.g. `throttle:10,1`) and cap pending invites per inviter to stop WhatsApp spam.
- **Require the inviter's WhatsApp to be verified** (`whatsapp_verified_at`) before allowing by-number invites, consistent with `EnforceWhatsappVerification`.
- **Opaque, single-use, expiring tokens**; `accept` is idempotent via a cache/DB lock like the rematch path.
- **Privacy:** never expose the invitee's phone number in API responses; log only a masked number (Fonnte service already logs minimally).
- **Consent:** only message numbers the user typed or registered users who haven't opted out of battle invites (add a `settings.battle_invites` toggle).

### 5.4 Reuse map (minimal new surface)
| Need | Reuse |
|------|-------|
| Send WhatsApp | `FonnteService` / `FonnteWhatsAppChannel` (no change) |
| Notification shape | Pattern from `ReviewReminderNotification` |
| Lobby + match start | Existing `queue()` "matched" path → factor into `BattleService::startMatch()` and call from both `queue()` and `accept()` |
| Real-time invite | New `BattleInvited` event on `App.Models.User.{invitee_id}` (copy `MatchFound`) |
| Link base | `config('services.frontend_url')` |
| Expiry sweep | Extend `CleanupBattles` |

### 5.5 Implementation checklist
- [ ] Migration: `battle_invites` table + `BattleInvite` model (relations to battle/inviter/invitee).
- [ ] `BattleInviteController` (create/show/accept/decline/cancel).
- [ ] `BattleInviteNotification` (`toFonnte` + `toWebPush`).
- [ ] `BattleInvited` Reverb event.
- [ ] Extract `BattleService::startMatch(Battle, User $joiner)` from `queue()`; reuse in `accept()`.
- [ ] Routes + rate limiting + request validation (`CreateBattleInviteRequest`).
- [ ] Extend `CleanupBattles` to expire stale invites.
- [ ] Tests: create→WhatsApp dispatched (faked), accept→`MatchFound` + `playing`, expiry, identity/expiry guards, rate limit.
- [ ] Frontend: `/battle/join/[token]` page (auth gate → invite preview → accept), and an "Invite friend" entry in the battle UI (pick from friends or enter a number).
- [ ] `.env.example`: confirm `FONNTE_TOKEN` + `FRONTEND_URL` documented (already present).

---

## 6. Suggested sequencing
1. **Invite feature** (self-contained, high user value) — Section 5, ideally after extracting `BattleService::startMatch` (P2 item) so `queue()` and `accept()` share one match-start path.
2. **P0 resilience** (durable state, resync, job hardening).
3. **P1 fairness** (Elo matchmaking, queue cancel, config).
4. **P2/P3** refactor + observability + `battle_rounds`.
</content>
</invoke>
