# game_endpoint

Surface **REST de jeu** du firmware. Le composant n'ouvre **aucun socket
propre** : il greffe ses handlers sur l'instance `esp_http_server`
(port **80**) détenue par `ota_server` — même pattern que
`voice_hook_endpoint`, même pool de workers. `game_endpoint_init(httpd)`
est appelé après `ota_server_init()`, avec le handle rendu par
`ota_server_get_handle()`.

Né en slice 12 pour le seul profil de groupe des indices, le composant a
beaucoup grandi. Il couvre aujourd'hui quatre domaines :

- **configuration runtime** — profil de groupe du moteur d'indices ;
- **scénario** — chargement à chaud de l'IR Runtime 3 (LittleFS + reboot),
  et relais du même IR vers d'autres boîtiers en ESP-NOW ;
- **provisioning mesh** — registre des pairs ESP-NOW (alias → MAC) ;
- **énigmes locales** — armement d'une énigme par step, lecture de l'état
  agrégé, et dépôt de fichiers d'apps pour le shell d'affichage.

## Table des routes

| Méthode | Chemin | Corps attendu | Réponse (succès) |
|---|---|---|---|
| GET  | `/game/group_profile` | — | `200 {group_profile}` |
| POST | `/game/group_profile` | `{group_profile}` | `200 {status,group_profile}` |
| POST | `/game/scenario` | IR Runtime 3 (JSON brut, ≤64 KiB) | `200 {status,steps_count,entry_step_id,bytes,reload}` + reboot |
| POST | `/game/scenario/relay` | `{peers:[…], ir:{…}}` | `200 {relayed:[…],skipped:[…]}` |
| GET  | `/game/peers` | — | `200 {peers:[{alias,mac}]}` |
| POST | `/game/peers` | `{alias, mac}` | `200 {ok,alias,live}` |
| POST | `/game/step` | `{step_id}` | `200 {step_id,armed}` |
| GET  | `/game/puzzle_state` | — | `200 {step_id,solved,code}` |
| POST | `/game/file?path=apps/<id>/<f>` | corps brut du fichier (≤256 KiB) | `200 {status,path,bytes}` |

Toutes les réponses sont `application/json` avec l'en-tête
`Access-Control-Allow-Origin: *`. Les corps d'erreur ont la forme
`{"error":"<message>"}` (sauf le cas `500 runtime_only` de
`POST /game/group_profile`, qui renvoie un objet structuré).

`POST /game/scenario/relay` n'est enregistrée **que** si
`scenario_mesh_init()` a réussi au boot ; sinon elle est absente et le log
le signale. `/game/peers`, `/game/step`, `/game/puzzle_state` et
`/game/file` sont enregistrées en best-effort (un échec d'enregistrement
est loggué mais non fatal). `GET /game/puzzle_state` et `POST /game/step`
ne fonctionnent qu'après l'appel à `game_endpoint_set_puzzle_state()` ;
avant cela elles répondent `503 not_ready`.

---

## Configuration runtime

### `GET /game/group_profile`

Renvoie le profil vivant rapporté par `hints_client_group_profile()`
(`MIXED` par défaut après init).

```json
{ "group_profile": "MIXED" }
```

### `POST /game/group_profile`

Corps JSON, **1..256 octets** :

```json
{ "group_profile": "NON_TECH" }
```

La validation est déléguée à `hints_client_set_group_profile()`
(whitelist `TECH` / `NON_TECH` / `MIXED` / `BOTH`). En cas d'acceptation
la valeur est ensuite persistée en NVS (namespace `zacus`, clé
`group_profile`) — le même slot que `main.c` relit au boot, donc le
changement survit au reboot sans flash.

| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"status":"ok","group_profile":"NON_TECH"}` | accepté, NVS persisté |
| 400 | `{"error":"missing 'group_profile'"}` | champ vide / non-string |
| 400 | `{"error":"invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]"}` | rejeté par la whitelist |
| 400 | `{"error":"malformed json"}` | corps non parsable |
| 413 | `{"error":"body must be 1..256 bytes"}` | corps hors bornes |
| 500 | `{"status":"runtime_only","group_profile":"NON_TECH","warning":"nvs write failed: …"}` | hints client mis à jour mais commit NVS échoué (ne survivra pas au reboot) |

---

## Scénario

### `POST /game/scenario`

Charge à chaud un IR **Runtime 3** complet. Corps = JSON brut,
**1..65536 octets**. Validation firmware **minimale** (le validateur
strict est `runtime3_common.py`, côté gateway) :

- JSON parsable ;
- `schema_version == "zacus.runtime3.v1"` ;
- `steps` = tableau **non vide**.

Sur succès : l'ancien `scenario.json` est tourné en `scenario.bak`, le
nouveau blob est écrit atomiquement sur LittleFS
(`/littlefs/scenario.json`), toute énigme en cours est désarmée, puis un
**reboot différé de ~800 ms** est planifié (le temps que la réponse HTTP
soit flushée). Un write court est rollback depuis `.bak`.

```json
{ "status": "ok", "steps_count": 12, "entry_step_id": "STEP_INTRO",
  "bytes": 4096, "reload": "reboot_pending" }
