# BIG EGG — directives de projet

Fichier de référence chargé à chaque session. **À maintenir à jour** : toute
nouvelle règle, tout piège découvert et tout changement de version se consigne
ici. Il sert aussi de point de reprise en cas de perte de contexte ou de reprise
par un autre agent.

Langue de travail : **français** (l'utilisateur écrit en français). Les
identifiants, noms de fonctions et commentaires de code restent en anglais,
conformément au code existant.

---

## 1. Environnement

| | |
|---|---|
| Racine de travail | `E:\GDRIVE\SERVEUR ET PRODUCTION WEB\2025\REBIRTH\BIG EGG` |
| Projet Godot | `big-egg/` |
| Outil de données | `big-egg/tools/game-data-manager/` |
| Godot | `C:\Users\ELR17\Downloads\Godot_v4.7.1-stable_win64.exe\Godot_v4.7.1-stable_win64_console.exe` |
| PHP | `C:\wamp64\bin\php\php7.4.33\php.exe` |
| Node | `C:\Program Files\nodejs\node.exe` |

**Pas de dépôt Git.** Le filet de sécurité est une copie datée dans `_backups/`
avant chaque chantier notable :

```bash
STAMP=$(date +%Y%m%d-%H%M) && DEST="_backups/big-egg_<motif>_$STAMP" \
  && mkdir -p "$DEST" && tar -cf - --exclude=.godot -C . big-egg | tar -xf - -C "$DEST"
```

Ne jamais tuer un éditeur Godot déjà ouvert. Préserver les fichiers et le travail
existants.

---

## 2. Validation — à lancer après tout changement

```bash
# 1. Parse de tous les scripts et scènes
godot --headless --editor --path 'big-egg' --quit

# 2. Smoke test complet (le principal filet)
godot --headless --path 'big-egg' 'res://scenes/main/smoke_test.tscn'

# 3. Retour depuis l'arbre d'upgrades
godot --headless --path 'big-egg' 'res://scenes/main/upgrade_return_smoke.tscn' --quit-after 3

# 4. Outil
node --check big-egg/tools/game-data-manager/assets/app.js
node big-egg/tools/game-data-manager/tests/address_test.js
node big-egg/tools/game-data-manager/tests/localization_test.js
node big-egg/tools/game-data-manager/tests/render_test.js
node big-egg/tools/game-data-manager/tests/dive_editor_test.js
node big-egg/tools/game-data-manager/tests/room_editor_test.js
node big-egg/tools/game-data-manager/tests/delete_cascade_test.js
node big-egg/tools/game-data-manager/tests/map_shape_test.js
node big-egg/tools/game-data-manager/tests/resync_test.js
node big-egg/tools/game-data-manager/tests/employee_editor_test.js
node big-egg/tools/game-data-manager/tests/visual_editor_test.js
node big-egg/tools/game-data-manager/tests/asset_layout_test.js
node big-egg/tools/game-data-manager/tests/enemy_bounds_test.js
# Après avoir ajouté une clé à Loc.UI_FALLBACKS:
node big-egg/tools/game-data-manager/tests/seed_ui_texts.js
php -l config.php ; php -l api.php ; php -l index.php
```

### Pièges d'exécution — coûteux si oubliés

- **Ne pas rediriger une longue commande Godot dans `grep`.** Le pipe bufferise
  et la commande semble bloquée pendant des minutes. Écrire dans un fichier puis
  filtrer : `godot ... > /tmp/s.log 2>&1; grep -E "SMOKE TEST" /tmp/s.log`.
- **Un nouveau script avec `class_name` doit passer par l'éditeur avant tout
  lancement headless**, sinon `Identifier "X" not declared`. Lancer d'abord
  `--headless --editor --quit` pour enregistrer la classe globale.
- Le smoke test tourne en ~6 s. Au-delà, c'est un blocage réel.
- Certains contrôles de combat sont sensibles au timing physique. Un échec isolé
  sur le dash ou la détection ennemie se revérifie par une seconde exécution
  avant d'être traité comme une régression.

---

## 3. Architecture — l'essentiel

### Espace d'adressage

Toute valeur se lit par une adresse en points. Racines : `fact`, `stat`,
`player`, `run`, `meta`, plus les noms de collections du fichier maître
(`enemies`, `ingredients`, `recipes`, `upgrade_nodes`, `customer_profiles`,
`attack_patterns`, `movement_patterns`, `player_configs`).

```
fact.boss_defeated
stat.attack_damage
enemies.fungling.total_killed
recipes.fried_egg.can_cook
```

**Lecture uniforme, écriture étroite** : tout se lit pareil, mais seules les
*cellules stockées* sont inscriptibles. Une valeur calculée a un résolveur, pas
de setter. Trois natures de champs :

| Nature | Origine | Écrivable | Sauvegardé |
|---|---|---|---|
| Donnée d'auteur | JSON maître | non | non |
| Compteur vivant | événements de jeu | oui | oui |
| Valeur calculée | fonction | non | non |

`AddressCatalog` (scripts/core) est la source de vérité des racines, champs
vivants, champs calculés et crochets. `Registry` résout les lectures/écritures et
applique les effets déclaratifs. **Ne pas ajouter de moteur de règles central** :
le registre est un annuaire, chaque système garde sa logique.

### Statistiques — quatre couches

```
final = (base + Σflat) × (coef_base + Σscaling) × (1 + Σglobal) × Π override
      → minimum → maximum → arrondi
```

- `flat` : +10 d'une arme
- `scaling` : pourcentages cumulatifs de progression (+0,25 par upgrade)
- `global` : coefficient général, **séparé pour que les effets temporaires ne
  soient pas dilués par la progression** (une malédiction à −0,5 fait toujours
  moitié-dégâts)
- `override` : vrai multiplicatif isolé, rare

**La liste de modificateurs est la vérité, l'accumulateur est un cache.** Ne
jamais muter une valeur finale : une valeur mutée ne sait pas se dé-muter, et
un effet temporaire ne survivrait pas à une sauvegarde.

Sources nommées `<type>:<id>` (`upgrade:`, `equipment:`, `consumable:`,
`debuff:`, `set:`, `run:`). Retrait par source : `remove_modifiers_by_source()`.

**Ordre d'application imposé** : `set` → `add` → `multiply`, puis tri par source.
Sans lui, le résultat dépendrait de l'ordre d'achat du joueur.

### Effets temporaires

`EffectHolder` sur chaque entité. Une définition combine trois choses :
modificateurs de statistiques, actions périodiques (dégâts), drapeaux de
capacité (`can_move`, `can_attack`, `can_interact`).

- **La paralysie est un drapeau, pas une vitesse nulle** : une vitesse à zéro
  n'empêche ni d'attaquer ni de finir une animation.
- Une instance par application, groupées par `stack_key` → l'expiration par pile
  est gratuite. `stack` = piles indépendantes, `stack_refresh` = expiration
  commune. Défaut de clé : l'identifiant de l'effet (seul cas où `max_stacks`
  garantit un vrai plafond).
- **Les instances ne sont pas adressables** : trois slimes sont trois instances
  d'une définition. Seuls les agrégats remontent vers l'espace d'adressage.
- Les effets du joueur vivent dans `GameState.player_effects` et sont réhydratés
  après chaque changement de scène, comme les assiettes.

### Effets déclaratifs et crochets

Crochets par collection : `on_kill`, `on_spawn`, `on_damage_player`,
`on_cooked`, `on_served`, `on_collected`, `on_purchase`, `on_left_unserved`.

Un effet est un enregistrement **plat** : `{op, path, value}` avec
`op ∈ {set, add, multiply, min, max, toggle}`. **Aucune expression, aucune
condition imbriquée** — dès qu'on en ajoute, ça devient un langage de script, et
un mauvais. Ce qui ne rentre pas reste en GDScript.

### Réactivité

Recalcul global via `state_changed`. **Ne pas construire d'invalidation fine par
chemin** : à cette échelle (15 nœuds, 5 monstres) elle n'apporte rien et ajoute
des abonnements fantômes. `Registry.write()` étant le point de passage unique,
elle reste greffable plus tard.

### Contrôles de cohérence

À l'ajout et au chargement, oui. **Pas de balayage périodique** : un processus
qui « répare » l'état masque les bugs et les rend intermittents. Les invariants
tiennent par construction.

---

## 4. Textes multilingues — **directive active**

> **Tout nouveau texte affiché passe par un identifiant.** Jamais de littéral
> dans le code.

- Texte d'interface : ajouter la clé dans `Loc.UI_FALLBACKS`
  (`scripts/core/loc.gd`) et appeler `Loc.ui("ui.domaine.nom")`.
- Nom d'entité : passer par `DataCatalog.ingredient_name()`, `recipe_name()`,
  `enemy_name()`, `customer_name()`, `upgrade_name()`, `variable_name()`.
- Puis lancer le semis pour ajouter la clé aux fichiers de données (ou l'ajouter
  via l'onglet **Textes** de l'outil).

**La clé de traduction est une adresse** : `ingredients.egg.display_name`,
`stat.attack_damage.display_name`, `ui.restaurant.plates`. Le catalogue se dérive
donc des données — aucun registre manuel. Les collections traduisibles et leurs
champs sont déclarés dans `Loc.TEXT_FIELDS`, en miroir de
`localization_text_fields()` côté PHP et `LOCALIZATION_TEXT_FIELDS` côté JS.

Résolution : `langue courante → langue de repli (en) → défaut d'auteur → texte
du code → la clé`.

Le **défaut d'auteur** est le texte saisi dans les formulaires de l'outil
(français, anglais ou mélangé, peu importe). Il est resynchronisé à chaque
publication. Il n'est **pas destiné à être livré** : la version finale active
`Loc.strict_mode`, qui signale chaque texte non traduit au lieu de retomber
dessus silencieusement.

Langues actuelles : `fr`, `en`. Ajout d'une langue depuis l'onglet Textes.

**Supprimer une entité emporte ses textes.** La clé étant une adresse
(`<collection>.<id>.<champ>`), `dropLocalizationOf` les retire à la suppression.
Sans ça l'onglet Textes garde des lignes qui ne nomment plus rien et le semis
les réécrirait indéfiniment.

**Reste à migrer** : `hud.gd` (~9 textes), l'arbre d'upgrades (~6).
`dungeon.gd` est migré.

---

## 5. Synchronisation des données

En changeant le schéma, garder alignés :

1. `DataCatalog.SCHEMA_VERSION` (Godot)
2. Valeurs de secours intégrées de `DataCatalog`
3. Normalisation / validation Godot
4. `AddressCatalog` (racines, champs vivants, calculés, crochets)
5. `Loc.TEXT_FIELDS` si des textes sont concernés
6. `data/game_data_master.json`
7. `tools/game-data-manager/data/draft.json`
8. `DATA_SCHEMA_VERSION` (PHP)
9. Normalisation PHP (miroir de la validation Godot)
10. Champs de l'éditeur JS
11. Tests de régression
12. Ce fichier et le `README.md` de l'outil

Versions actuelles : **schéma de données 19**, **sauvegarde 9**.
Contenu : 5 ingrédients, 5 plats, **5 monstres**, **3 visuels**, 3 profils clients,
7 biomes, 5 dimensions, 8 récompenses, 6 éléments de salle,
36 statistiques, **27 nœuds d'upgrade**, 5 employés, 8 facts. Les salles sont écrites par l'utilisateur.

**Page blanche assumée (18/08/2026)** : toutes les salles et les cinq gardiens
ont été supprimés à la demande de l'utilisateur, qui réécrit le contenu case par
case. **Godot retombe alors sur `_generated_rooms`** — 4 cases par dimension,
dernière case clôturante. Conséquence à connaître : dès qu'**une seule** salle
est écrite, le filet ne s'applique plus et la colonne vaut ce qui est écrit.
Écrire une case sans `ends_dimension` rend donc la plongée interminable ;
l'outil le signale.

**Godot valide indépendamment** : une donnée invalide produit un avertissement et
bascule sur les valeurs de secours plutôt que de planter. Préserver la
compatibilité des sauvegardes chaque fois que c'est praticable.

---

## 5b. Combat — points de conception hérités

- **Couches physiques** : joueur `1`, ennemis et murs `2`, ramassables `4`.
- L'attaque du joueur fait une requête physique large puis filtre par portée et
  par angle : c'est un **éventail vers l'avant**, pas un cercle. Les API de
  dégâts prennent direction et force, donc joueur et ennemis appliquent le même
  recul dégressif.
- **Le filtre de portée se mesure au corps de la cible, jamais à son centre.**
  `Enemy.body_radius()` vaut 18 × `size_multiplier`, donc 36 px pour un monstre
  agrandi ×2: son centre sort d'allonge alors que son flanc est collé au joueur.
  L'angle se compare en retirant la demi-ouverture angulaire du corps, sinon un
  gros monstre de flanc est refusé bien qu'il occupe le cône. Le même rayon sert
  à la collision et à la portée, pour qu'ils ne puissent pas diverger.
  **La règle vaut dans les deux sens.** Une créature mesure elle aussi sur le
  corps du héros : `Player.body_radius()` vaut 19 (le cercle dessiné, contour de
  la capsule de collision 16 × 38), `Enemy.reach_to_target()` porte l'allonge
  restante, et `_try_attack_hit` retire la demi-ouverture angulaire du corps
  comme le fait l'arme. Sans ça une créature collée au héros se croyait hors
  d'atteinte : `direct_contact` a une portée de 40 px alors que deux corps en
  collision sont à 46–55 px de centre à centre.
  **Portée mesurée du centre de l'attaquant au corps de la cible** — jamais de
  corps à corps, sinon les deux règles ne seraient plus symétriques.
  La **détection** (`_can_detect_target`) reste au centre : c'est de la vue, pas
  de l'allonge.
- Les ennemis testent **distance et angle** avant de détecter. Mouvements sans et
  avec détection choisis indépendamment, puis orientés par `chase`/`avoid`/`ignore`.
- La géométrie d'attaque vit dans des **patterns réutilisables**, pas dans la
  fiche du monstre : `direct_hit` résout au contact, `burst` fige origine et
  direction, `sticky` suit le héros jusqu'à l'impact, `swipe` fait tourner son
  secteur pendant `active_duration`. Chaque état a un chemin de fin explicite,
  sinon un visuel reste accroché. Recevoir un recul annule l'état et rend 65 %
  du temps de recharge.
- `ShapeFactory` mappe les formes du catalogue vers les polygones de dessin
  **et** les formes physiques : aperçu navigateur, silhouette et collision
  viennent d'un seul réglage.

---

## 5c. Structure d'une plongée — construit

```
dimension (une colonne nommée) → cases écrites une à une
```

**La salle est la seule unité écrite.** Elle nomme son biome, choisit ses
créatures, ses récompenses, ses conditions d'accès, sa sortie, et déclare si
elle termine la dimension. Le biome n'est qu'une **palette** (habillage + table
de repli). La dimension n'est qu'une **colonne nommée** : identité, rang dans le
cycle, crochets. Rien d'autre.

`DiveMap` (scripts/core) répond seul à « où en est la plongée » et « qu'est-ce
qui vient ensuite ». Il ne connaît pas la scène du donjon.

| Portée | Champ | Rôle |
|---|---|---|
| `meta` | `dimension_index` | rang dans le cycle, boucle après la dernière |
| `meta` | `gate_pending` | épreuve convoquée, écrite par un crochet `on_blocked` |
| `run` | `dimension_id` | **figée à l'entrée** de la plongée |
| `run` | `level` | rang de la case, à partir de 1 |
| `run` | `room_id` | salle réellement traversée, **figée** elle aussi |
| `run` | `special_used` | combien de fois chaque salle alternative est sortie |

**Deux choses sont figées dans `run`, pour la même raison.** La dimension :
sans ça, franchir une porte après avoir gagné la fourchette maîtresse changerait
de dimension en cours de plongée. La salle : sans ça, chaque relecture
retirerait au sort le remplacement par une salle alternative.

### La règle qui tient tout

> **Zéro ou vide sur une salle veut dire « demande au-dessus ».**

Effectif à 0 → statistique + marche du biome. Table de monstres vide → table du
biome, puis tout le bestiaire. Choix de récompense à 0 → statistique.

Il n'y a que **deux niveaux**: la salle, puis le défaut global. Le biome n'entre
dans aucun de ces calculs.

**Le repli doit être visible là où on écrit.** Le formulaire d'une case affiche
ce qu'elle héritera réellement, et la grille marque d'un `∅` toute case qui n'a
ni effectif ni table — sans quoi elle tirerait dans tout le bestiaire sans que
personne le voie. Chaque en-tête de colonne annonce combien restent à régler.

**Le biome est une pure palette** (schéma 13): nom, description, trois couleurs.
`room_count`, `enemies` et `population_bonus` ont été supprimés. Un biome
n'habille plus qu'un décor.

### Trois concepts fusionnés en un

Ce qui était réparti sur la dimension vit maintenant sur la case :

| Avant, sur la dimension | Maintenant, sur la case |
|---|---|
| `biomes` (liste ordonnée) | chaque case nomme son biome |
| `boss` + trois facteurs d'échelle | une créature du bestiaire posée dans la case, et `ends_dimension` |
| `gate_level` / `gate_fact` / `gate_dimension` | `conditions` sur la case scellée, + crochet `on_blocked` |

**Le verrou n'existe plus comme mécanique séparée** : une case scellée est une
case dont les `conditions` ne sont pas remplies. Son crochet `on_blocked` écrit
`meta.gate_pending` — c'est ainsi qu'une épreuve se convoque, par un effet
déclaratif et **aucun code dédié**.

**Le boss n'existe plus comme entité séparée** : on crée une créature dans le
bestiaire, on la pose dans une case, on coche « termine la dimension ». Nettoyer
cette case appelle `complete_dimension()`. Une colonne doit déclarer **exactement
une** fin, sinon la plongée ne peut pas se terminer — l'outil le signale.

### Sorties et raccourcis

Une case déclare où mène sa porte :

- `exit_offset` (défaut 1) : nombre de cases franchies. `5` en saute 4 — c'est
  le tunnel secret.
- `exit_level` (défaut 0) : adresse directe, **prioritaire** sur le pas.
- Une sortie au-delà de la colonne termine la plongée au lieu de sortir de la
  grille.

### Salles alternatives

Une salle sans `dimension` n'occupe aucune case : elle survient en **remplaçant**
une case normale, selon `replaces = [{dimension, level, chance}]`. Elle porte les
mêmes `conditions` que n'importe quelle salle — une alternative fermée n'est pas
éligible.

**La désignation est positionnelle** : « la case N de telle dimension ».
Insérer ou retirer une case décale ce que les remplacements visent.
`renumberColumn()` refait la suite à chaque suppression.

**Arbitrage** — la probabilité la plus **haute** décide s'il y a remplacement,
puis le choix se fait au poids proportionnel. La somme n'entre jamais dans le
calcul, donc **dépasser 100 % n'est pas une erreur**. Contrepartie : ajouter une
alternative n'augmente pas la fréquence des salles spéciales, elle dilue les
autres. `max_per_run` plafonne, `0` valant « sans limite ».

### Le reste

- **Renforts** : durée > 0 → vagues régulières étalées ; durée = 0 → un bloc
  quand la vague d'ouverture faiblit. Pool distinct de celui d'ouverture. Une
  salle n'est nette que lorsque **les deux budgets** sont épuisés.
- **Récompenses** : tirage pondéré sans remise dans une offre, avec remise entre
  deux. Source `run:`.
- **Modificateurs de salle** : source unique `run:room`, posés en entrant,
  retirés en sortant.
- **Assurance** : tirage sur les **unités**, pas sur les types.

### Une seule page dans l'outil

La page **Salles** porte la grille (une colonne par dimension), le formulaire de
case, et — en cliquant un en-tête de colonne — le formulaire de dimension.
**Il n'y a plus d'onglet Dimensions**, et le panneau de liste à gauche se replie
sur cette page : la grille est déjà la navigation.

### Filet de secours

`DataCatalog.ROOMS` par défaut est produit par `_generated_rooms` : quelques
cases par dimension, la dernière déclarant `ends_dimension`. C'est un **filet**,
pas un miroir du contenu réel, qui vit dans le fichier maître.

Le budget de salle reste un **plafond, pas une garantie** : quand les
`spawn_max` ne peuvent pas l'absorber, une salle plus petite est valide.

### Écrit mais inerte

Les **éléments de salle** (`room_elements`) et les **comportements**
(`behaviours`) sont écrits, validés et publiés. **Le jeu ne les fait pas encore
apparaître ni ne les interprète.**

---

## 5d. Trottoir et file d'attente — construit

Un client ne surgit plus devant une table: il **passe sur le trottoir**, décide
de s'arrêter ou non, fait la **file**, puis entre quand une table entière est
libre et propre.

```
STROLLING → (décision devant la porte) → QUEUED → ARRIVING → WAITING → EATING → LEAVING
                     ↓ refus                ↓ patience épuisée
              continue son chemin        s'en va + pénalité de réputation
```

**La décision appartient au passant, le restaurant ne fait qu'appliquer.**
`evaluate_queue_join()` retourne un verdict avec sa raison, ce qui la rend
testable une par une. Cinq raisons de passer son chemin:

| Raison | Condition |
|---|---|
| `closed` | le restaurant est fermé |
| `queue_full` | la file atteint `queue_capacity` |
| `too_expensive` | rien sur le menu n'entre dans son budget |
| `out_of_stock` | le stock ne couvre pas les gens devant lui **plus lui** |
| `too_slow` | l'attente estimée dépasse ce qu'il accepte |

**Rien d'aléatoire**: devant le même restaurant, le même passant décide toujours
pareil.

- **Il compte les gens devant lui.** `_stock_covers(recettes, file + 1)` — c'est
  ce qui empêche une file d'attendre pour un plat qui ne sera jamais cuisiné.
- **Il juge le débit réel, pas une promesse.** `served_rate()` lit les
  horodatages de service sur `throughput_window` secondes. Sans historique
  suffisant, l'attente estimée vaut zéro: sinon aucun restaurant neuf ne verrait
  son premier client.
- **Attente acceptée** = `patience × queue_tolerance × (0,5 + hunger)`. C'est
  aussi le compte à rebours avant qu'il renonce — il attend exactement ce qu'il
  avait jugé acceptable, pas une seconde de plus.

**Le trottoir occupe sa propre bande, entre le bandeau du HUD et le mur nord.**
La salle démarre à `y = 200` et absorbe le décalage sur sa **hauteur**, pas sur
sa position basse, qui reste collée à l'écran. `ENTRANCE` se dérive de
`SIDEWALK_Y` pour que déplacer la bande n'oublie pas la porte derrière elle. La
bande est dessinée: sans elle la file flottait dans le vide.

Statistiques: `sidewalk_traffic` (passants/minute), `queue_capacity`,
`throughput_window`, `popularity_loss_queue`.
Traits de profil: `hunger`, `queue_tolerance`.

**Une table est un tabouret**: une place, un client. Les emplacements se
déduisent d'une grille (`table_center()`, 5 colonnes, jusqu'à 25), donc ouvrir
une table de plus ne demande aucune position écrite à la main. Le nombre ouvert
est la statistique **`table_count`** (base 2) et non une liste d'upgrades
nommées: les nœuds `second_table`/`third_table` la modifient comme n'importe
quel bonus. Une table verrouillée **n'existe pas** — ni dessin ni collision;
`visible_table_indices()` porte la règle pour qu'elle soit vérifiable au lieu
d'être enfouie dans `_draw`.

