# API Reference — CSI World

Toutes les routes JSON de l'application. Les routes MMO (`/map`, `/battle`, `/api/quest`, `/market`) nécessitent une session Symfony active (connexion web). L'API encyclopédie (`/api`) est publique sauf mention contraire.

---

## Encyclopédie — données de jeu

Base URL : `/api`

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/characters` | Liste tous les personnages jouables |
| GET | `/api/characters/{id}` | Détail d'un personnage |
| GET | `/api/weapons` | Liste toutes les armes |
| GET | `/api/weapons/{id}` | Détail d'une arme (stats, type, description) |
| GET | `/api/armors` | Liste toutes les armures |
| GET | `/api/armors/{id}` | Détail d'une armure (stats, slot, description) |
| GET | `/api/items` | Liste tous les objets |
| GET | `/api/items/{id}` | Détail d'un objet |
| GET | `/api/skills` | Liste toutes les compétences |
| GET | `/api/skills/{id}` | Détail d'une compétence |
| GET | `/api/statuses` | Liste tous les statuts |
| GET | `/api/statuses/{id}` | Détail d'un statut |
| GET | `/api/enemies` | Liste tous les ennemis |
| GET | `/api/enemies/{id}` | Détail d'un ennemi |
| GET | `/api/elements` | Liste tous les éléments. Param. `?gameId=` pour filtrer par jeu |
| GET | `/api/elements/{id}` | Détail d'un élément + ses 4 sections de relations |
| GET | `/api/weapon-types` | Liste les types d'armes |
| GET | `/api/armor-types` | Liste les types d'armures |
| GET | `/api/character-roles` | Liste les rôles de personnages |

### Éléments

**Réponse `/api/elements` :** tableau d'éléments triés par `gameId`, `originalId`, `id`.

```json
[
  {
    "id": 36,
    "gameId": 1,
    "originalId": 3,
    "name": "Feu",
    "color": "#FF0000",
    "iconUrl": "/api/icon/1/0",
    "csi1Exclusive": false,
    "csi2Exclusive": false,
    "csiRogueExclusive": false
  }
]
```

- `gameId` : `0` CSI Forever · `1` CSII Forever · `2` CSI Rogue · `3` Narval Souls.
- `color` : hex `#RRGGBB`, `null` si l'élément n'a pas de couleur seedée.
- `iconUrl` : URL redirigée par `/api/icon/{gameId}/{id}` ; `null` si pas d'icône.
- `csi1Exclusive` / `csi2Exclusive` / `csiRogueExclusive` : **dépréciés**, conservés pour rétro-compat. Utiliser `gameId`.

**Réponse `/api/elements/{id}` :** mêmes champs + bloc `relations` avec 4 sections.

```json
{
  "id": 36,
  "gameId": 1,
  "originalId": 3,
  "name": "Feu",
  "color": "#FF0000",
  "iconUrl": "/api/icon/1/0",
  "csi1Exclusive": false,
  "csi2Exclusive": false,
  "csiRogueExclusive": false,
  "relations": {
    "efficaceContre": [{ "elementId": 38, "multiplier": 2 }],
    "estResistePar":  [{ "elementId": 36, "multiplier": 0.5 }],
    "faibleContre":   [{ "elementId": 44, "multiplier": 2 }],
    "resisteA":       [{ "elementId": 36, "multiplier": 0.5 }]
  }
}
```

- `efficaceContre` : éléments contre lesquels celui-ci inflige plus de dégâts (`mult > 1`).
- `estResistePar`  : éléments qui réduisent les dégâts entrants de celui-ci (`mult < 1` côté attaquant).
- `faibleContre`   : éléments qui infligent plus de dégâts à celui-ci (`mult > 1` côté défenseur).
- `resisteA`       : éléments dont il réduit les dégâts subis (`mult < 1` côté défenseur).
- `multiplier == 0` représente une immunité totale. Les relations neutres (`× 1`) ne sont pas listées.