```

| Statut | Corps | Sens |
|---|---|---|
| 200 | `{…,"reload":"reboot_pending"}` | accepté, écrit, reboot planifié |
| 400 | `{"error":"malformed json"}` | JSON invalide |
| 400 | `{"error":"schema_version must be zacus.runtime3.v1"}` | mauvais schéma |
| 400 | `{"error":"steps must be a non-empty array"}` | `steps` absent/vide |
| 400 | `{"error":"recv failed"}` | erreur de réception socket |
| 413 | `{"error":"body must be 1..65536 bytes"}` | corps hors bornes |
| 500 | `{"error":"littlefs mount failed"}` / `"scenario write open failed"` / `"scenario write short"` / `"out of memory"` | échec stockage |

```bash
curl -X POST http://zacus-master.local/game/scenario \
  -H "Content-Type: application/json" \
  --data-binary @scenario.runtime3.json
```

### `POST /game/scenario/relay`

(Présente uniquement si le mesh ESP-NOW est monté.) Diffuse un IR vers
d'autres boîtiers. Corps **1..65536 octets** :

```json
{ "peers": ["box3", "plip"], "ir": { /* IR Runtime 3 */ } }
```

L'objet `ir` est re-sérialisé compact puis chunké/envoyé en ESP-NOW à
chaque alias résolu via le registre des pairs. Un échec sur un pair
(alias inconnu, timeout) n'interrompt pas les autres : il atterrit dans
`skipped` avec sa raison. Côté récepteur, l'IR repasse par exactement le
même chemin que `POST /game/scenario` (validation + write + reboot).

```json
{ "relayed": ["box3"],
  "skipped": [ { "name": "plip", "reason": "unknown_peer" } ] }
```

Raisons de `skipped` : `unknown_peer` (alias absent du registre),
`timeout` (pas d'ack ESP-NOW), ou tout autre `esp_err_to_name`.

| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"relayed":[…],"skipped":[…]}` | traité (succès partiel possible) |
| 400 | `{"error":"malformed json"}` | JSON invalide |
| 400 | `{"error":"'peers' must be a non-empty array"}` | `peers` absent/vide |
| 400 | `{"error":"'ir' must be an object"}` | `ir` non objet |
| 413 | `{"error":"body must be 1..65536 bytes"}` | corps hors bornes |
| 500 | `{"error":"ir serialize failed"}` / `"response serialize failed"` | échec interne cJSON |

---

## Provisioning mesh

Remplace le round-trip desktop `NvsConfigurator` pour le provisioning
ESP-NOW.

### `POST /game/peers`

Corps **1..256 octets** :

```json
{ "alias": "plip", "mac": "AA:BB:CC:DD:EE:FF" }
```

- `alias` : **1..15 caractères** (limite de clé NVS) ;
- `mac` : format `AA:BB:CC:DD:EE:FF` (6 octets hex).

Persiste en NVS (namespace `peers`, clé = alias, blob = MAC 6 octets — le
format exact que `main.c` reseed au boot) **et** enregistre le pair en
direct dans la table `scenario_mesh` (sans reboot). Si l'enregistrement
live échoue, le pair est tout de même en NVS (`live:false`, effectif au
prochain reboot).

```json
{ "ok": true, "alias": "plip", "live": true }
```

| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"ok":true,"alias":"plip","live":true|false}` | persisté (`live` = enregistré à chaud) |
| 400 | `{"error":"invalid JSON"}` | corps non parsable |
| 400 | `{"error":"alias and mac (string) required"}` | champ manquant/non-string |
| 400 | `{"error":"alias must be 1..15 chars (NVS key)"}` | alias hors bornes |
| 400 | `{"error":"mac must be AA:BB:CC:DD:EE:FF"}` | MAC mal formé |
| 413 | `{"error":"body must be 1..256 bytes"}` | corps hors bornes |
| 500 | `{"error":"NVS write failed"}` | échec NVS |

### `GET /game/peers`

Itère le namespace NVS `peers` et liste les pairs persistés :

```json
{ "peers": [ { "alias": "plip", "mac": "AA:BB:CC:DD:EE:FF" } ] }
```

```bash
curl -X POST http://zacus-master.local/game/peers \
  -H "Content-Type: application/json" \
  -d '{"alias":"plip","mac":"AA:BB:CC:DD:EE:FF"}'