**Un magasin vide vide la file, il ne la fait pas défiler.** `_seat_from_queue`
refuse de faire entrer qui que ce soit quand `_can_accept_arrivals()` est faux,
sinon chaque client franchissait la porte et repartait aussitôt, un par un. La
fermeture appelle `_disperse_queue()` — sans pénalité de réputation, car ce
n'est pas un abandon de leur part.

### L'ardoise — construite (phase 1), **débloquée par upgrade**

> **Toute phase de la brigade est un déverrouillage, jamais un acquis de départ.**
> Le jeu de base doit rester entier sans elle. Un compte à **zéro** dit « ce
> poste n'existe pas encore », et une upgrade l'ouvre — comme `table_count`.

**Une seule exception, décidée le 20/08/2026 : la console.** Elle est livrée avec
le restaurant (`management_console`, base **1**). La raison est que ce n'est pas
un poste de travail mais le **tableau de commande** du magasin : elle porte la
carte du jour, l'interrupteur d'ouverture et l'embauche. Une porte d'entrée ne se
déverrouille pas — sans elle, le four ne saurait plus quoi proposer et un poste
d'employé ouvert par une upgrade resterait impossible à pourvoir. La statistique
reste lue partout : un auteur peut toujours écrire un restaurant sans pupitre en
la mettant à 0, et le noeud d'upgrade qui la vendait a été **supprimé** (un noeud
qui accorde ce qu'on possède déjà est un piège). `first_server` a été rebranché
sur `wider_menu` à sa place.