### Icônes

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/icon/{gameId}/{id}` | Renvoie l'image d'une icône (PNG, redirect) |
| GET | `/api/icons/random` | Renvoie une icône aléatoire. Param. `?gameId=` |

---

## Joueur & Authentification

| Méthode | Route | Auth | Description |
|---------|-------|------|-------------|
| POST | `/api/player/register` | — | Crée un compte. Body : `{username, email, password}` |
| POST | `/api/player/login` | — | Connexion. Body : `{username, password}` → `{token}` |
| GET | `/api/player/me` | Bearer token | Profil du joueur authentifié |
| GET | `/api/player/me/subscription` | Bearer token | Statut d'abonnement Premium du joueur |
| GET | `/api/cloud-saves` | Bearer token | Liste des sauvegardes cloud + quota |
| POST | `/api/cloud-saves/upload` | Bearer token | Upload d'une sauvegarde (multipart : `file`, `gameSlug`, `label?`) |
| GET | `/api/cloud-saves/{id}/download` | Bearer token | Téléchargement du fichier brut |
| DELETE | `/api/cloud-saves/{id}` | Bearer token | Supprime sauvegarde (Wasabi + DB) |
| GET | `/api/player/me/badges` | Bearer token | Badges obtenus |
| GET | `/api/player/me/inventory` | Bearer token | Inventaire complet enrichi (noms + icônes) |
| GET | `/api/player/me/characters` | Bearer token | Personnages et progression du joueur |
| GET | `/api/player/me/characters/{id}` | Bearer token | Détail d'un personnage avec stats calculées |
| GET | `/api/player/{username}` | — | Profil public d'un joueur |

### Inventaire

**Réponse `/api/player/me/inventory` :**
```json
{
  "inventoryLimit": 50,
  "usedSlots": 22,
  "items": [
    { "type": "item",               "id": 1,  "qty": 32, "name": "Millésime d'H2O",    "iconUrl": "/api/icon/4/528" },
    { "type": "weapon",             "id": 1,  "qty": 1,  "name": "Épée longue",         "iconUrl": "/api/icon/4/442" },
    { "type": "armor",              "id": 4,  "qty": 1,  "name": "Relique test",         "iconUrl": "/api/icon/4/512", "slot": 4 },
    { "type": "weapon_instance",    "id": 32, "qty": 1,  "name": "Écuflingue de vitalité","iconUrl": "/api/icon/4/1368" },
    { "type": "equipment_instance", "id": 47, "qty": 1,  "name": "Armure test de vitalité","iconUrl": "/api/icon/4/562", "slot": 3 }
  ]
}
```

**Types d'items :** `item` · `weapon` · `armor` · `weapon_instance` · `armor_instance` · `equipment_instance`  
`slot` n'est présent que pour les armures (base ou instance). `iconUrl` peut être `null` si l'item n'a pas d'icône.

### Abonnement Premium

**Réponse `/api/player/me/subscription` — joueur abonné :**
```json
{
  "active": true,
  "status": "active",
  "tier": "argent",
  "color": "#c0c0c0",
  "expiresAt": "2026-07-11T10:00:00+00:00",
  "startsAt": "2026-06-11T18:20:37+00:00",
  "plan": {
    "name": "CSI World Online Premium",
    "tier": "argent",
    "color": "#c0c0c0",
    "billingPeriod": "monthly",
    "priceCents": 299,
    "priceEuros": 2.99,
    "weeklyMoney": 2000,
    "weeklyPremium": 50
  },
  "weeklyClaim": {
    "available": true,
    "money": 2000,
    "premium": 50,
    "lastClaimAt": null,
    "nextClaimAt": null
  }
}
```

**`weeklyClaim`** — statut des récompenses hebdomadaires associées au plan :
- `available: true` si le joueur peut réclamer (jamais réclamé ou cooldown 7 jours écoulé)
- `money` / `premium` : montants à recevoir (en Dollawrs et Crédits MasterMoney)
- `lastClaimAt` : ISO 8601 du dernier claim, `null` si jamais
- `nextClaimAt` : ISO 8601 du prochain claim possible, `null` si jamais claim donc dispo immédiatement

Le claim côté joueur s'effectue **uniquement** via la session web (`POST /subscribe/claim-weekly`) — pas exposé en API Bearer pour l'instant.

### Cloud saves

**Réponse `/api/cloud-saves` :**
```json
{
  "tier": "platine",
  "quota": 100,
  "used": 3,
  "saves": [
    {
      "id": 12,
      "gameSlug": "csi_forever",
      "fileName": "Save 2026-06-15.zip",
      "label": "Fin de partie",
      "sizeBytes": 3482869,
      "sha256": "ab...64car",
      "uploadedAt": "2026-06-15T10:23:11+00:00"
    }
  ]
}
```

**Quotas par tier** (configurables en admin via `subscription_plan.cloudSavesQuota`) :
- Non-abonné : 0 (pas d'accès)
- Bronze : 5 slots
- Argent : 20 slots
- Gold : 50 slots
- Platine : 100 slots + **versioning** (5 dernières versions par `gameSlug`)

**Upload (`POST /api/cloud-saves/upload`)** : multipart/form-data avec :
- `file` (requis) — binaire ZIP
- `gameSlug` (requis) — identifiant du jeu (ex. `csi_forever`)
- `label` (optionnel) — description libre

Limite de taille : 20 MB par fichier. Le binaire est stocké sur Wasabi (S3-compatible), pas en DB.

**Download** : stream du binaire avec `Content-Type: application/zip` et `Content-Disposition: attachment`.

**Versioning Platine** : sur un compte Platine, uploader une nouvelle sauvegarde pour un `gameSlug` qui en a déjà 5 supprime automatiquement la plus ancienne — tu gardes toujours les 5 dernières versions par jeu.

**Période de facturation** : le champ `plan.billingPeriod` vaut `"monthly"` ou `"annual"`. Règle de bascule lors d'un changement de plan (`reviseSubscription`) :
- Mensuel → mensuel ou annuel : ✓
- Annuel → annuel : ✓
- Annuel → mensuel : **refusé** (HTTP 400 avec message explicite). Une fois engagé sur une facturation annuelle, il faut résilier puis attendre la fin de période pour reprendre du mensuel.

**Réponse — joueur non abonné :** tous les champs (`status`, `tier`, `color`, `expiresAt`, `startsAt`, `plan`) sont à `null` et `active` est `false`.

**Couleurs par tier** (source unique de vérité, partagée par l'application web et le launcher) :
- `bronze`  → `#cd7f32`
- `argent`  → `#c0c0c0`
- `gold`    → `#f5c542`
- `platine` → `#6354d6` (fallback ; rendu visuel principal = dégradé vertical indigo-violet `#8fa3ff → #6354d6 → #4b3aa8` avec léger shimmer animé)