curl http://zacus-master.local/game/peers
```

---

## Énigmes locales

### `POST /game/step`

Arme l'énigme locale attachée à un step de l'IR stocké. Corps
**1..512 octets** :

```json
{ "step_id": "STEP_3" }
```

Le handler relit `/littlefs/scenario.json`, en extrait le binding du step
via `puzzle_binding_from_ir` (+ la `scene` d'affichage, parse lenient),
désarme l'énigme courante, puis arme la nouvelle. Même logique exposée en
interne via `game_endpoint_apply_step()` (lancement d'app sur l'écran du
boîtier).

`armed` indique ce qui a été armé : `"qr"`, `"sound"`, ou `"none"` (step
trouvé mais sans objet `puzzle`).

```json
{ "step_id": "STEP_3", "armed": "qr" }
```

| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"step_id":"STEP_3","armed":"qr|sound|none"}` | armé |
| 400 | `{"error":"missing or empty step_id"}` | champ absent/vide |
| 400 | `{"error":"malformed json"}` | corps non parsable |
| 400 | `{"error":"body must be 1..512 bytes"}` | corps hors bornes |
| 404 | `{"error":"unknown_step"}` | aucun step de cet id dans l'IR |
| 409 | `{"error":"no_scenario"}` | aucun scénario stocké / stockage indispo |
| 422 | `{"error":"invalid_puzzle"}` | objet `puzzle` invalide dans l'IR |
| 503 | `{"error":"puzzle_busy"}` | énigme occupée (fenêtre de teardown QR, après un retry) |
| 503 | `{"error":"not_ready"}` | `puzzle_state` pas encore câblé |

```bash
curl -X POST http://zacus-master.local/game/step \
  -H "Content-Type: application/json" \
  -d '{"step_id":"STEP_3"}'
```

### `GET /game/puzzle_state`

État agrégé des énigmes résolues, pour le polling du dashboard GM.

```json
{ "step_id": "STEP_3", "solved": [1, 3], "code": "125" }
```

- `step_id` : dernier step armé, ou `null` si rien n'a encore été armé ;
- `solved` : ids (1..8) des énigmes résolues ;
- `code` : concaténation des fragments des énigmes résolues.

`503 {"error":"not_ready"}` tant que `puzzle_state` n'est pas câblé.

```bash
curl http://zacus-master.local/game/puzzle_state
```

### `POST /game/file?path=apps/<id>/<file>`

Provisionne un fichier d'app du shell d'affichage (icône, `step.txt`…)
sans reflasher l'image LittleFS. Le chemin est passé en **query param**,
le corps est le **contenu brut** du fichier (**1..262144 octets**, soit
256 KiB).

Contraintes de sécurité sur `path` :

- doit commencer par `apps/` (whitelist) ;
- ne doit contenir aucun `..` (anti-traversal) ;
- ne doit pas finir par `/`.

Toute violation → `403 {"error":"path must be under apps/"}`. Les
répertoires intermédiaires sont créés (`mkdir -p`). Le fichier final est
écrit sous `/littlefs/apps/…` ; un échec d'écriture supprime le fichier
partiel.

```json
{ "status": "ok", "path": "apps/clock/icon.png", "bytes": 1873 }
```

| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"status":"ok","path":"…","bytes":N}` | écrit |
| 400 | `{"error":"missing path param"}` | query `path` absent |
| 400 | `{"error":"size 1..262144 bytes"}` | corps hors bornes |
| 400 | `{"error":"recv failed"}` | erreur réception socket |
| 403 | `{"error":"path must be under apps/"}` | hors whitelist / traversal |
| 500 | `{"error":"open failed"}` / `"write failed"` | échec stockage |
| 503 | `{"error":"storage_unavailable"}` | mount LittleFS échoué |

```bash
curl -X POST "http://zacus-master.local/game/file?path=apps/clock/icon.png" \
  --data-binary @icon.png