**Deux régimes cohabitent.** Sans ardoise (`order_board_slots` à 0, le départ),
une commande lue s'oublie après `order_memory_seconds` : c'est le jeu actuel,
intact. Avec, la repérer l'inscrit définitivement.

**Une commande devient alors une donnée partagée.** `GameState.order_board`
porte une ligne par table : `{table, recipe, written_at}`. Rien d'autre — l'état
d'avancement se **déduit** de la salle (une assiette pleine de cette recette
existe-t-elle ?) au lieu d'être recopié, et les champs des phases suivantes
n'existent pas tant que personne ne les écrit.

- **L'ardoise pleine retombe sur la mémoire.** Un tableau saturé ne doit jamais
  rendre une commande **moins** lisible qu'avant qu'on l'achète : une upgrade ne
  peut pas dégrader le jeu de base.
- **Ce qui était su bascule au déverrouillage** au lieu d'être perdu.
- `player_vision` reste : c'est la distance de repérage, dans les deux régimes.
- **Une ligne par table.** Un tabouret, un client, une commande : l'indice de
  table est la clé. Une nouvelle commande à la même table remplace l'ancienne.
- **Quatre effacements**, tous explicites : le service, le départ, la nouvelle
  commande, et la perte d'une table (`prune_order_board`).
- **`order_board_slots`** (base 6) est le seul frein : une ardoise pleine refuse
  d'inscrire, et `write_order_line` rend `false` plutôt que d'échouer en silence.
- Le panneau (`OrderBoard`, `scripts/restaurant/order_board.gd`) est **créé en
  code** comme les murs — ajouter une ligne ne demande aucune retouche de scène.
  Il ne décide de rien, il montre.

### Les postes et la console — construits (phase 2)

**La cuisson n'est plus un champ unique.** `restaurant.stations` porte une liste
de postes `{kind, prop, recipe, remaining, total}` ; `stove_count` (base **1**)
dit combien sont ouverts, `MAX_STATIONS` = 4. Les emplacements se déduisent
d'une colonne (`station_center`), comme les tabourets : ouvrir un four de plus
ne demande aucune position écrite à la main. La cuisine tient désormais **deux
colonnes** — cuisson à gauche (x 470), service à droite (x 620).

- **Un poste verrouillé n'existe pas** : ni dessin, ni collision, ni interaction.
- `InteractiveProp.station_index` (défaut -1) permet à un poste de dire lequel il
  est. À -1 le niveau est interrogé comme avant, **ce qui laisse le donjon
  intact** — c'est la même classe d'accessoire des deux côtés.
- Tenir l'action près d'un poste n'accélère que **celui-là** ; les autres
  avancent à `cook_idle_rate`. Le bouton d'annulation suit le poste tenu, sinon
  le premier occupé.
- Chaque poste occupé **réserve une assiette** (`_plates_in_use`), sinon deux
  cuissons produiraient plus d'assiettes que la vaisselle n'en compte.