**Tiers holographiques** : `platine` est rendu avec un dégradé animé (indigo → violet) au lieu d'une couleur unie. Le champ `color` retourne le hex du milieu du gradient pour les contextes statiques. Si le launcher peut faire de l'animation CSS, il peut détecter le tier `platine` et appliquer son propre rendu.

`active` vaut `true` ssi le joueur a une souscription en cours de validité (`expiresAt > now`) avec un statut donnant accès au Premium :
- `status: "active"` — abonnement courant, renouvellement automatique en fin de période
- `status: "cancelled"` — abonnement annulé mais période payée non encore écoulée ; accès Premium maintenu jusqu'à `expiresAt`, puis basculera en `active: false`

**Tiers disponibles** (hiérarchie croissante) :
- `"bronze"` — accès Premium de base
- `"argent"` — accès intermédiaire
- `"gold"` — accès complet

Une fonctionnalité gold-locked accepte uniquement `tier: "gold"`. Une fonctionnalité argent-locked accepte `tier: "argent"` ou `tier: "gold"`. Bronze accepte tous les tiers. Le launcher peut afficher un badge/couleur distinct selon le `tier`.

### Badges obtenus

**Réponse `/api/player/me/badges` :**
```json
[
  {
    "slug": "first-blood",
    "displayName": "Première victoire",
    "description": "Remportez votre premier combat PvP.",
    "icon": "https://example.com/uploads/badges/first-blood.png",
    "earnedAt": "2026-06-01T14:00:00+02:00"
  }
]
```

### Déverrouillage d'achievements (serveur à serveur)

```
POST /api/achievements/unlock
Body: { "secret": "<GAME_API_SECRET>", "token": "<player_token>", "gameId": 1, "achievementId": 42 }
```