```

---

## Format IR : objets `puzzle` et `scene`

Les deux objets sont **optionnels** par step de l'IR Runtime 3, lus à
`POST /game/step` (et `game_endpoint_apply_step`). Les contraintes
ci-dessous sont celles de `puzzle_binding.c` / `puzzle_binding.h`.

### `puzzle` (validation **stricte**)

Un objet `puzzle` invalide bloque le step (`422 invalid_puzzle`).
Absent ou `null` → `armed:"none"`, pas d'erreur.

| Champ | Type | Contrainte |
|---|---|---|
| `id` | nombre | **1..8** (entier) |
| `type` | string | `"qr"` ou `"sound"` (rien d'autre) |
| `fragment` | tableau d'entiers | **1..4** valeurs, chacune **0..9** (chiffres) |
| `codes` | tableau de strings | *(qr)* **1..16** labels, chacun < 32 chars |
| `melody` | tableau d'entiers | *(sound)* **1..32** notes |
| `tolerance` | nombre | *(sound, optionnel)* entier **≥ 0**, défaut **1** |

Énigme QR :

```json
{
  "id": 1,
  "type": "qr",
  "codes": ["BADGE_ROUGE", "BADGE_BLEU"],
  "fragment": [1, 2]
}
```

Énigme sonore (mélodie + tolérance) :

```json
{
  "id": 4,
  "type": "sound",
  "melody": [262, 294, 330, 349],
  "tolerance": 2,
  "fragment": [5]
}
```

### `scene` (parse **lenient**)

Décoration d'affichage : ne bloque **jamais** un changement de step. Les
strings trop longues sont **silencieusement tronquées**, un `effect`
inconnu retombe sur `pulse`. Seul un JSON globalement non parsable échoue.

| Champ | Type | Contrainte |
|---|---|---|
| `title` | string | tronqué à 48 chars |
| `subtitle` | string | tronqué à 64 chars |
| `symbol` | string | tronqué à 16 chars |
| `effect` | string | `pulse` (défaut) \| `glitch` \| `gyro` \| `none` ; inconnu → `pulse` |

```json
{
  "title": "Salle des machines",
  "subtitle": "Trouvez la séquence",
  "symbol": "⚙",
  "effect": "glitch"
}
```

Un step complet combinant les deux :

```json
{
  "id": "STEP_3",
  "puzzle": { "id": 1, "type": "qr", "codes": ["BADGE_ROUGE"], "fragment": [1, 2] },
  "scene":  { "title": "Le coffre", "effect": "pulse" }
}
```

---

## Modèle de concurrence

La lecture de `GET /game/puzzle_state` (et les snapshots
`game_endpoint_get_puzzle_status` / `game_endpoint_get_scene`) est
**lock-free**, par choix assumé pour ce jeu :

- l'armement d'un step (writer) se fait uniquement sur la tâche httpd ;
  les tâches d'énigmes n'écrivent que via `puzzle_state_report` (flags
  `solved[]` montones) ;
- le dashboard GM ne fait que poller ;
- un read concurrent peut donc voir un `solved[id]` à `true` alors que le
  fragment correspondant est en cours d'écriture (lecture torn). De même
  un torn read du texte de `scene` pendant la copie est cosmétique et
  s'auto-corrige au poll suivant.

**Note honnête** : c'est acceptable ici (un seul step armé à la fois,
affichage informatif, polling), mais ce n'est pas thread-safe au sens
strict — pas de mutex sur `puzzle_state` ni sur les statiques
`s_current_*`. À ne pas réutiliser tel quel dans un contexte
multi-writer.

---

## Tests hôte du parser

Le parser `puzzle_binding` a une suite de **10 tests `[pbind]`** qui
tourne sur l'hôte (pas de cible ESP requise), via Unity + cJSON :

```bash
make -C idf_zacus/components/game_endpoint/test/host test
```

Le `Makefile` attend Unity et cJSON dans l'arbre ESP-IDF
(`UNITY_DIR` / `CJSON_DIR`, par défaut sous `~/esp/esp-idf/components/`,
surchargeables en variables d'environnement).

---

## Dépendances du composant

```cmake
REQUIRES
    esp_http_server   # le httpd_handle_t partagé
    json              # cJSON pour le parsing
    nvs_flash         # group_profile + registre des pairs
    hints_client      # validation + push du profil en RAM
    ota_server        # fournit le handle via ota_server_get_handle()
    scenario_mesh     # transport ESP-NOW (relay + provisioning)
    local_puzzles     # armement QR / sonore
    esp_littlefs      # stockage scénario + fichiers d'apps
    freertos
    log
```

Câblage dans `main.c` :

```c
esp_err_t ota_err = ota_server_init();
if (ota_err == ESP_OK) {
    httpd_handle_t httpd = ota_server_get_handle();
    voice_hook_endpoint_init(httpd);
    game_endpoint_init(httpd);
    game_endpoint_set_puzzle_state(&g_puzzle_state); // active /game/step + /game/puzzle_state
}
```