**La console de gestion** (`management_console`, base **1**) porte le menu du
jour, un tableau de bord (état, menu, débit réel par minute — `served_rate()`
n'était visible nulle part), un **interrupteur d'ouverture** et **l'embauche**.
La fermeture volontaire **prime sur l'approvisionnement** : `manually_closed` est
distinct de `day_closed`, qui est subi. Démonter la console rouvre le magasin —
elle ne peut pas laisser le rideau baissé derrière elle.

**Trois onglets, une grosse icône, un voyant** (`RecipeMenu.Section`). Magasin,
carte et brigade n'ont rien à voir l'un avec l'autre : empilés dans une seule
liste ils faisaient un rouleau interminable. L'icône du haut dit de quoi parle
l'onglet avant qu'on lise quoi que ce soit, et sur le magasin **c'est le voyant**
— vert allumé, orange éteint, avec un halo qui respire. Les icônes sont dessinées
(`ConsoleIcon`), comme tout le reste : aucun fichier d'art n'est nécessaire, et
un visuel pourra les remplacer plus tard. Au clavier, les côtés changent d'onglet
sur la barre et actionnent la ligne ailleurs.

**Un seul panneau, deux modes** (`RecipeMenu.Mode`). Le four demande « quel
plat », la console demande « comment tourne le magasin ». Ce qu'ils partagent —
la liste, la navigation clavier, le focus — n'est pas trivial ; ce qui les sépare
tient dans le mode. Le panneau **ne décide de rien** : il émet
(`open_toggle_requested`, `hire_requested`, `dismiss_requested`), le restaurant
applique. C'est ce qui lui permet de porter l'interrupteur sans rien savoir du
magasin.

**Le four ne propose que la carte du jour.** Depuis que la console existe,
cuisiner un plat qu'on n'offre à personne n'a plus de sens. La console, elle,
montre tout le catalogue — c'est là qu'on décide de la carte. Une carte vidée
n'est pas une impasse : la liste du four le dit au lieu de rester vide.

**La brigade s'embauche là**, catalogue entier affiché même sans poste ouvert :
c'est ce qui apprend au joueur ce qu'une upgrade lui donnerait. Une ligne refusée
reste pressable et le refus dit **pourquoi** (fiche fermée, pas de poste, pas
d'argent) — un bouton grisé ne le dirait jamais. Les `conditions` d'une fiche
d'employé sont enfin lues : le champ existait dans l'outil sans vouloir dire
quoi que ce soit.

### L'étagère, l'épicerie, la fenêtre — construits (phases 3, 4, 5)

**L'étagère est dressée devant la colonne des fours** et couvre la hauteur des
`SHELF_STOVE_SPAN` premiers postes : ses emplacements s'empilent verticalement,
leur écart se resserre pour tenir sur le meuble, et le cuisinier attend **à
droite de son four** — le va-et-vient four → étagère tient sur un axe. La
géométrie se déduit des postes (`_place_shelf`), donc déplacer la colonne emmène
l'étagère avec elle, et `InteractiveProp.resize()` fait suivre la collision.

**L'étagère a ses propres emplacements** (`shelf_slots`, base 3) : le nombre
d'assiettes possédées et la place pour les poser ne sont plus la même
contrainte. `Player.current_target` est extrait de `interact()` et rafraîchi à
chaque frame ; l'assiette visée porte un **halo**, ce qui lève l'ambiguïté quand
plusieurs plats attendent. Une assiette en main coupe la visée.

**L'épicerie raffine, elle ne cuisine pas** (`spice_station_count`, base 0). On y
dépose un plat cuit, il en ressort **supérieur à coup sûr** — drapeau `Dish.superior`,
prix multiplié par `superior_payout` (base 1.5, monté par upgrades). Le genre
d'un poste **dépend de son rang** (`station_kind`) : les fours d'abord, les
épiceries ensuite, donc ouvrir une épicerie ne déplace aucun four. Annuler un
raffinage rend **le plat**, pas ses matériaux : il était déjà cuit.

**La fenêtre sur la rue** (`drink_counter`, base 0) : **personne ne la tient, on
la remplit.** `GameState.counter_drinks` est une réserve garnie d'avance, et
`passerby_reached_door` tente la vente **avant** la file — un verre pris sur le
trottoir n'occupe ni table, ni file, ni vaisselle. Un comptoir vide fait hausser
les épaules : **aucune pénalité de réputation**, c'est une soupape et pas une
source de pression.

### La brigade en données — construite (phase 6)

Collection **`employees`**, du même rang que `enemies` : racine d'adressage,
compteurs vivants, champ calculé `hired`, crochet `on_hired`, textes, et une
page dans l'outil. `EMPLOYEE_SKILLS` est un **vocabulaire fixe** miroir des trois
côtés — ajouter une compétence est trois lignes, jamais un changement de schéma.

- **La spécialisation n'est pas un champ.** Un cuisinier avec
  `station_affinity.spices = 1.6` *est* un épicier. Le formulaire ne propose les
  affinités de poste qu'aux cuisiniers, et les compétences de salle qu'aux
  serveurs.
- **Slots par statistique** : `server_count` / `cook_count`, base **0**.
- **Les `modifiers` travaillent gratuitement**, sous la source `employee:<id>` :
  le renvoi appelle `remove_modifiers_by_source()` et tout part avec.
- **La portée est déclarée par la fiche**, défaut `dimension` : l'employé se
  rachète au cycle suivant. C'est la seule dépense récurrente, et elle est
  voulue. Un slot refermé congédie l'employé en trop (`prune_employees`).

### Les serveurs et les cuisiniers — construits (phases 7 et 8)

**Un seul script pour les deux rôles** (`scripts/restaurant/employee.gd`) : ils
marchent pareil et choisissent leur tâche pareil, seule la liste des tâches
change. **Aucune coordination centrale** — chacun note les tâches disponibles
*pour lui* et réserve ce qu'il vise, donc deux serveurs aux profils opposés se
répartissent le travail sans répartiteur.

| Tâche | Note | Réservation |
|---|---|---|
| Prendre une commande | `1.0 + attentiveness` | la ligne apparaît sur l'ardoise |
| Porter une assiette prête | `1.2 + pace` | l'assiette est réclamée (`_is_claimed`) |
| Débarrasser | `0.8 + tidiness` | idem |
| Cuisiner | tâche unique du cuisinier | la ligne prend un `station` |

- **Le serveur écrit sur l'ardoise par le même appel que le joueur.** Une ardoise
  pleine le laisse repartir sans rien casser.
- **Le charisme se paie sur ce qu'il sert lui-même**, pas en bonus global.
- **Trois sources font avancer un poste, la meilleure gagne** : le joueur qui le
  tient, le cuisinier qui y travaille (`craft` × affinité de poste × affinité de
  plat), ou le four autonome. C'est ce qui laisse le joueur contribuer sans
  jamais gêner.
- **Le cuisinier dresse sur l'étagère** et la ligne passe à `ready`.

**Le cuisinier tient son four, sort le plat et le porte.** Trois temps
(`Stage.GO → TEND → CARRY`), et le plat sort **sur le four** — il se téléportait
sur l'étagère dès qu'un cuisinier était affecté, ce qui faisait des deux
emplacements un seul à l'écran et vidait la cuisine de tout trajet. Le cuisinier
lâchait aussi son poste à l'allumage, d'où deux cuisiniers plantés au même
endroit et un seul capable d'enchaîner plusieurs fours. Rien ne réserve le plat :
si le serveur ou le joueur passe avant, le cuisinier le constate et retourne à
son poste — un poste **par four** (`station_center(rang) + COOK_REST_OFFSET`).

**Une réservation doit couvrir la ressource visée, pas seulement la commande.**
La ligne d'ardoise était réservée, le **four** ne l'était pas : `station_is_busy`
ne devient vrai qu'à l'allumage, donc pendant la marche deux cuisiniers
choisissaient le même poste et le second arrivait pour rien.
`_free_unclaimed_station()` applique aux postes ce que `_is_claimed` fait déjà
aux assiettes, et la validité d'une tâche de cuisson tient maintenant aussi au
poste — le joueur peut l'allumer pendant qu'on marche.

**Un agent sans tâche a quand même un comportement.** Un employé qui ne trouve
rien regagne son **poste d'attente** (`employee_rest_position`) : le passage
entre la cuisine et la salle pour un serveur, la colonne des postes pour un
cuisinier, décalés par rang. Sans ça il restait planté là où sa dernière tâche
l'avait laissé — le plus souvent collé à un client en train de manger.

**Le paiement appartient au ramassage, pas au porteur.** `pick_up` (joueur) et
`carry_by` (employé) faisaient le même geste et un seul encaissait : le joueur
était payé tout de suite, un serveur faisait attendre jusqu'à la plonge. Un seul
point de passage (`Dish._on_dirty_plate_taken`) porte les deux effets — libérer
la table et encaisser. L'appel restant côté plonge est un **filet** pour une
assiette restaurée depuis la sauvegarde, rendu inoffensif par `payment_claimed`.

**La ligne d'ardoise gagne ses deux champs ici, et seulement ici** : `station`
quand un poste la réserve, `ready` quand le plat sort. Sans `ready`, le même
plat repartait en boucle tant que le client n'était pas servi — le défaut a été
trouvé en test. Une nouvelle commande à la même table efface les deux.

### Le magasin qui tourne sans toi — amorcé (phase 9)

`restaurant_runs_unattended()` remplace la condition des quatre drapeaux :
**un comptoir garni suffit**. `settle_unattended_sales()` convertit
`pending_simulation_seconds` — qui s'accumulait depuis toujours sans que
personne le dépense — en verres vendus au rythme du trottoir, **plafonnés par
la réserve laissée derrière soi**. Le compte rendu du retour dit ce qui a été
vendu et si la réserve s'est épuisée. Rien de magique : réserve vide, revenu nul.

**La salle entière survit au changement de scène** — y compris ce qui n'y
survivait pas. Trois choses ont été ajoutées à `_persist_room` : la **position de
chaque employé** (la tâche, non — elle se redéduit de la salle, ce qui vaut mieux
qu'une tâche périmée), et les **cuissons en cours**. Sans elles, aller voir
l'arbre d'upgrades renvoyait la brigade au fond de la cuisine et **jetait le plat
sur le feu avec les ingrédients déjà consommés**. Les deux vivent en mémoire dans
`GameState`, pas dans la sauvegarde : elles n'ont à traverser qu'un changement de
scène, et ça ne vaut pas une version de sauvegarde.

Convives attablés, repas en cours et file d'attente sont capturés et restaurés
comme les assiettes, par un seul appel
(`_persist_room` / `_restore_customers` puis `_restore_plates`) —
séparés, un aller-retour pouvait rendre une assiette dont le mangeur avait
disparu. **Le passant du trottoir ne se stocke pas** : le flux le régénère en
une seconde et il n'a rien engagé. Les convives reviennent **avant** les
assiettes, parce qu'une assiette en cours de repas doit retrouver son mangeur —
un tabouret par table, donc l'indice de table suffit à le désigner. Une assiette
qui ne retrouve personne **redevient sale** plutôt que de rester posée hors
d'atteinte.

**Une assiette sale libère sa table au ramassage, pas au lavage.** Le client
suivant n'a aucune raison d'attendre la plonge. `release_table_plate()` remet
`table_index` à -1, ce qui rend l'opération idempotente si l'assiette est
reposée puis reprise.

---

---

## 6. Pièges spécifiques à ce projet

- **`action` est aussi lié au clic gauche** (`project.godot`). `Node._input()`
  tourne **avant** le routage GUI : y consommer un `InputEventMouseButton`
  empêche tout bouton de recevoir le clic. Toujours laisser passer les
  `InputEventMouseButton` dans `_input`.
- **Une garde `is_instance_valid()` dans une boucle typée ne protège pas** d'une
  instance libérée : l'assignation de `for x: Type in ...` échoue avant le corps.
  Les entités qui s'inscrivent dans une liste doivent s'en retirer elles-mêmes
  dans `_exit_tree()` (voir `Customer._exit_tree`), et non compter sur l'appelant.
- **PHP encode un tableau associatif vide en `[]`, pas `{}`.** Godot refuse `[]`
  là où il attend un dictionnaire. Utiliser `json_map()` avant publication. Côté
  JS, ne jamais écrire une clé texte sur un tableau : `JSON.stringify` la supprime
  silencieusement.
  **Ce piège s'est réalisé (18/08/2026), et il ne se voyait pas.** Le fichier
  contenait bien `"rooms": {}`, mais `api.php` le relit en tableau PHP vide et le
  renvoie au navigateur en `[]`. Les cases créées s'affichaient, se modifiaient,
  se publiaient « avec succès » — et arrivaient vides. Le défaut n'apparaît que
  quand une collection est **vide au chargement**, donc jamais avant la page
  blanche. `restoreMapShapes()` rétablit la forme à la lecture, aux deux points
  d'entrée, pour les collections **et** leurs sous-cartes (`initial_enemies`,
  `drops`, `ingredients`, entrées de localisation).
- **Une commande utile ne doit pas vivre dans un panneau qui disparaît.**
  « Recharger la version publiée » était un lien du panneau de gauche, replié sur
  la page Salles — donc absent là où on en a le plus besoin. Les commandes de
  synchronisation sont remontées dans l'en-tête.
- **Un gestionnaire anonyme ne se teste pas.** Le contrôle du retour d'onglet
  cherchait `visibilitychange` dans le source : remplacer la condition par
  `if (false)` le laissait au vert. Nommer `handleVisibilityChange` permet de
  l'exécuter.
- **Un compte rendu de publication doit compter ce qu'on vient d'éditer.** Le
  message annonçait monstres, upgrades, statistiques et facts — pas les salles.
  Une publication qui les perdait toutes disait quand même « succès ».
- **Un nœud qui n'affiche qu'un statut conditionnel doit redessiner une fois de
  plus au passage à vide**, sinon le dernier texte reste gravé (voir
  `InteractiveProp._process` et le flash d'attaque dans `enemy.gd`).
- **Un fichier d'art n'arrive pas par le brouillon.** Le catalogue n'était relu
  qu'au chargement de la page : un PNG déposé restait invisible, et rien ne le
  disait. « Recharger du serveur » rebalaie maintenant `assets/` et vide le cache
  des uniforms.
- **Une branche manquante dans `addItem` produit une fiche malformée.** Créer un
  visuel par le dialogue tombait dans le cas générique et sortait avec `color` et
  `shape` : la publication le refusait. Un seul gabarit (`blankVisual`) sert
  maintenant les deux chemins de création.
- **Les assets de l'outil sont versionnés par `filemtime`** dans `index.php`.
  Sans ça le navigateur sert une version en cache et une correction publiée
  reste invisible — on croit que le code est faux alors qu'il ne s'exécute pas.
- **Tester une fonction ne teste pas son câblage.** Une fonction de rendu peut
  être correcte et n'être jamais appelée. `tests/render_test.js` vérifie les deux.
- **Un test de combat doit imposer ce dont il dépend.** Placer un ennemi à une
  distance fixe suppose une portée d'attaque précise; le placer trop près le met
  en collision avec le joueur, que `move_and_slide` repousse hors de portée.
  Imposer le pattern voulu et se placer relativement à `attack_range`.
- **Ne jamais choisir une entité de test par index.** La composition d'une salle
  est tirée au poids, et un monstre abattu plus tôt reste dans le tableau. Un
  test doit sélectionner par capacité et écarter les instances inactives
  (`_enemy_with_attack_mode` dans le smoke test).
- **Un test qui pilote la salle doit imposer la statistique, pas les upgrades.**
  `table_count` remplace les noms `second_table`/`third_table`: `_force_table_count`
  pose un modificateur et le retire ensuite.
- **Un plancher rend une pénalité invisible.** `adjust_popularity` borne à zéro:
  un test qui vérifie une perte de réputation doit poser une mise de départ,
  sinon il valide une soustraction qui n'a rien fait.
- **Une entité libérée d'office doit se désinscrire elle-même.** Une assiette
  `queue_free()` sans passer par `consume()` laissait le joueur avec une
  référence morte, et le HUD lisait une instance libérée à chaque frame. Réglé
  par `Dish._exit_tree()`, comme `Customer._exit_tree`.
- **La poubelle jette le repas, pas l'assiette.** Elle reste en main et devient
  sale, avec sa valeur en attente effacée. Une assiette déjà sale n'a rien à
  jeter: la poubelle ne mange jamais de vaisselle.
- **Une table ne se partage pas entre inconnus.** Les deux places ne servent
  qu'à un groupe arrivé ensemble (`PAIR_ARRIVAL_CHANCE`); `_find_open_table`
  n'offre qu'une table **entièrement** libre et propre.
- **Le dash suit les touches, pas la visée.** `movement_direction` retient le
  dernier cap de course; sans touche pressée il retombe sur `facing`.
- **Un test qui dépend du restaurant doit lui donner de quoi cuisiner.**
  `_register_customer` refuse toute arrivée quand `_can_accept_arrivals()` est
  faux, donc un test qui ne fournit ni ingrédient ni menu vérifie une salle
  restée vide sans s'en apercevoir. Même chose pour le nombre de tables, qui
  dépend des upgrades achetées plus haut dans le test.
- **Un champ d'auteur que personne ne lit finit par mentir.** `biome.room_count`
  ne servait plus qu'au bouton « générer » et continuait d'annoncer une longueur
  de colonne fausse. Supprimé.
- **Une donnée réglée à la main invalide les tests qui supposaient l'uniformité.**
  Dès que l'utilisateur règle une case, « la salle suivante a le même budget »
  devient faux, et « les renforts arrivent au seuil » aussi si la case déclare
  une durée. Un test doit **imposer le chemin** qu'il vérifie.
- **Les modificateurs `run:` vivent dans la sauvegarde.** Une exécution de test
  interrompue laisse un `run:room` derrière elle, et un `+1` d'attaque venu
  d'une salle fausse silencieusement toutes les vérifications de statistiques de
  l'exécution suivante. Le bloc d'isolation les purge au démarrage.
- **Masquer un enfant de grille CSS décale les autres d'une piste.** Le panneau
  de gauche en `display:none` sort du flux: le panneau d'édition devenait le
  premier enfant et atterrissait dans une piste déclarée à `0`, ce qui écrasait
  toute la page Salles. Une piste masquée se retire du `grid-template-columns`,
  elle ne se met pas à zéro.
- **Retirer une liste de navigation crée un cul-de-sac.** Sans panneau gauche,
  une page sans sélection n'affichait plus rien — donc aucun moyen de créer une
  entrée. L'état vide doit garder ce qui permet d'en sortir.
- **Un `grep` sur la sortie d'un test peut masquer son plantage.** Une suite JS
  qui lève une exception n'imprime aucun « ECHEC » : filtrer la sortie la faisait
  passer pour verte. Contrôler le **code de sortie**, pas le texte.
- **Un script de correction qui écrit à la fin n'applique rien s'il échoue au
  milieu.** Utile — l'état reste propre — mais il faut vérifier ce qui a
  réellement été écrit avant de relancer.
- **Un test qui cherche un nom dans un fichier ne teste pas son exécution.**
  L'assertion « la suppression renumérote la colonne » cherchait
  `renumberColumn(removedColumn)` dans le source : remplacer la garde par
  `if (false)` laissait le test au vert. Vérifier la fonction *et* la forme
  exacte de son appel.
- **Ne jamais figer un numéro de case dans un test.** Le premier test des salles
  alternatives visait la case 3, que la donnée livrée occupait déjà. Choisir la
  case libre en la cherchant.
- **`normalize_amount_map` rend déjà une carte encodable en `{}`.** L'envelopper
  une seconde fois dans `json_map()` lève une TypeError à la publication.
- **Un cache de statistique doit se resynchroniser.** `dungeon.max_stamina`
  était lu une fois au démarrage ; une récompense qui déplace `stamina` le
  laissait périmé. La liste de modificateurs fait foi, un champ n'en est que le
  cache : s'abonner à `state_changed` plutôt que muter.
- **Un formulaire à plusieurs couleurs casse un liant écrit pour une seule.**
  Le champ texte hexadécimal de l'outil visait `item.color` en dur et prenait le
  premier `[data-color-text]` de la page. Les trois couleurs d'un biome
  l'obligent à passer par `data-color-for`.
- **Une sonde de combat qui inflige des dégâts doit poser un plancher de vie.**
  Le bestiaire livré descend à 1 ou 2 points : le `take_damage(1.0)` qui vérifie
  l'annulation par recul tuait la créature, et la suite du test lisait une
  instance libérée — le run se bloquait au lieu d'échouer proprement.
- **Un test de mise en page doit mesurer la bande dessinée, pas un centre.**
  Vérifier que le HUD ne couvre pas `SIDEWALK_Y` passait avec 5 px de marge,
  alors que la bande de 52 px était bien recouverte à l'écran.
- **Une table libre fait sortir la file pendant le test.** `_seat_from_queue`
  tourne à chaque frame: un test de file d'attente doit fermer exactement autant
  de tabourets qu'il occupe, sinon il vérifie une file déjà vidée.
- **Le dossier de travail est sur Google Drive.** Une synchronisation a rétabli
  `restaurant.gd` dans une version antérieure, en conservant sa date de
  modification — donc invisible à `ls`. Comparer au dernier `_backups/` avant de
  conclure qu'une correction n'a jamais été appliquée.
- **Un test ne doit pas dépendre du contenu publié.** Vider les salles a fait
  échouer quatre contrôles du smoke test et toute la suite `room_editor_test`,
  qui lisaient la colonne livrée. Ils posent maintenant leur propre colonne :
  décor repeint, longueur allongée, case scellée, salle alternative.
- **Une suppression laissait des références que seule la publication voyait.**
  Retirer une créature encore nommée par une salle passait dans le brouillon,
  puis échouait à la publication (`identifiant inconnu`). Deux balayages :
  `CASCADE_MAPS` pour les cartes (tables de monstres, offre de récompenses,
  éléments posés, butins, ingrédients d'une recette), et un balayage générique
  des **listes d'enregistrements** — tout `modifiers[]`, `conditions[]` et effet
  de crochet portant un `variable` ou un `path` — sur toutes les collections.
  Bloquent au contraire : un champ obligatoire à valeur unique (biome,
  dimension, pattern, plat favori, parent d'un nœud) et le dernier matériau
  d'une recette.
- **Une carte publie `[]` si personne ne la force.** `normalize_visual_map`
  rendait un tableau PHP nu : une carte vide arrivait en `[]` et Godot refuse `[]`
  là où il attend `{}`. Le piège des salles, à l'identique. Le normaliseur rend
  maintenant une carte encodable, comme `normalize_amount_map`.
- **Une insertion automatique peut tomber dans la fonction voisine.** Trois ajouts
  PHP ont atterri dans la fonction suivante : le fichier compilait et se lisait
  comme un miroir correct. Le test contrôle désormais que chaque appel appartient
  bien à la fonction qui porte son nom.
- **La cascade cherche une clé ; un visuel est une valeur.** `CASCADE_MAPS` compare
  l'identifiant supprimé aux **clés** d'une carte — juste pour `{monstre: poids}`,
  faux pour `{état: visuel}` et `{crochet: visuel}`. Supprimer un visuel laissait
  des références mortes que seule la publication signalait. D'où
  `CASCADE_VALUE_MAPS`.
- **Une adresse se compare avec son point.** Une condition lit soit l'adresse
  nue (`fact.boss_defeated`), soit un champ (`upgrade_nodes.fork.purchased`).
  Comparer au seul préfixe `fact.boss_defeated.` laissait passer la première
  forme ; comparer sans point emporterait `fact.boss_defeated_bis`.
- **Un refus doit dire par où sortir.** Onze des seize refus de suppression
  disaient « impossible » sans indiquer quoi défaire ni dans quel onglet.
  `refuseDeletion(raison, marche_a_suivre)` est le seul point de sortie: la
  forme ne peut plus dériver, et le test parcourt **chaque** chemin de refus.
- **L'outil ne voit pas ce que le code lit par son nom.** `table_count`,
  `boss_defeated` : supprimer une statistique ou un fait passe toutes les
  vérifications de données et casse quand même le jeu. La confirmation le dit,
  faute de pouvoir le vérifier.
- **Un noeud retiré par `queue_free()` reste compté jusqu'à la fin de la frame.**
  Le porteur mentait sur ce qu'il montrait: `remove_child()` d'abord, libération
  ensuite.
- **Une créature de test erre d'elle-même.** Un contrôle sur son état visuel
  échouait une fois sur trois: la boucle physique changeait sa vitesse entre deux
  assertions. Un test doit **figer** ce dont il dépend.
- **Un visuel ne décide jamais de la collision.** `ShapeFactory` et
  `body_radius()` gardent la physique et la portée ; un visuel n'a qu'un
  décalage et une échelle. Sans ça, chaque retouche d'art deviendrait une
  session d'équilibrage. Le smoke test vérifie que déclarer un visuel ne change
  pas le rayon du corps.
  **La réciproque manquait, et c'était un défaut** (20/08/2026) : le dessin doit
  suivre le corps. `size_multiplier` est tiré entre `min_size` et `max_size` à
  chaque apparition, la forme procédurale grandissait avec
  (`draw_set_transform`), mais `VisualHolder` posait le seul `scale` du visuel —
  donc deux bêtes de hitbox différente portaient exactement la même image, et
  l'agrandissement cessait de se voir dès qu'un art était déclaré. La créature
  pose maintenant son facteur sur le porteur (`visuals.scale`), qui se multiplie
  avec celui du visuel. **Les deux réglages restent, ils répondent à deux
  questions** : `size` dit quelle taille fait la bête, `scale` cale une image
  donnée sur un corps de référence — le moteur ne peut pas deviner que le PNG du
  poulet a 32 px de marge vide de chaque côté.
- **Un agent qui agit à distance téléporte.** Une tâche de serveur était un seul
  trajet : il marchait jusqu'à l'assiette, et l'assiette arrivait chez le client
  sans traverser la salle. Une tâche de portage a **deux temps** — aller chercher,
  puis porter. `Dish.carrier` était typé `Player`, ce qui interdisait à un employé
  d'en être un ; le suivi visuel, le halo et les quatre libérations marchaient
  déjà pour n'importe quel porteur.
- **Un objectif se vérifie pendant le trajet, pas à l'arrivée.** Le choix de tâche
  ne se rejouait qu'une fois la précédente finie : le joueur prenait l'assiette
  visée et le serveur finissait sa marche pour ne rien faire. `_task_still_valid()`
  passe à chaque frame et `_abandon_task()` repose ce qui était en main.
- **Une boucle d'agent doit refuser ce qu'un collègue vise déjà.** Sans
  réservation, deux serveurs convergent sur la même assiette et l'un repart
  bredouille; deux cuisiniers cuisinent la même commande.
- **`meta_values` traverse tout, y compris un run planté.** Un
  `dimension_index` avancé par un test interrompu faisait démarrer la plongée
  suivante dans une colonne que personne n'a écrite, et six contrôles sans
  rapport échouaient. Le bloc d'isolation le vide.
- **Ne jamais appeler `complete_dimension()` au milieu du smoke test.** Il vide
  l'économie, les upgrades et avance le cycle : tout ce qui suit s'effondre. Un
  test de portée se **greffe sur la fin de dimension déjà exercée**.
- **Un `.replace(..., 1)` peut viser la mauvaise occurrence.** `if coins < cost:`
  existe dans `purchase_upgrade` **et** dans `hire_employee` : trois cassures de
  contrôle ont semblé « non attrapées » alors qu'elles touchaient une autre
  fonction. Vérifier l'unicité du motif avant de conclure qu'un test est creux.
- **Un contrôle qui dépend d'une donnée absente ne teste rien.** Le catalogue
  d'employés ne déclare aucun bonus passif : la vérification correspondante était
  vide. Le test pose maintenant le bonus qu'il vérifie.
- **Les sources `test:` vivent dans la sauvegarde, comme les `run:`.** Une
  exécution interrompue a laissé `test:table_count` à -3 : le restaurant
  démarrait sans aucune table et deux contrôles sans rapport échouaient. Le bloc
  d'isolation purge maintenant **tout le préfixe `test:`**.
- **Casser une valeur de secours ne prouve rien.** `DataCatalog.GAME_VARIABLES`
  vient du fichier maître ; les défauts intégrés ne servent que si le maître est
  invalide. Un test sur ce que le jeu livre doit être vérifié en cassant **la
  donnée publiée**, pas la constante de secours.
- **Un test qui lit une référence nulle bloque au lieu d'échouer.** Le contrôle
  du panneau d'ardoise crashait la suite quand le panneau manquait : le run
  partait en timeout et ne rapportait rien. Garder la garde autour du bloc.
- **La commande ne se divulgue jamais toute seule.** Un texte annonçait le plat
  dès qu'un client s'asseyait, ce qui vidait de son sens la prise de commande.
  `ui.restaurant.customer_seated` ne dit plus que *qui* attend.
- **Une lambda GDScript capture par valeur.** Écrire dans une variable locale
  depuis l'intérieur d'une lambda ne remonte pas ; un test qui sonde un signal
  doit accumuler dans un conteneur, qui lui est partagé.
- **Un verrou posé à la fin ne protège pas d'un plantage.** Rendre la sauvegarde
  au moment de conclure ne sert à rien si le test plante ou dépasse son temps : il
  n'atteint jamais cette ligne. `GameState.save_locked` est donc posé **au
  démarrage**. Pour `upgrade_return_smoke`, dans `_enter_tree()` : le `_ready` d'un
  enfant passe avant celui du parent, et la page d'upgrades écrivait dans cet
  intervalle.
- **Un test qui écrit dans `user://` écrase la partie du joueur.** Le bloc
  d'isolation restaurait la sauvegarde **champ par champ**, et la liste avait pris
  du retard : employés embauchés, ardoise, comptoir et convives attablés n'y
  étaient pas. Deux employés du test se sont retrouvés dans la partie de
  l'utilisateur. **Énumérer était l'erreur** : `GameState.save_file_at_boot` garde
  le fichier tel qu'il était, le test le réécrit octet pour octet, et
  `GameState.save_locked` coupe toute écriture après — sans quoi un appel différé
  (fermeture de magasin, donjon libéré) repassait juste après. **Les deux scènes de
  contrôle sont concernées** : `upgrade_return_smoke` sauvegardait aussi, sans
  aucune isolation.
- **La sauvegarde se lit au démarrage, pas quand le test la veut.** La première
  frame de la salle verse déjà les assiettes stockées dans la sienne. La copie est
  donc prise dans `load_game()`, avant que la moindre scène ne tourne.
- **Une fonction de correction que personne n'appelle ne corrige rien.**
  `prune_employees()` n'était invoquée que par le test : une sauvegarde portant
  plus d'employés que de places ouvertes les gardait tous. Elle tourne maintenant
  au chargement — mais **pas** dans le rafraîchissement de la salle, où
  `dismiss_employee` → `state_changed` → rafraîchissement la rendait ré-entrante
  et bloquait le démarrage.
- **Un `call_deferred("_focus_...")` vole le focus à la frame suivante.**
  Changer d'onglet rend la main à la barre, donc un test qui pose le focus sur
  une ligne juste après voit le raccourci changer d'onglet au lieu d'actionner
  la ligne. Attendre une frame entre les deux.
- **Un agent qui produit à distance efface la géographie.** Le plat cuit
  atterrissait sur l'étagère sans que personne ne l'y porte : le four et
  l'étagère devenaient un seul et même endroit, et la cuisine n'avait plus de
  trajets. Même défaut que le serveur qui téléportait les assiettes, un cran
  plus haut. Quand un poste produit quelque chose, il le pose **là où il est**.
- **Une cassure de test doit être unique dans les deux sens.**
  `held.place_on(global_position)` existe aussi dans `_abandon_task` et
  `_exit_tree` : la garde d'unicité passait à l'aller et échouait au retour,
  laissant le fichier cassé. Et le contrôle correspondant était creux, parce que
  le test posait le porteur **exactement** sur sa cible — poser « là où je suis »
  donnait alors le même résultat. Un test de dépose doit arrêter le porteur à
  côté de sa cible.
- **Ce qui n'est pas capturé au changement de scène disparaît sans un mot.**
  Assiettes et convives traversaient, les **postes** non : une cuisson lancée
  avant d'ouvrir le menu d'upgrades emportait le plat *et* les ingrédients.
  Aucune erreur, aucun message — juste des ingrédients en moins. Quand on ajoute
  un état à la salle, il faut se demander tout de suite qui le rejoue au retour.
- **Deux chemins pour le même geste finissent par diverger.** `pick_up` et
  `carry_by` prenaient tous deux une assiette sale ; seul le premier encaissait.
  Le défaut n'était visible qu'avec un employé, donc invisible pendant les huit
  phases où il n'y en avait pas.
- **Un `z_index` négatif sur un enfant le fait passer sous son parent.**
  `z_as_relative` étant vrai par défaut, un enfant à -1 se dessine **avant** le
  parent — donc sous le sol que `Restaurant._draw` peint sur toute la salle.
  L'ardoise était `visible = true`, accrochée au bon mur, et intégralement
  recouverte. `visible` ne prouve rien : c'est le `z` qu'il faut contrôler.
- **Une fonction que personne n'appelle n'est pas une fonctionnalité.**
  `toggle_open_closed()` et `hire_employee()` étaient écrits, testés
  unitairement, et injoignables : aucun bouton ne les atteignait. Le smoke test
  les appelait directement, ce qui les faisait passer pour câblés. Un test doit
  passer par **le geste du joueur** — presser le bouton du panneau — sinon il
  vérifie une fonction, pas une fonctionnalité.
- **Une cassure de test à forme vide ne se restaure pas.** Le script de preuve
  remplaçait une ligne par `''` ; à l'annulation, la garde `s.count(old) == 1`
  lit `s.count('')` — jamais 1 — et le fichier restait cassé **en silence**.
  Trois connexions de signaux ont ainsi disparu au milieu d'une série de
  vérifications, contaminant tout ce qui suivait. Aucune forme cassée ne doit
  être vide, et la série doit **vérifier que l'arbre est revenu** après chaque
  annulation.
- **Casser une valeur de secours ne prouve toujours rien** — le piège s'est
  reproduit avec `management_console`. Casser `DataCatalog.GAME_VARIABLES` dans
  le code laissait le test au vert : la donnée publiée l'écrase. Il faut casser
  `data/game_data_master.json`.
- **L'état persistant doit être isolé dans le smoke test** : assiettes, compteurs
  par entité, menu, effets. Un reliquat d'une exécution précédente fausse la
  suivante. Voir le bloc `snapshot` / `_restore_progress`.

---

## 7. Méthode de travail attendue

- Inspecter les fichiers réels avant d'agir ; ne pas se fier à un instantané de
  conversation.
- Préférer les implémentations pilotées par les données.
- Garder souris **et** clavier/manette fonctionnels.
- Maintenir l'échelle logique 1920×1080.
- Ajouter une couverture de test proportionnée, puis **vérifier que le test
  attrape vraiment la régression** en cassant temporairement le code. Un test
  qui passe sans jamais avoir échoué ne prouve rien.
- Rapporter exactement ce qui a changé, ce qui a cassé en route, et les limites
  restantes. L'utilisateur ne peut pas repérer les régressions lui-même : ne pas
  présenter uniquement le résultat final.

### Décisions déjà arrêtées — ne pas rouvrir sans raison

| | Décision |
|---|---|
| Collections | noms du fichier maître, inchangés |
| Adresses | pas de préfixe `element.` |
| Couches | `flat`, `scaling`, `global`, `override` |
| Empilement | clé par effet par défaut |
| Réactivité | recalcul global |
| Compteurs | `fact_values` fait foi, `statistics` supprimé |
| Équipement | prévu (arme, armure, objet, consommables, 1–3 skills, sets
  distincts resto/donjon) mais **non construit** : le socle existe, la
  fonctionnalité reste à faire |
| Dimensions | 4 dans le cycle, plus l'épreuve `fork_trial` hors cycle |
| Verrou | déclaré par la donnée, jamais codé en dur |
| Récompenses | source `run:`, sans remise dans une offre, avec remise entre deux |
| Boss | créature du bestiaire agrandie, pas une seconde table à tenir |
| Salles | la salle est l'unité écrite, le biome n'est qu'une palette |
| Dimension | une colonne nommée: identité, rang, crochets. Rien d'autre |
| Boss | une créature posée dans une case qui coche `ends_dimension` |
| Verrou | supprimé: c'est une `conditions` de case + crochet `on_blocked` |
| Sortie | `exit_level` prime sur `exit_offset`; au-delà de la colonne = fin |
| Cases | désignation **positionnelle**, renumérotées à la suppression |
| Remplacement | le maximum déclenche, la somme ne fait que répartir |
| Zéro/vide | veut toujours dire « demande au-dessus » |
| Suppression | **cascade** sur une entrée de liste ou de carte, **blocage** sur un champ obligatoire |

### Donjon — construit

Une plongée traverse les biomes d'une dimension puis sa salle de boss. Elle se
termine par : plus de stamina, mise hors service, retour volontaire, ou victoire
sur le boss. Le joueur peut rentrer au restaurant à tout moment.

**Trois portées de persistance** — c'est le point structurant :

| Portée | Contient | Effacée |
|---|---|---|
| `run` | position dans la plongée, dimension figée, récompenses | à chaque retour au restaurant |
| `dimension` | upgrades générales, pièces, ingrédients, compteurs, restaurant | à la victoire sur le boss de fin de dimension |
| `meta` | upgrades spéciales, `dimension_index`, `gate_pending`, contenu débloqué | jamais |

La portée est **déclarée par la donnée** (`scope` sur un nœud d'upgrade ou un
fait), jamais codée en dur. Défaut `dimension` : un oubli ne rend jamais un
acquis permanent par accident. `GameState.end_run()` et `complete_dimension()`
sont les deux seules opérations.

Décisions appliquées : remise à zéro de **tout** l'avancement de la dimension
(économie comprise), upgrades spéciales dans le **même arbre** via `scope`,
restaurant habillé **par données** d'une dimension à l'autre plutôt qu'une scène
par dimension.

**Cycle** : dimension 1 (16 cases) → 2 (21) → 3 (26) → 4 (31, case 26 sous
condition `fact.master_fork_owned`) → retour à la première. L'épreuve
`fork_trial` (5 cases) accorde la fourchette. La dernière case de chaque colonne
est l'antre du gardien et coche `ends_dimension`.

Reste à faire côté donjon : **la plongée ne rapporte pas encore de pièces**, donc
le volet monétaire de l'assurance est implémenté mais jamais sollicité. Les
éléments de salle et les comportements spéciaux sont écrits mais inertes.
L'habillage du restaurant par dimension reste à faire.

**L'arbre se déplace et se zoome** (`UpgradeTreeView`). Le plan des noeuds est
plus grand que l'écran et il n'y avait ni l'un ni l'autre : une branche entière
pouvait rester injoignable à la souris, alors que l'outil de gestion laissait
tout balayer. Glisser déplace, la molette zoome **en gardant sous le curseur ce
qui y était**, et la navigation clavier entraîne la vue (`keep_visible`) — ce qui
répond au « déplacer la vue avec les touches de déplacement » sans lui retirer le
choix d'un noeud. Le seuil `DRAG_THRESHOLD` empêche qu'un achat devienne un
déplacement. La fenêtre ne sait pas ce qu'est un noeud d'upgrade : elle ne
connaît que la géométrie, et c'est ce qui la garde à côté du canevas.

Plan détaillé : `docs/architecture-donjon.html`.

### Postes et employés — **les neuf phases sont construites**

Deuxième four, épicerie, bar, puis serveurs et cuisiniers embauchés dans un
catalogue. **Le point structurant est l'ardoise** : une commande n'existe
aujourd'hui que dans la mémoire du joueur, donc aucune automatisation n'est
possible tant qu'elle n'est pas une file de travail partagée. Ordre imposé :
ardoise → postes et console → étagère → épicerie → employés en données →
serveurs → cuisiniers → magasin autonome (les quatre drapeaux de
`restaurant_operations`, aujourd'hui inutilisés).

**Courbe visée : celle des jeux d'accumulation.** Au début, faire le travail
soi-même est plus rapide ; à terme la brigade dépasse le joueur de plusieurs
ordres de grandeur. Les compétences sont donc **multiplicatives et sans plafond
dur**. (Ma première version posait l'inverse — « un employé est toujours plus
lent que le joueur » — et l'utilisateur l'a corrigée.)

Décisions tranchées : portée déclarée par la fiche (défaut `dimension`),
catalogue fixe à embauche conditionnée, **aucun coût continu** (le rachat à
chaque dimension est la seule récurrence), geste du joueur conservé et
**repérer un client inscrit sa commande sur l'ardoise**, épicerie = raffinage
**garanti** d'un plat déjà cuit avec coefficient `superior_payout` monté par
upgrades, et **bar = fenêtre sur la rue** : vente au comptoir depuis le
trottoir, **file séparée** de celle de la porte, ni table ni vaisselle, et
**pas de pénalité de réputation** sur un refus — c'est une soupape, pas une
source de pression. Neuf phases, l'ardoise en premier.

**Personne ne tient le comptoir : on le remplit.** Les boissons se préparent
d'avance dans une réserve `counter_stock` et la fenêtre vend seule tant qu'il
reste du stock. Ça évite d'inventer une interaction à travers un mur, ça évite
une corvée par client, et surtout ça garde la fenêtre utile **avant** le premier
employé — elle arrive quatre phases avant eux. Conséquence : c'est le premier
revenu qui tombe en l'absence du joueur, donc un galop d'essai de la phase 9.
Un client attablé **ne commande jamais de boisson** : circuit indépendant, une
seule ligne d'ardoise par client. Le barman est un **cuisinier** qui garnit la
réserve, jamais un serveur.

Reste ouvert, sans rien bloquer : verre offert à la file de la porte, pénalité
au comptoir.

**Ce qui n'est pas fait, et qu'il faut savoir** : les compétences `stamina`,
`poise`, `thrift`, `plating` et `nose` sont **déclarées et éditables mais
inertes** — le vocabulaire existe des trois côtés, aucun code ne les lit encore.
`tray` n'est pas utilisé non plus : un serveur porte une assiette à la fois. Le
cuisinier ne tient que les postes `stove` (l'épicerie reste manuelle), et la
production hors ligne ne couvre que le comptoir, pas la brigade.

Plan détaillé : `docs/architecture-employes.html`.

### Habillage visuel — **phases 1 à 6 construites**, premier art livré

Passer des formes procédurales à des images, animations, effets et shaders,
éditables dans l'outil. **Le mécanisme existe déjà** sur une collection :
`upgrade_nodes.appearance.image_path` + `ResourceLoader.exists()` + repli sur le
dessin. Il s'agit de le généraliser avec un vocabulaire d'états par collection
et une chaîne de repli (`death → hurt → idle → forme procédurale`), ce qui rend
le jeu jouable pendant toute la migration.

Deux règles à ne pas perdre de vue :

1. **Le visuel ne décide jamais de la collision.** `ShapeFactory` et
   `body_radius()` gardent la physique et la portée. Sinon chaque retouche d'art
   devient une session d'équilibrage.
2. **Godot n'exécute que ce qu'il a importé.** Un fichier sans `.import` voisin
   passe toutes les validations de données et ne s'affiche pas en jeu — même
   classe de bug que les salles qui ne se publiaient pas. L'outil peut le
   détecter et doit le signaler.

Décisions prises : collection **`visuals` partagée** (réutilisable, référencée
par identifiant) ; animations en **feuille + grille** ; décor de salle en
**image de fond**, pas de `TileMap`.

**Rangement par type, et non par entité — décision révisée (20/08/2026).**

```
assets/  creatures/  characters/  props/  items/  backgrounds/  effects/  shared/
```

`characters/` couvre employés, clients et joueur : ils se ressemblent, et une
marche se réutilise entre eux. `effects/` n'existait pas dans le rangement par
entité — un effet ponctuel se déclenche sur un crochet et n'appartient à aucune
créature.

**Le rangement sert à proposer, jamais à interdire.** Le dossier dit *pour quelle
collection* une ressource est faite, le nom du fichier dit *pour quel état*
(`walk.png`, `slime_walk.png`, `walk_fast.png` visent tous *walk*, un mot entier
primant sur un fragment). La liste classe : bon dossier + bon état, puis `shared/`
+ bon état, puis le reste du dossier, puis `shared/`, puis tout. **Rien n'est
retiré** — sinon un fichier mal rangé deviendrait inutilisable au lieu d'être
seulement plus long à trouver.

**Ce qu'on perd** : « supprimer une créature = un dossier à jeter ». La cascade
couvre déjà les références en données, et le gain de lisibilité l'emporte.

**Choisir un fichier crée la ressource.** Un nom de fichier ne dit pas comment il
se découpe — c'est ce que porte l'entrée `visuals`, qui ne peut donc pas
disparaître. Mais elle s'écrit toute seule : identifiant dérivé du chemin, nom
lisible, et une découpe **devinée puis annoncée** (largeur multiple de la hauteur
→ bande horizontale de cases carrées). `death` et `destroyed` arrivent sans
boucle. Le même fichier ne crée jamais deux ressources.

**Phase 1 en place, et rien ne change à l'écran.** `VisualCatalog.resolve()`
répond « quelle ressource pour cette entité dans cet état », rien d'autre — ni
chargement, ni affichage. La collection `visuals` porte trois entrées
(20/08/2026) : `chicken_beast` en repos et marche, `player_idle` pour le héros.
Toutes les autres entités tournent sur leurs formes procédurales, et c'est un
état normal, pas une erreur.

**`VisualHolder` ignore les directions, et c'est structurant.** Il découpe
`colonnes × rangées` et joue **toutes** les cases comme une seule animation ; la
seule orientation qu'il connaît est `face()`, qui retourne horizontalement. Poser
une feuille directionnelle (les 5 rangées que rend PixelLab pour 8 directions,
les 3 autres venant du miroir) ferait donc **défiler les orientations comme un
cycle de marche**. Le joueur est livré avec sa seule vue sud pour cette raison.
Support directionnel = une rangée choisie par l'orientation, plus un champ de
schéma des trois côtés. **Non construit, et à décider avant de générer des
animations par direction** — elles coûtent une génération par direction.

**Le contrôle a changé de sens.** Le smoke test et `visual_editor_test` exigeaient
« aucun visuel n'est livré » — une sentinelle de l'époque sans art, fausse dès la
première image. Ils exigent maintenant que **chaque visuel publié soit
chargeable** : `VisualCatalog.is_available()` côté Godot, existence du fichier
côté outil. C'est plus fort, et ça attrape le défaut réel — un PNG sans
`.import` voisin passe toutes les validations de données et reste invisible.
**Et l'entité de repli ne se choisit plus par index** : le bestiaire porte de
l'art maintenant, donc `ENEMIES.keys()[0]` pouvait tomber sur une créature
habillée. Elle se choisit par absence de visuel.

- **Vocabulaire `VISUAL_STATES` par collection**, miroir des trois côtés, avec
  `VISUAL_FALLBACKS` : `death → hurt → idle → forme procédurale`.
- **Un visuel déclaré dont le fichier manque ne bloque pas** : la chaîne
  continue. `broken_states()` et `missing_files()` le signalent sans rien casser.
- La carte `visuals: {état → identifiant}` est écrite sur **quatre collections**
  (`enemies`, `employees`, `room_elements`, `biomes`) — les six autres du
  vocabulaire attendent la phase 4, inutile de leur poser un champ vide.
- Page **Visuels** dans l'outil : découpe, calage, aperçu direct pour une feuille
  (l'outil est servi par le même serveur que les fichiers), vignette pour une
  scène. La grille d'états d'une entité **dit d'où vient chaque image** —
  déclarée, héritée, ou forme procédurale.

**`VisualHolder`** porte l'image d'une entité : feuille découpée en atlas (les
`SpriteFrames` sont mises en cache et partagées) ou scène instanciée. Branché sur
les **monstres** ; `Enemy._current_visual_state()` déduit l'état de ce que la
créature fait déjà — aucun drapeau neuf à tenir à jour, donc aucun risque qu'il
mente. `should_draw_body()` est extrait du `_draw` pour être vérifiable : l'image
remplace **le corps et rien d'autre**, le télégraphe et la barre de vie restent.

**Le sélecteur de ressources** : `api.php?action=scan_assets` parcourt `assets/`
et rend chemin, dossier, dimensions, date — et surtout **si Godot a importé le
fichier**. Une ressource sans `.import` voisin n'existe pas à l'exécution ; le
sélecteur la marque dans la liste et affiche un avertissement. La découpe est
confrontée aux dimensions réelles (« case de 32 × 32 px », ou « la découpe ne
tombe pas juste »).

**Phase 4 : les dix collections, et le décor.** `enemies`, `employees`,
`room_elements`, `biomes`, plus `customer_profiles`, `player_configs`,
`ingredients`, `recipes`, `dimensions`, `rooms` portent une carte d'états des trois
côtés. Porteurs branchés sur le **client** (état déduit du pas réellement
parcouru — il se déplace par `move_toward` sans vélocité stockée), le **joueur**
(le plus bref passe devant : dash, puis coup reçu, puis attaque), l'**assiette**
(sale = plus un plat, elle retombe sur le dessin) et le **ramassage**.

**Le décor de salle suit la règle du projet** : la case, puis son biome, puis la
colonne, puis le sol dessiné. `VisualHolder.cover()` étire le fond sur l'arène —
c'est le seul cas où l'auteur ne peut pas dimensionner son image, l'arène ayant
une taille fixe. Les murs restent dessinés : ils disent où est la limite.

**Phase 5 : les effets sont des événements, pas des états.** Aucun déclencheur
inventé — les clés d'une carte `vfx` sont les **crochets** que la collection
déclare déjà. Neuf collections la portent : celles dont un événement se produit
quelque part dans le monde. `on_purchase`, `on_taken` et `on_hired` se produisent
dans un menu, donc rien à y jouer.

- `Registry.run_hook(collection, id, hook, origin)` porte les deux à la fois : ce
  que l'événement change, et ce qu'on en voit. Un seul point d'appel.
- **`on_hurt` est un crochet neuf** sur `enemies` et `player_configs` : c'est lui
  qui rend le flash d'impact déclaratif. Le clignotement codé en dur devient le
  **repli**, joué seulement si la donnée ne dit pas mieux.
- Un effet s'attache au **parent** de son origine : une gerbe de mort doit
  survivre au cadavre qui la déclenche. Il se libère seul.
- `lifetime` à 0 veut dire « déduis-la » : découpe ÷ cadence pour une feuille qui
  s'arrête, une seconde pour une scène ou une boucle.

**Phase 6 : le matériau et l'ambiance. Les six phases sont construites.**

**Un shader n'est pas une entité de plus** : c'est un matériau posé sur un
visuel. D'où `shader` et `shader_params` sur l'entrée visuelle, jamais sur
l'entité qui la porte. `VisualHolder.material_for()` le construit ; les shaders
compilés sont partagés comme les `SpriteFrames`, les paramètres restent par
instance. Un chemin vide, un fichier absent ou un fichier qui n'est pas un shader
laissent le visuel s'afficher tel quel.

**Le formulaire se construit depuis le shader lui-même.** `api.php?action=scan_shader`
lit les `uniform` du `.gdshader` — nom, type, `hint_range`, `source_color`, valeur
par défaut — et l'outil en déduit le contrôle. **Ajouter un `uniform` fait
apparaître un champ, sans toucher au schéma.** Types éditables : `float`, `int`,
`bool`, `vec2`, `vec3`, `vec4`, `sampler2D` ; une matrice ou un tableau gardent
leur défaut. Le chemin vient du navigateur, donc il est vérifié comme restant
dans le projet.

**L'ambiance appartient au lieu**, pas à la créature : `ambient_color` et
`ambient_energy` sur le biome — qui n'est plus qu'une palette, c'est exactement là
que ça vit. Le donjon pose un `CanvasModulate` ; le blanc à intensité 1 ne teinte
rien, et c'est le défaut. Une torche dans une salle reste un `room_element` avec
un visuel de nature scène qui contient sa lumière : rien de spécial à prévoir.

**Corrigé au passage** : `scan_assets` marquait « pas encore importé » **toute
scène et tout shader**. Seules les ressources *importées* ont un `.import`
voisin ; un `.tscn` et un `.gdshader` sont natifs. L'auteur était envoyé chercher
un problème inexistant.

Un shader d'exemple est livré : `assets/shaders/tint.gdshader`. Il sert aussi de
sonde au test — teinte, intensité bornée, booléen, plus un `uniform` commenté et
une matrice, qui ne doivent produire aucun champ.

**Publier depuis l'outil écrase les écritures fichier.** Le navigateur publie son
état **en mémoire**, pas le brouillon sur disque : une page ouverte avant une
modification faite hors de l'outil la réécrit à la publication, en silence et
sans conflit visible. C'est arrivé le 20/08/2026 — un calage `scale` posé dans
les deux JSON est revenu à 1 à la publication suivante. **Cliquer « Recharger du
serveur » avant de publier** dès que quelqu'un d'autre a touché aux données.

**Une vignette sur les fiches d'entités** (`visualThumb`) : la page Visuels avait
l'aperçu, les fiches d'entités n'avaient que du texte. Deux détails qui mordent :
un `<img>` sur une feuille montrerait la **bande entière**, d'où un
`background-size` exprimé en pourcentage de la découpe qui isole la première
case ; et `escapeHtml` rend `'` en `&#39;`, que le navigateur **redécode avant
CSS** — un chemin à apostrophe sortirait du `url()`, donc les guillemets sont
retirés du chemin. La vignette est posée sur `visualStateGrid` **et** `vfxGrid` :
elles partagent `.visual-row`, et la piste de grille est déclarée pour les deux —
sans quoi l'une des deux se décalerait d'une piste.

**Une valeur hors bornes se ramène à la borne la plus proche** (`clamp_to_bounds`,
config.php). Les six normalisations numériques faisaient `$number = $min` : écrire
`max_size: 8` donnait une créature à **0,25**, soit l'inverse exact de l'intention,
et l'inverse de ce que Godot fait du même chiffre (`clampf` écrête au maximum).
L'erreur reste signalée — écrêter n'est pas accepter. Une valeur non numérique n'a
pas de borne la plus proche et retombe sur le minimum.
**Le plafond de taille (0,25–5) n'a aucune justification écrite** : c'est un
garde-fou miroir des trois côtés, pas une limite technique. À 5, une créature fait
180 px de diamètre dans une arène de 1780 × 910. Le relever est trois lignes plus
le miroir dans `enemy_bounds_test`.

**Génération d'art : serveur MCP PixelLab** (ajouté le 20/08/2026, portée locale
à ce projet dans `~/.claude.json`, 76 outils). `create_character` puis
`animate_character` produisent le pixel art directement depuis l'assistant.
Quatre choses apprises au premier essai (`chicken_beast`) :

- **PixelLab rend des images séparées, jamais une feuille.** L'archive de
  `…/download` contient `frame_000.png`, `frame_001.png`… Il faut les composer en
  bande horizontale avant de les poser dans `assets/`, sinon la découpe
  `columns × rows` n'a rien à découper.
- **`VisualHolder` ignore les directions** — il n'a que `face()`, qui retourne
  horizontalement. Les 4 ou 8 rotations de PixelLab sont donc du gâchis : générer
  la vue **sud** seule (`directions: ["south"]` à l'animation) coûte 1 génération
  au lieu de 4.
- **Les URLs Backblaze directes renvoient 520.** Seul l'endpoint
  `…/characters/<id>/download` répond, avec l'en-tête d'autorisation.
- **L'éditeur ouvert importe tout seul au retour du focus.** Inutile de lancer un
  `--headless --import` concurrent : déposer le fichier puis cliquer sur la
  fenêtre Godot suffit, et ça ne touche pas à la session en cours.

Plan détaillé : `docs/architecture-visuels.html`.

Document d'architecture détaillé : `docs/architecture-variables.html`.