Retourne `{success: true, alreadyUnlocked: false}`. Vérifié via `hash_equals()` côté serveur — le secret ne doit jamais transiter côté client. Valeur configurée dans `.env.local` uniquement.

---

## Carte MMO

Base URL : `/map`  
Toutes les routes `/map/api/...` nécessitent une session web active.

### Équipement

| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/map/api/character/{id}/equip` | Équipe un item sur un personnage |
| POST | `/map/api/character/{id}/unequip` | Déséquipe un slot |

**Body equip :**
```json
{ "itemType": "weapon_instance", "itemId": 45, "slot": 0 }
```
`itemType` : `weapon` · `armor` · `weapon_instance` · `armor_instance`

### Boutiques

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/map/api/shop/{id}` | Catalogue d'une boutique (génère le cache joueur si expiré) |
| POST | `/map/api/shop/{id}/buy` | Achète un item. Body : `{itemType, itemId, qty?}` |
| POST | `/map/api/shop/{id}/sell` | Vend un item de l'inventaire. Body : `{itemType, itemId, qty?}` |

**Réponse GET shop :** `{weapons: [...], armors: [...], items: [...], refreshAt, playerMoney, shopTitle}`  
Armes et armures sont des instances avec `{id, name, iconId, rarity, stats, affixes, price, slot?, weaponTypeName?}`.

### Terrain et interactions

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/map/api/field/{id}` | Données d'une zone : ennemis, PNJ, événements, quêtes disponibles |
| POST | `/map/api/event/{id}/trigger` | Déclenche un événement de zone |
| POST | `/map/api/item/use` | Utilise un objet de l'inventaire. Body : `{itemType, itemId, characterId?}` |

---

## Combat

Base URL : `/battle`  
Session web requise.

| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/battle/start` | Démarre un combat. Body : `{zoneId, enemyGroupId?}` |
| POST | `/battle/action` | Joue une action. Body : `{action, targetId?, skillId?, itemId?, itemType?}` |
| GET | `/battle/state` | État courant du combat (parties, ennemis, tour, log) |
| POST | `/battle/clear` | Efface le combat en session après victoire/défaite |

**Actions disponibles :** `attack` · `skill` · `item` · `defend` · `flee`

**Réponse `/battle/action` :**
```json
{
  "log": [...],
  "state": "ongoing|victory|defeat",
  "drops": [{ "type": "weapon_instance", "id": 12, "name": "Épée Furieuse", "rarity": 3 }],
  "rewards": { "xp": 120, "money": 45 }
}
```

---

## Quêtes

Base URL : `/api/quest`  
Session web requise.

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/quest/available` | Quêtes disponibles non encore acceptées (pré-conditions vérifiées) |
| GET | `/api/quest/active` | Quêtes actives du joueur avec progression |
| POST | `/api/quest/{id}/accept` | Accepte une quête par son ID |
| POST | `/api/quest/zone/{id}/accept` | Accepte la quête liée à une zone |
| GET | `/api/quest/zone/{id}/next` | Prochaine quête disponible dans la séquence d'une zone |
| GET | `/api/quest/zone/{id}/npc` | Objectif `talk_to_npc` actif pour une zone, s'il existe |
| POST | `/api/quest/zone/{id}/reach` | Notifie qu'une zone a été atteinte (progresse les objectifs `reach_zone`) |
| POST | `/api/quest/interact/{interactId}` | Notifie une interaction avec un objet/PNJ |
| GET | `/api/quest/{id}/progress` | Détail de progression d'une quête active |

**Réponse `/interact/{interactId}` :**
```json
{
  "questProgressed": true,
  "dialogId": 3,
  "rewards": { "xp": 50, "money": 100, "items": [...] },
  "activeQuests": [...]
}
```

---

## Marché joueur

Base URL : `/market`  
Session web requise.

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/market/api/listings` | Annonces actives des autres joueurs. Param. `?type=weapon\|armor\|item&offset=` |
| GET | `/market/api/mine` | Annonces actives du joueur connecté |
| POST | `/market/api/list` | Publie une annonce |
| POST | `/market/api/{id}/buy` | Achète une annonce |
| POST | `/market/api/{id}/cancel` | Retire sa propre annonce (restitue l'objet) |

**Body `/market/api/list` :**
```json
{ "itemType": "weapon_instance", "itemId": 45, "qty": 1, "price": 5000 }
```
`itemType` accepté : `item` · `weapon` · `armor` · `weapon_instance` · `armor_instance`  
Les objets clés (`isKeyItem = true`) ne peuvent pas être mis en vente.

**Réponse listing :**
```json
{
  "id": 12, "seller": "Adrien", "itemType": "weapon_instance", "itemId": 45,
  "qty": 1, "price": 5000, "status": "active",
  "snapshot": {
    "name": "Épée Furieuse", "iconId": 88, "rarity": 3,
    "description": "...", "specialEffect": null, "formula": "ATQ × 2.5",
    "statsSnapshot": { "attaque": 22, "multiplierAttaque": 1.35, ... },
    "affixes": [{ "affix_id": 3, "name": "Fureur", "effects": { "attaque": 12 } }]
  },
  "createdAt": "2026-06-01T14:00:00+02:00"
}
```

---

## Notifications

Base URL : `/api/notifications`  
Session web requise.

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/notifications` | 15 dernières notifications. Réponse : `{unread: int, notifications: [...]}` |
| POST | `/api/notifications/read-all` | Marque toutes les notifications non lues comme lues |

**Champs d'une notification :**
```json
{ "id": 1, "type": "market_sale", "title": "Épée vendue pour 5 000 Dollawrs",
  "body": null, "data": { "price": 5000, "qty": 1 },
  "isRead": false, "createdAt": "2026-06-01T14:00:00+02:00" }
```

**Types existants :** `market_sale` · `quest_complete` · `level_up` · `badge_earned` · `achievement` · `dm_start` · `dm_result`

---

## Coffre & Banque personnelle

Base URL : `/vault`  
Session web requise.

### Coffre (stockage d'objets)

| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/vault/api/deposit` | Déplace un item de l'inventaire vers le coffre |
| POST | `/vault/api/withdraw` | Déplace un item du coffre vers l'inventaire |

**Body :**
```json
{ "type": "weapon_instance", "id": 45, "qty": 1 }
```
`type` accepté : `item` · `weapon` · `armor` · `weapon_instance` · `armor_instance` · `equipment_instance`  
Pour les instances (`*_instance`), `qty` est ignoré et forcé à 1.

**Réponse succès :**
```json
{ "success": true, "vaultUsed": 3, "inventoryUsed": 12 }
```

### Banque (Dollawrs)

| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/vault/api/bank/deposit` | Dépose des Dollawrs sur le compte bancaire |
| POST | `/vault/api/bank/withdraw` | Retire des Dollawrs de la banque |

**Body deposit :** `{ "amount": 5000 }`  
Les dépôts ne sont acceptés qu'entre le **1er et le 15** du mois.

**Body withdraw :** `{ "amount": 5000 }`  
Un retrait entre le **16 et le 31** est taxé à **20 %**.

**Réponse deposit :**
```json
{ "success": true, "money": 3000, "bankBalance": 8000 }
```

**Réponse withdraw :**
```json
{ "success": true, "money": 8000, "bankBalance": 3000, "received": 4000, "taxed": 1000 }
```
(`taxed` = 0 si retrait en première quinzaine.)

---

## Troupe (guilde)

Base URL : `/troupe`  
Session web requise.

### Rôles

`chef` · `bras_droit` · `tresorier` · `elite` · `membre` · `recrue`

### Recherche & Adhésion

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/troupe/api/search` | Cherche des troupes. Param. `?q=<nom>` |
| POST | `/troupe/api/create` | Crée une troupe. Body : `{name, description?}` |
| POST | `/troupe/api/join/{troupeId}` | Envoie une demande d'adhésion |
| POST | `/troupe/api/join-request/{id}/cancel` | Annule sa propre demande |

**Réponse `/troupe/api/search` :**
```json
[{ "id": 1, "name": "Les Gardiens", "description": "...", "memberCount": 7, "emblem": "uploads/troupes/troupe_1.png", "requested": false }]
```

### Invitations

| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/troupe/api/invite` | Invite un joueur. Body : `{username}` |
| POST | `/troupe/api/invite/{id}/accept` | Accepte une invitation reçue |
| POST | `/troupe/api/invite/{id}/decline` | Décline une invitation reçue |

### Gestion des membres (chef / bras droit)

| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/troupe/api/join-request/{id}/accept` | Accepte une demande d'adhésion |
| POST | `/troupe/api/join-request/{id}/decline` | Refuse une demande d'adhésion |
| POST | `/troupe/api/kick/{memberId}` | Exclut un membre |
| POST | `/troupe/api/member/{memberId}/set-role` | Change le rôle d'un membre. Body : `{role}` |
| POST | `/troupe/api/transfer/{memberId}` | Transfère le titre de chef |
| POST | `/troupe/api/leave` | Quitte la troupe |
| POST | `/troupe/api/dissolve` | Dissout la troupe (chef uniquement) |

### Emblème

| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/troupe/api/emblem` | Upload de l'emblème (chef uniquement). `multipart/form-data`, champ `emblem`. Max 2 Mo, formats JPG/PNG/GIF/WebP. |

**Réponse :** `{ "ok": true, "emblem": "uploads/troupes/troupe_1.png" }`

Toutes les autres routes renvoient `{ "ok": true }` en cas de succès, ou `{ "error": "..." }` avec un code 4xx.

---

## Banque de guilde

Base URL : `/troupe/bank`  
Session web requise.

| Méthode | Route | Rôle requis | Description |
|---------|-------|-------------|-------------|
| POST | `/troupe/bank/api/deposit` | tout membre | Dépose des Dollawrs dans la banque. Body : `{amount}` |
| POST | `/troupe/bank/api/withdraw-request` | tout membre | Soumet une demande de retrait. Body : `{amount, reason?}` |
| POST | `/troupe/bank/api/withdraw-request/{id}/approve` | trésorier+ | Approuve une demande |
| POST | `/troupe/bank/api/withdraw-request/{id}/decline` | trésorier+ | Refuse une demande |
| POST | `/troupe/bank/api/withdraw-request/{id}/cancel` | demandeur | Annule sa propre demande |

Toutes les routes renvoient `{ "ok": true }` ou `{ "error": "..." }`.

---

## Death Match

Base URL : `/death-match`  
Session web requise.

### Inscription au tournoi

| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/death-match/register` | S'inscrit à un tournoi. Body : `{subFormat, maxCharsPerPlayer, characterIds: [...]}` |
| POST | `/death-match/unregister` | Se désinscrit du tournoi en cours d'inscription |

`subFormat` : `solo` · `team` — `maxCharsPerPlayer` : `1` · `2` · `4`

### Combat PvP

Base URL : `/death-match/match`

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/death-match/match/{id}/state` | État courant du combat. Réponse : `{state, timeoutAt, battleStatus}` |
| POST | `/death-match/match/{id}/action` | Joue une action. Body : `{type, params?}` |
| POST | `/death-match/match/{id}/surrender` | Abandon immédiat |

**Types d'action (`type`) :** `attack` · `defend` · `skill` · `surrender`

**Body `attack` :** `{ "type": "attack", "params": { "targetCharIndex": 0 } }`  
**Body `skill` :** `{ "type": "skill", "params": { "skillId": 3, "targetCharIndex": 0 } }`

**Réponse `/action` :**
```json
{
  "state": { "phase": "ongoing|finished", "turn": 4, "activePlayerId": 2, "winnerId": null, "parties": {...}, "log": [...], "prizeNotif": { "42": { "place": 1, "title": "Victoire au Death Match !", "body": "..." } } },
  "timeoutAt": "2026-06-04T22:00:32+02:00",
  "battleStatus": "ongoing|finished"
}
```

`prizeNotif` est présent uniquement sur l'action finale (clé = playerId en string). `place` vaut `1`, `2` ou `3`.

**Timeout :** 32 secondes par tour. Après 3 timeouts consécutifs, le joueur est déclaré perdant.

---

## Dialogues

| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/dialog/{id}` | Chaîne de dialogue. Réponse : `{id, title, entries: [{speaker, text, choices?}]}` |
