# Journal de conception — archive

Ce fichier est l'ancien `CLAUDE.md` **dans son intégralité**, conservé au
20/08/2026 avant sa réduction. Rien n'a été supprimé du projet : ce qui a
disparu de `CLAUDE.md` est ici, mot pour mot.

`CLAUDE.md` est chargé à **chaque** session et doit rester court. Ce journal ne
se lit que sur demande : récit d'une fonctionnalité déjà construite, raison
d'une correction ancienne, détail d'une phase. En cas de doute sur « pourquoi
c'est écrit comme ça », chercher ici avant de rouvrir une décision.

> **Chemins périmés.** Ce journal parle de `big-egg/` partout : c'était la
> branche de travail quand il a été écrit. Depuis le **27/08/2026** la branche
> de travail est **`big-egg-3d/`** et `big-egg/` est une référence en lecture
> seule. Le raisonnement conservé ici reste valable ; les chemins des commandes,
> non — les commandes à jour sont dans `../CLAUDE.md`.

---

# 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`.

---

## 21/08/2026 — thèmes centralisés de l’arbre d’upgrades

Les couleurs et les surcharges `state_styles` vivaient sur chacun des 27 nœuds,
alors que les segments restaient codés en dur dans `upgrade_tree_canvas.gd`. Une
retouche cohérente demandait donc de répéter la même décision partout sans même
pouvoir harmoniser les liens.

Le schéma 20 introduit `upgrade_themes`. Un thème définit la forme et le style
complet des six états : fond, contour, texte, épaisseur du contour, puis couleur,
épaisseur et motif du segment. Six thèmes prêts à l’emploi couvrent les familles
cyan, cramoisie, azur, ambre, verte et violette. Le nœud ne garde que son
identifiant de `theme`, son éventuel `image_path` et `show_parent_link`; ce dernier
masque uniquement son segment vers le parent, sans détacher la branche.

L’outil présente désormais deux sous-onglets, **Nœuds** et **Thèmes**. La
suppression d’un thème utilisé est bloquée avec la liste des nœuds à migrer. Dans
le jeu comme dans l’aperçu web, le segment prend l’état du nœud enfant : le thème
peut ainsi rendre une branche verrouillée en tirets, disponible en continu et
achetée avec une autre couleur ou épaisseur.

### Correctif du panneau de conditions

Le panneau de survol lisait encore les anciennes clés `id` et `operator` par
accès direct, alors que les conditions d'upgrade normalisées portent désormais
une adresse `path`. Survoler un nœud verrouillé levait donc une erreur GDScript à
chaque entrée de souris et le menu paraissait gelé, jusqu'à rendre le retour au
restaurant impraticable. Le résumé lit maintenant `path`, `operator` et `value`
avec des replis sûrs. Le smoke test de retour ouvre ce chemin de rendu avant de
cliquer sur le bouton et vérifie que la cuisson reprend dans le restaurant.

---

## 22/08/2026 — le comptoir devient un vrai takeout

L'ancien comptoir à boissons n'était qu'une quantité remplie instantanément :
pas de recette, pas de cuisson, pas de file propre et aucune décision pour la
brigade. Il est remplacé par un poste de cuisine placé plus près de la rue.

Le joueur choisit **une recette maître** dans un mode dédié du menu de recettes.
Tenir l'action produit ensuite ce plat en succession, en consommant ses vrais
ingrédients, jusqu'à `takeout_stock_capacity`. La recette ne peut pas changer
tant qu'un plat fini ou une cuisson en cours porte l'ancienne recette. Le stock
et la progression ne créent jamais d'assiette physique.

Sept statistiques isolent son équilibrage : installation, capacité du stock,
vitesse de cuisson, vitesse de service, capacité de file, attrait et priorité.
Les upgrades existantes `street_window`, `deeper_counter` et
`street_reputation` gardent leurs identifiants pour les sauvegardes, mais ciblent
maintenant ces statistiques takeout.

Les passants intéressés rejoignent `takeout_queue`, une file extérieure qui
n'occupe ni la file d'entrée ni les tables. Chaque admission est couverte par un
plat déjà fini. Le comptoir sert automatiquement selon
`takeout_service_speed`; le client paie puis repart sans vaisselle et sans
pénalité de réputation si le stock manque.

Les cuisiniers connaissent désormais le genre de poste `takeout`. Sans commande
de salle plus prioritaire, ils réservent le comptoir, s'y déplacent, puis
enchaînent les cuissons jusqu'au plafond. Le rendement combine leur métier, leur
affinité de poste, leur spécialité de recette et `takeout_cook_speed`. Une file
en attente augmente l'urgence; un stock plein libère immédiatement le poste.

La sauvegarde passe en **version 10** avec `takeout_recipe`, `takeout_stock`,
`takeout_cooking_remaining` et `takeout_cooking_total`. Une sauvegarde 9 migre
`counter_drinks` vers le stock de la première recette valide au lieu de perdre
ce que le joueur avait accumulé. Hors scène, seuls les plats déjà cuisinés se
vendent : trafic, attrait et débit fixent la demande, puis le stock la borne.

Le smoke test exerce le menu, le travail du joueur, la sélection autonome d'un
cuisinier, le plafond, le refus de mélange, la file séparée, le délai de service,
la vente sans vaisselle, la sérialisation et les ventes d'absence. Une régression
volontaire qui empêchait l'ajout au stock a produit douze échecs takeout avant
restauration, ce qui prouve que le filet observe bien la mécanique et pas
seulement son existence.

Deux défauts voisins découverts pendant la validation ont aussi été réparés :
`boss_trophy` ciblait par erreur `management_console` au lieu de `payout`, et
deux assertions visuelles du smoke test supposaient encore qu'aucun visuel joueur
n'était livré. Le smoke test complet et le retour depuis l'arbre d'upgrades sont
de nouveau verts.

### Ajustement : la console configure, le comptoir travaille

Le premier jet ouvrait encore un sélecteur de recette au comptoir et dessinait
une voie de file séparée au-dessus du trottoir. Ce n'était pas le comportement
visé. La recette maître vit désormais dans un quatrième onglet **Takeout** de la
console. Appuyer ou tenir l'action au poste ne bloque plus les contrôles et
n'ouvre aucun panneau : le geste commence ou poursuit directement la cuisson,
comme sur un four. La brigade de la console possède aussi deux sous-onglets,
**Serveurs** et **Cuisiniers**, afin de ne plus mélanger les deux catalogues.

Le même onglet règle « restock below X, up to Y ». La règle est à hystérésis :
un cuisinier ne choisit pas le comptoir à X, il attend que le stock passe
strictement sous X; une fois déclenché, il garde le poste jusqu'à Y. Le joueur
reste libre de produire manuellement jusqu'à la capacité dure. X, Y et le lot en
cours font passer la sauvegarde en **version 11**.

La ligne graphique a été retirée. Chaque passant porte maintenant deux points de
décision physiques : le centre du comptoir pour le takeout et la porte pour la
salle. Un client intéressé s'arrête au premier, rejoint une seconde file sur le
trottoir devant le comptoir et doit atteindre sa place avant que le chronomètre
de service commence. Le débit initial vaut `1 / 30` client par seconde, donc
environ **30 secondes par client** sans upgrade.

Le smoke test verrouille ces détails : sélection à la console sans cuisson,
travail direct au poste, déclenchement sous X et arrêt à Y, absence de décision
takeout à la porte principale, croisement réel du comptoir, arrivée en tête de
file et délai initial de 30 secondes. Le test complet reste vert.

Preuve du filet : remplacer temporairement le seuil strict `< X` par `<= X` a
fait échouer précisément l'assertion « le cuisinier repart alors que le stock est
exactement à X ». La condition stricte a ensuite été restaurée et la suite est
redevenue verte.

### Ajustement : aider le service quand la réserve est pleine

Le geste au comptoir change naturellement de fonction au plafond : tenir
l'action avec une réserve pleine accélère maintenant le chronomètre de service
de **30 %** au lieu de tenter une cuisson impossible. Un serveur sans tâche plus
prioritaire peut réserver une place physique au comptoir et apporte un autre
**30 %** tant que la file contient un client et qu'un plat est prêt. Une seule
réservation de serveur est permise; les deux contributions sont additives, soit
un multiplicateur de **1,6×** lorsque le joueur et le serveur travaillent
ensemble. Le débit sans aide reste `1 / 30` client par seconde.

La file takeout n'affiche plus la recette au-dessus des clients. Cette première
version conservait encore leur barre de patience; l'ajustement suivant la
remplace par l'avancement réel du service. Les clients de salle gardent leur
affichage de commande inchangé.

Le smoke test couvre l'absence de bonus avant le plein, le bonus joueur à 1,3×,
l'affectation et la libération du serveur, le cumul à 1,6×, la vente réellement
accélérée et l'absence de bulle takeout. Neutraliser temporairement la constante
de bonus a déclenché sept échecs ciblés avant restauration; le test observe donc
bien le débit réel et pas seulement l'existence des nouvelles fonctions.

### Correctif : un chronomètre stable et des priorités lisibles

Le ralentissement apparent des aides était réel :
`passerby_reached_takeout()` remettait le chronomètre à 30 secondes à chaque
nouveau client ajouté derrière la tête. `customer_left_queue()` faisait la même
chose lorsqu'une personne quittait l'arrière de la file. Une file plus active
pouvait donc effacer répétitivement le travail déjà accompli par le joueur ou
le serveur, au point de rendre leur bonus contre-productif en apparence. Le
chronomètre n'est désormais initialisé que lorsqu'une nouvelle file commence et
n'est réinitialisé que si sa tête quitte réellement la file.

Le retour visuel montre maintenant la mécanique qui compte. Le statut du
comptoir affiche le pourcentage de service, le multiplicateur courant et le nom
des aides actives. Tenir l'action au comptoir plein affiche immédiatement que le
bonus joueur de 30 % est prêt ou actif; un serveur posté affiche aussi son
`+30 %`. Sur le trottoir, seule la tête arrivée au comptoir montre une barre :
c'est l'avancement du service, calculé depuis le même chronomètre. La patience et
la bulle de recette restent invisibles pour tous les clients takeout.

Le schéma **21** ajoute aux serveurs quatre `task_preferences` fixes sur une
échelle commune de 0 à 5 : prise de commande, service des assiettes, nettoyage
et takeout. La priorité finale est l'urgence de la situation multipliée par la
préférence correspondante. Les anciennes compétences ne pèsent plus secrètement
sur le choix : elles déterminent uniquement la vitesse ou la qualité une fois la
tâche choisie. Les quatre valeurs apparaissent comme de petites jauges sur les
fiches d'embauche, afin de garder les profils comparables malgré le nombre de
statistiques. Le plancher de déplacement des cuisiniers passe aussi de 35 % à
50 % de la vitesse de base.

Le smoke test compare directement la barre au chronomètre, vérifie qu'elle ne
dépend pas de la patience, change les préférences d'un vrai serveur pour lui
faire choisir successivement une commande puis le takeout, inspecte les quatre
jauges d'embauche et protège le nouveau plancher de vitesse. Preuve ciblée du
filet : inverser temporairement la condition qui initialise une nouvelle file a
fait échouer exactement les deux assertions sur l'arrivée et le départ derrière
la tête. La condition correcte a ensuite été restaurée.

### Ajustement : deux hit zones et un langage visuel commun

Le comptoir changeait encore de sens selon la réserve : près du même meuble,
Action préparait tant qu'il restait de la place puis aidait le service seulement
au plafond. Cette économie d'un emplacement rendait le geste impossible à lire
et liait deux fonctions qui doivent pouvoir coexister. Le meuble n'est donc plus
lui-même la cible. Deux tapis non superposés portent les vraies hit zones :
`takeout_prepare` à gauche et `takeout_service` à droite. La première ne fait que
cuisiner; la seconde apporte toujours 30 % au service, quel que soit le niveau du
stock et même si un serveur est déjà placé. Joueur et serveur restent additifs à
1,6×. De la même façon, préparer pendant qu'un cuisinier est en poste ajoute le
taux du joueur au sien : le prompt ne promet jamais un travail absorbé par un
simple maximum.

Le tapis devient le vocabulaire de tous les postes fixes du restaurant : fours,
épicerie, étagère, plonge, poubelle et console. Ce n'est pas un décor posé près
d'une ancienne portée invisible. `InteractiveProp.get_interaction_position()`
et `get_interaction_radius()` servent à la fois au choix de cible, au travail
continu et au dessin. Le tapis s'éclaire discrètement lorsqu'il est sélectionné.
Les assiettes et les clients gardent leur propre halo ou leur place physique,
car ajouter un tapis à un objet mobile aurait produit le bruit inverse.

Le HUD effectuait auparavant une seconde recherche du nœud le plus proche,
différente de celle du joueur et ignorant notamment la priorité d'une assiette.
Il lit désormais directement `Player.current_target`. Un petit panneau
`SPACE / A` se place au-dessus de la cible dans l'espace écran et disparaît
lorsque `can_interact()` dit que le geste n'aurait aucun effet. Les prompts de
console, cuisson, ramassage, lavage, rangement, poubelle, service à table et
takeout partagent ainsi exactement la cible qui recevra Action.

Le smoke test inspecte les tapis de chaque poste, vérifie que les deux cercles
takeout ne se recouvrent pas, que préparation et service ne s'échangent jamais,
que le bonus service fonctionne avant le plein et avec un serveur présent, et
que le panneau suit la console, une assiette, la plonge et les deux tapis. Preuve
du filet : relier temporairement le bonus service au tapis préparation a produit
cinq échecs ciblés (mauvais tapis, débit, statut et cumul) avant restauration de
la liaison correcte.

### Correctif : un texte léger, y compris quand Action échoue

Le panneau et l'éclairage de tapis ajoutaient beaucoup plus de bruit visuel que
le geste qu'ils devaient expliquer. Le fond, la bordure, l'ombre et le séparateur
du prompt sont retirés; il ne reste qu'une petite ligne `(SPACE / A) action`
près de la cible. Les tapis redeviennent entièrement fixes et ne réagissent plus
à la sélection.

Masquer le prompt quand `can_interact()` était faux posait l'autre problème :
un four sans ingrédient ou une plonge vide ne disait rien du tout. Les postes
fixes peuvent désormais rester `current_target` avec une pénalité de sélection,
afin qu'une action réellement disponible à proximité garde toujours priorité.
Le texte devient rouge et décrit le blocage : ingrédients manquants, aucune
assiette à laver, mauvaise assiette, étagère pleine, rien à jeter, takeout plein
ou recette maître absente. Appuyer sur Action appelle encore le poste et produit
le toast de refus correspondant.

Le smoke test impose un takeout sans ingrédient puis une plonge vide, vérifie la
cible conservée, le texte exact, les deux teintes rouges, le fond transparent et
le toast obtenu à l'appui. Il revient ensuite à une action disponible pour
protéger la teinte neutre. Preuve de sensibilité : remplacer temporairement le
rouge du texte par la couleur neutre a produit exactement deux échecs ciblés,
un pour le takeout et un pour la plonge, avant restauration.

### Simplification : une vitesse, des repères orientés et un HUD allégé

La bascule marche/course ajoutait une décision permanente sans enrichir le
déplacement. Le schéma **22** remplace donc `walk_speed`, `run_speed` et leurs
deux coûts de stamina par `move_speed` et `move_stamina_per_second` dans le
maître, le brouillon, les valeurs de secours, les normalisations Godot/PHP et
le formulaire JS. La valeur livrée est **400 px/s**, milieu exact des anciennes
vitesses 300 et 500. L'action `toggle_run`, le signal, l'état runtime et les
mentions R/X disparaissent; rester immobile ne coûte toujours rien et le dash
conserve son coût immédiat.

Les tapis décrivent maintenant aussi le sens d'approche. Fours, étagère,
plonge et poubelle ont un rectangle vertical sur leur côté; console et takeout
gardent un rectangle horizontal devant eux. Le tapis reste visuellement fixe.
Quand la cible est réellement utilisable, `InteractiveProp` dessine plutôt un
contour rectangulaire statique autour du meuble : couleur, marge, épaisseur,
décalage et taille sont exportés. Les deux zones invisibles du takeout décalent
ce contour jusqu'au comptoir physique, au lieu d'encadrer le tapis.

Le HUD perd trois blocs permanents : inventaire, assiette portée et mode de
déplacement. Objectif, toast et fermeture deviennent des lignes de 12 px dans
le coin inférieur gauche. Le stock complet entre dans un cinquième onglet
**Ingrédients** de la console, avec une quantité par matériau. Quand le joueur
porte une assiette, seul le nom du plat — ou « assiette sale » — apparaît dans
un petit label avec contour au-dessus de sa tête; il disparaît au service, au
rangement ou au lavage.

Le smoke test vérifie la vitesse et le coût uniques, l'absence de `toggle_run`,
les cinq onglets et toutes les quantités, la disparition des anciens panneaux,
la position et la taille des messages, le label porté, l'orientation des tapis,
le contour de la console, le proxy du takeout et l'absence de contour sur une
action indisponible. Preuve de sensibilité : forcer temporairement le calcul du
contour à `false` a produit exactement deux échecs ciblés — console et takeout —
avant restauration.

### Correctif : tapis sous les acteurs, contour collé et verbes courts

Le tapis était encore rendu dans `InteractiveProp._draw()`. Comme les meubles
arrivent après le héros dans l'arbre de scène, le tapis héritait du même ordre
que son meuble et se peignait par-dessus le personnage. Les repères sont
maintenant des nœuds autonomes produits par
`scripts/core/interaction_floor_marker.gd` et rangés sous
`InteractionFloorMarkers`, avant le joueur et les autres acteurs. Chaque repère
suit la transformation et la visibilité de son poste, tandis que le poste
conserve seul le corps, le statut et le contour.

Le contour n'est plus un second rectangle distant : sa marge par défaut vaut
zéro et il est dessiné en dernière passe directement sur le bord du meuble. Les
réglages restent configurables mais sont désormais faciles à trouver sous le
groupe d'inspecteur **Target Outline** (`color`, `width`, `margin`, `offset`,
`size`). Les proxies préparation et service du takeout continuent de viser le
comptoir physique.

Les prompts disponibles ont été ramenés à des verbes de huit caractères ou
moins : `Manage`, `Cook`, `Prepare`, `Service`, `Refine`, `Wash`, `Store`,
`Discard`, `Serve` et `Pick up`; la touche devient `(SPACE/A)`. Dès qu'une
Action valide est maintenue, la ligne disparaît afin de laisser lire le feedback
du travail. Une action impossible fait l'inverse : sa raison détaillée reste
visible en rouge, même pendant l'appui.

Le smoke test contrôle la présence du repère séparé pour chaque poste de
cuisine, l'ordre de la couche sous le héros, la marge nulle, la longueur des
verbes, le masquage pendant la préparation et la persistance du refus rouge.
Preuve de sensibilité : déplacer temporairement `InteractionFloorMarkers` après
le joueur a produit exactement un échec ciblé sur l'ordre de rendu; la couche a
ensuite été remise avant le héros.

### Interaction : emplacement d'abord, curseur en cas d'ambiguïté

La distance seule pouvait hésiter entre deux tâches presque équidistantes. Le
joueur conserve désormais tous les filtres physiques — cible active, action
permise ou refus explicable, position dans le rayon — puis compare les scores.
Si les deux meilleurs sont séparés d'au plus 32 px et que la souris pilote la
visée, aucun choix implicite n'est fait : le curseur doit entrer dans le rayon
visuel d'un tapis, calculé depuis `interaction_spot_size`. Pointer ailleurs ne
sélectionne rien. Une cible nettement plus proche reste automatique et la
manette conserve toujours le repli par proximité.

Le même `Player.current_target` gouverne maintenant le geste ponctuel, le HUD,
le contour et le travail maintenu. Les fours et les deux tâches takeout ne se
contentent plus de voir Action tenue dans leur rayon : ils vérifient aussi être
la cible choisie. Une seule préparation ou aide au service peut donc progresser
pendant l'appui, même si deux zones ont été rendues volontairement
chevauchantes.

Le smoke test agrandit temporairement les deux rayons takeout et place le joueur
à égale distance. Il vérifie curseur gauche, curseur droit, curseur absent,
préparation seule, service seul, refus hors portée et repli manette. Preuve de
sensibilité : ignorer temporairement le curseur a produit exactement trois
échecs ciblés — mauvais choix de service, tâche choisie sans pointeur et mauvaise
action continue — avant restauration.

## Accueil, options, pause et sauvegardes à trois slots

Le jeu démarre désormais sur une page d'accueil 1920 × 1080 dédiée, avec
Nouvelle partie, Continuer, Options et Quitter. Nouvelle partie et Continuer
ouvrent le même écran de trois slots; un slot occupé demande confirmation avant
d'être remplacé et les résumés montrent dimension, temps, pièces et date.

La sauvegarde passe en version 12. Chaque slot vit sous `user://saves/` et
s'écrit d'abord dans un fichier temporaire validé, puis remplace l'ancienne
version en gardant un `.bak`. Un fichier principal corrompu retombe sur ce
secours et est réparé sans sacrifier le backup. L'ancien `savegame.json` migre
vers le slot 1 sans être supprimé. Les options sont globales dans
`user://settings.json`, donc changer de partie ne change plus résolution,
langue, volume général ou VSync. Les cuissons du restaurant font maintenant
partie du payload afin que Save and Quit ne mange ni progression ni ingrédients.

Le menu Options réemploie le sélecteur d'affichage existant, mais celui-ci a
quitté le panneau de débogage permanent du HUD. Le HUD ne porte plus ce panneau;
Escape/Start ouvre plutôt Pause avec Continuer, Options et Save and Quit.
L'écriture doit réussir avant le retour à l'accueil, sinon la partie reste
ouverte et un message rouge explique l'échec.

Le test `menu_system_test` instancie le parcours, vérifie les trois slots,
l'intégration de l'affichage, la pause et la rotation atomique. Sa sensibilité a
été confirmée en ramenant temporairement le nombre de slots à deux : deux
échecs ciblés ont été produits avant restauration. Une capture OpenGL réelle a
aussi validé la composition de l'accueil; le pilote headless dummy ne pouvant
pas produire la texture, la première tentative de capture avait planté dans
Godot avant tout rendu du menu.

## Butin : lignes compactes et probabilités indépendantes

Le grand tableau de tous les ingrédients à cocher a été remplacé, pour les
monstres et les éléments de salle, par une liste libre de lignes. Chaque ligne
contient un sélecteur d'ingrédient, une probabilité en pourcentage et un bouton
de suppression; **Ajouter un butin** crée une nouvelle ligne avec le premier
ingrédient encore disponible. Un ingrédient déjà choisi est désactivé dans les
autres sélecteurs afin d'empêcher les doublons.

Le champ `drops` change donc de sens au schéma 25 : sa valeur n'est plus une
quantité garantie, mais une chance indépendante comprise entre 0 et 100. Un jet
réussi produit une unité, `100` garantit le butin et `0` permet de le désactiver
sans effacer la ligne. Les anciennes valeurs `1` ont été migrées vers `100.0`
pour conserver exactement leur résultat en jeu. Les quantités de recettes ne
sont pas touchées.

PHP et Godot normalisent tous deux les identifiants, les bornes et les valeurs
numériques. Le test `drop_editor_test` couvre la gestion des lignes et le
câblage jusqu'au combat; `drop_validation_test.php` couvre maître et brouillon,
valeurs décimales, bornes, ingrédients inconnus et collection vide. Le smoke
test vérifie aussi les jets à 0, 37,5 et 100 %. Les suites complètes de l'outil,
le parse Godot et les deux smoke tests passent après la migration.

## Butin répété et espace de travail responsive

Le schéma 26 remplace la carte de butin du schéma 25 par une liste ordonnée de
jets `{ingredient_id, chance}`. La carte interdisait structurellement deux clés
identiques; la liste permet désormais, par exemple, un œuf garanti à 100 % puis
un second œuf à 10 %. Les deux lignes sont sauvegardées séparément, validées
séparément et parcourues séparément à la mort du monstre. Les données maître et
brouillon ont été migrées sans fusionner ni écraser les changements non publiés
du brouillon. La suppression d'un ingrédient balaie toutes les lignes qui le
référencent.

L'outil occupe maintenant la hauteur visible plutôt que d'allonger le document
entier. Le catalogue de gauche et le formulaire principal possèdent chacun leur
propre défilement; chercher un autre monstre ne fait donc plus perdre la position
du formulaire. Le plafond du formulaire passe de 1120 à 1720 px. À partir de
1800 px de largeur CSS, les cartes de la zone principale se distribuent sur deux
colonnes; augmenter le zoom réduit cette largeur CSS et ramène naturellement la
pile à une colonne.

`drop_editor_test`, `drop_validation_test.php`, `map_shape_test` et le smoke
Godot couvrent les doublons, les chances indépendantes, la sérialisation et le
runtime. `management_layout_test` fixe le double défilement et le breakpoint.
Toutes les suites JS/PHP, le parse Godot et les deux smoke tests passent.

## Multiplicateur global des jets de butin

La statistique `drop_count_multiplier` rejoint les Variables avec une base de
1, un minimum de 0, un maximum de 100 et aucun arrondi. Elle utilise les quatre
couches ordinaires : une upgrade `scaling +1` transforme la base en 2×, alors
qu'un malus `scaling -0,5` la ramène à 0,5×. Aucun traitement spécial n'est donc
nécessaire dans l'éditeur d'upgrades.

À la mort d'un monstre, chaque ligne de butin calcule séparément son nombre de
tentatives. La partie entière du multiplicateur produit des jets certains; la
partie décimale est la probabilité d'un seul jet supplémentaire. Ainsi 1,25
donne toujours un jet et 25 % de chance d'un second, tandis que 0,5 donne 50 %
de chance d'effectuer le premier. Chaque tentative applique ensuite la
probabilité propre à la ligne : le multiplicateur augmente bien le nombre de
jets, il ne modifie pas directement leur pourcentage.

Le smoke test fixe les frontières 0, 0,5, 1,25 et 2 avec des tirages
déterministes. `drop_editor_test` vérifie aussi la présence de la variable dans
le maître, le brouillon et les valeurs de secours, ainsi que sa consommation par
le runtime. Le parse Godot, le smoke complet et les validations PHP passent avec
40 statistiques publiées.

## Portée magnétique des butins libres

La statistique `drop_magnet_range` est ajoutée aux Variables de donjon. Elle est
un rayon en pixels, de 0 à 4000, avec une base nulle : sans upgrade, le
ramassage reste strictement identique. Une upgrade peut employer `flat +200`,
par exemple, pour attirer les ingrédients libres situés à 200 px ou moins.

Le ramassable attend toujours la fin de son petit saut d'apparition. Il cherche
ensuite le joueur par son groupe, lit la portée courante et avance vers sa
position à vitesse fixe tant qu'il reste dans le rayon. Il traverse ainsi la
distance sans téléportation. Le magnétisme ne crédite jamais l'inventaire
directement : `body_entered` et le contrôle des chevauchements restent les seuls
chemins vers `_try_collect`, avec les compteurs et crochets existants.

`drop_magnet_test` vérifie maître, brouillon, localisation, valeur de secours et
câblage du runtime. Le smoke teste les limites 0, hors rayon et sur la limite,
puis instancie un vrai ramassable à 180 px et confirme qu'une portée temporaire
de 240 px le rapproche du héros. Les suites passent avec 41 statistiques.

## Vitesse magnétique sortie du code

La vitesse de 520 px/s était initialement une constante `MAGNET_SPEED` de
`ingredient_pickup.gd`. Elle devient la 42e statistique,
`drop_magnet_speed`, publiée à 520 avec des bornes de 0 à 5000. Le ramassable
lit désormais portée et vitesse à chaque frame; une valeur de vitesse nulle
arrête le mouvement même si la portée est positive. Le calcul pur reçoit la
vitesse en paramètre et aucune valeur 520 ne subsiste dans le runtime.

La portée publiée avait entre-temps été réglée à 300 dans l'outil. Cette valeur
d'auteur est conservée; seul le secours intégré de catalogue reste à 0. Le test
ne fige donc plus une valeur de contenu éditable et vérifie plutôt qu'elle
traverse le résolveur. `drop_magnet_test` couvre la nouvelle variable, sa
localisation et l'absence de constante; le smoke fixe la base à 520 et le cas
vitesse nulle.

## Bond parabolique du magnétisme

Le déplacement magnétique conserve une trajectoire de collision directe vers
le héros, mais le visuel du ramassable reçoit maintenant une hauteur calculée
par `4 × hauteur × phase × (1 − phase)`. C'est une parabole exacte : sol au
départ, sommet à mi-course, retour au sol, puis nouveau bond. Une petite ombre
reste sur le point de collision pour rendre la hauteur lisible; une icône et la
forme procédurale utilisent le même décalage.

Les paramètres cosmétiques vivent sous le groupe d'inspecteur **Magnet Bounce**
de `IngredientPickup` : hauteur 34 px et longueur 180 px par défaut. Ils ne sont
pas des statistiques d'upgrade, contrairement à la portée et la vitesse qui ont
un effet mécanique. Quitter le rayon remet le visuel au sol. Le smoke vérifie
les trois points de la parabole et confirme sur un vrai ramassable que le visuel
monte tandis que `CollisionShape2D` reste à l'origine.

---

## Histoire des schémas 27 à 34 — retirée de `CLAUDE.md` le 27/08/2026

Ces paragraphes racontaient **pourquoi** chaque schéma a bougé et comment la
migration s'est faite. `CLAUDE.md` n'en garde que la règle qui vaut aujourd'hui;
le raisonnement est ici, à relire avant de rouvrir une de ces décisions.

**Schéma 29 — meubles du restaurant.** Une seule collection,
`restaurant_elements` : nom, taille, `kind` (le comportement : `stove`,
`dishwasher`, `trash`, `table`, `console`, `takeout`, `shelf`, `board`,
`spices`, `employee_locker`, `none`), `visuals` **par quart de tour** (`front`/`right`/`back`/
`left`, avec la chaîne de repli habituelle jusqu'à la forme procédurale),
`usable_faces`, `anchor`, `cost`, `modifiers` passifs.

**Une collection `station_functions` a existé et a été fondue ici (schéma 29).**
Elle était pensée clonable : un « four rapide » aurait été un four à
coefficients (`speed`, `capacity`, `quality`, `reach`). Les brancher demandait
quatre points d'insertion asymétriques, une couche locale hors des quatre
couches de statistiques, et de démêler au passage la dualité four/épicerie —
trop cher pour le gain. Sans coefficients, deux fonctions de même `kind`
étaient identiques : la collection ne faisait que renommer un mot que le code
connaît déjà. **Ne pas la réintroduire sans brancher les coefficients d'abord.**

**Schéma 33 — vagues de renfort et feuilles directionnelles.** Une salle accepte
`reinforcement_waves: 0`, qui veut dire **aucun renfort** — et non « demande
au-dessus », puisque aucune statistique ne surplombe ce champ; `DiveMap` faisait
auparavant `maxi(1, …)` et ramenait le zéro à une vague, ce qui rendait la
salle sans renfort impossible à écrire. `reinforcement_waves_max` au-dessus du
minimum fait **tirer** le nombre de vagues à l'entrée de la salle; à zéro, le
nombre déclaré sort tel quel. Côté visuels, `rows_are_directions` déclare une
feuille dont les rangées sont des orientations (§6).

**Schéma 34 — forme des salles.** Une salle déclare `scroll_axis`
(`none`, `horizontal`, `vertical`) et `scroll_extent` (1 à 2,5). `none` est
l'arène 16:9 d'origine, au pixel près; les deux autres l'étirent sur **un seul
axe** — les deux à la fois redonneraient une carte, et la caméra n'aurait plus
de cadrage sûr. L'origine ne bouge jamais : l'entrée, la sortie de secours et
les murs nord et ouest gardent leur place, seule la face opposée s'éloigne et la
porte la suit. La caméra ne se déplace **que** sur l'axe déclaré, bornée au
monde dessiné. **Une salle de fin de dimension ne défile pas** : un boss qui
sort du cadre pendant qu'il poursuit le joueur rend le combat illisible.
La grille d'apparition s'étire avec l'arène, sinon les monstres resteraient
entassés dans la première moitié de la salle.

**Schéma 32 — dépendances de pose.** `placement_requires` est une liste de
sources de meubles qui doivent déjà être sur le plancher. Le mode construction
refuse la pose tant qu'il en manque une et refuse le retrait du dernier
prérequis tant qu'un dépendant reste posé. Le `employee_locker` exige
`basic_shelf` et `basic_order_board`; sans casier posé, aucune embauche ni mise
au travail n'est possible. Retirer le casier conserve les contrats mais place
tous les employés actifs en stand-by.

Le casier suit `max_count_variable: employee_locker_count` : cette statistique
vaut `0` avant son upgrade et `1` après. Le nœud `casier` ajoute donc un
modificateur `flat +1` à la statistique, exactement comme une upgrade de table;
il n'écrit aucun effet ponctuel dans le meuble.

**Sauvegarde 15 — contrats et travail séparés.** `hired_employees` contient les
contrats acquis; `working_employees` est son sous-ensemble actif et seul ce
dernier occupe les slots, apparaît dans la salle et produit des modificateurs.
`employee_standby_reasons` mémorise `new_hire`, `manual`, `limit` ou `locker`.
L'onglet Brigade se révèle au premier déblocage de la fonction
`employee_locker`; `meta.brigade_unlocked` le garde ensuite entre les dimensions.

**Schéma 30 — prix d'un nœud d'upgrade.** `cost` + `cost_resource` sont remplacés
par une liste `costs: [{resource, amount}]`. `resource` vaut `coins` ou un
identifiant d'ingrédient : les deux sont des **quantités possédées à déduire**,
et le refus d'achat nomme précisément ce qui manque. Liste vide = gratuit.

`cost_resource` était du **texte libre que le jeu ignorait** : `purchase_upgrade`
ne dépensait que des pièces quoi qu'on y écrive. Encore un champ d'auteur que
personne ne lisait.

**Pièces et ingrédients restent deux stockages.** Ils ont la même forme — un
entier possédé — mais deux rôles : monnaie d'échange contre entrée de
production. Les fondre rendrait chaque ingrédient utilisable comme monnaie et
chaque monnaie utilisable dans une recette, et il faudrait ensuite écrire les
règles qui interdisent les combinaisons absurdes. L'unification se fait **au
point de dépense**, pas dans le stockage. Si un jour les pièces deviennent un
ingrédient, `resource` devient « toujours un ingrédient » et le format survit.

**Schéma 31 — conditions des recettes et effets de matériaux.** Chaque recette
porte `conditions`, une liste adressée combinée par ET. Elle décide si le plat
peut être placé à la carte, choisi au takeout ou cuisiné. Pour « avoir acquis X
matériaux », viser `ingredients.<id>.total_collected >= X`; pour exiger le stock
actuel, viser `ingredients.<id>.owned >= X`; une upgrade se teste par
`upgrade_nodes.<id>.purchased truthy`. Une condition de recette ne peut pas lire
`recipes.<id>.can_cook`, car ce champ réévaluerait la condition récursivement.

**L'éditeur d'upgrade n'a qu'une seule liste d'effets.** Une cible `stat.*`
donne une contribution **permanente** avec sa couche, retirée d'elle-même si
l'upgrade est perdue; une cellule stockée (fait, compteur) reçoit une écriture
**ponctuelle** à l'achat, jamais reprise. Changer l'espace d'une ligne la
déplace entre `modifiers` et `on_purchase`. **Le stockage reste séparé** : les
mélanger casserait la formule des quatre couches. Seule l'édition est unifiée.

**Le déblocage binaire passe par `conditions`, jamais par un champ d'upgrade dédié.**
Un second mécanisme en parallèle créerait deux sources de vérité pour « est-ce
disponible ». `conditions` lit n'importe quelle adresse, donc **les deux formes
existent déjà, sans une ligne de code** :

| forme | à écrire | quand |
|---|---|---|
| directe | `conditions: [{path: "upgrade_nodes.<id>.purchased", operator: "truthy"}]` | **par défaut** — un seul chemin de déblocage |
| par un fait | l'upgrade pose `{op: "set", path: "fact.<x>", value: true}` dans son crochet `on_purchase`, et l'élément lit `fact.<x>` | quand **plusieurs chemins** débloquent la même chose (une upgrade **ou** un boss vaincu **ou** une quête) |

`purchased` est un champ calculé des `upgrade_nodes`; `fact.*` est inscriptible
et `Registry.run_hook("upgrade_nodes", id, "on_purchase")` est bien joué à
l'achat. La forme directe n'a donc **rien à poser nulle part**; la forme par un
fait ne se paie que quand le point de rendez-vous sert vraiment.

La collection `restaurant_elements` alimente seule l'onglet **Meubles** de
l'outil. Chaque meuble porte directement son `kind`; il n'existe plus de
sous-onglet ni de collection séparée pour les fonctions.

**Schéma 27 — attaques de créature.** Un monstre ne porte plus `attack_pattern`,
`damage` ni `damage_variance`. Il porte une **liste** `attacks`
(`{pattern, damage_min, damage_max, cooldown, weight}`) plus le bloc de coup
direct (`direct_hit`, `direct_damage_min/max`, `direct_hit_cooldown`). Un
pattern ne porte plus de `cooldown` : la cadence appartient à l'attaque qui
l'emploie, pas à la géométrie qu'elle réutilise. `migrate_schema_27.php` fait la
conversion; il **doit** passer par `validate_and_normalize()` et
`write_json_file()`, jamais par un `json_encode` direct (§8, cartes vides).
Les salles sont écrites par l'utilisateur.

---

## Trois sections resserrées dans `CLAUDE.md` le 27/08/2026

Le plan de la salle, les faces d'interaction et le restaurant permanent. Les
règles sont restées dans `CLAUDE.md`; leur version longue, avec le récit des
régressions qui les ont fait naître, est conservée ici mot pour mot.

### Le plan de la salle et le mode construction

**Aucune position de meuble n'est plus une constante.** Chaque chose déplaçable
a une fiche `RestaurantElement` : identifiant, genre, encombrement, position,
rotation par quarts de tour, ancrage éventuel. `station_center()` et
`table_center()` **lisent le plan**, ils ne déduisent plus d'une grille figée —
sans quoi déplacer un four laisserait son cuisinier viser le vide. Les
constantes `STATION_ORIGIN`, `TABLE_ORIGIN`… ne servent plus qu'à donner la
position de départ (`default_station_center()`).

`apply_layout()` est le **point de passage unique** du plan vers la salle :
position du nœud, taille après rotation, masque de faces tourné, collision,
centre et tabouret d'une table. Y ajouter un genre d'élément sans y ajouter son
application, c'est un meuble qui bouge à l'écran et pas dans le jeu.

Le plan vit dans `GameState.restaurant_layout` et suit la portée `dimension` :
une salle réaménagée le reste jusqu'au monde suivant. Un plan vide veut dire
« disposition d'origine », donc une partie neuve n'a rien à stocker.

Règles de placement, toutes dans `placement_blocker()`, qui rend **une raison**
et jamais un booléen — la couche verte et le fantôme rouge disent alors la même
chose, et le joueur apprend la règle en la voyant :

- un meuble ne touche jamais un mur (`WALL_CLEARANCE`, la zone libre est déjà en
  retrait) ;
- deux emprises ne se chevauchent pas ;
- un élément **ancré** échappe à la première règle mais pas à la seconde : le
  comptoir takeout reste sur le mur nord, à gauche de l'entrée, à
  `TAKEOUT_DOOR_CLEARANCE` de la porte, et ne glisse que le long de ce mur.

`BuildMode` ne connaît que des fiches : il ignore ce qu'est un four. Clic pour
prendre, `R` pour tourner, clic pour poser, Échap ou clic droit pour reposer.
**Sortir du mode en portant un meuble le repose d'où il vient** — quitter ne doit
pas être une façon de perdre un four. La couche verte est peinte par la
validation elle-même : elle ne peut donc pas mentir sur ce qui est posable.

**Poser, retirer, reprendre.** Un exemplaire n'est jamais créé ni détruit: sa
fiche existe toujours et change d'état. `placed` dit s'il est dans la salle,
`dismissed` dit qu'il en a été **retiré à la main**. Les deux sont nécessaires:
`_sync_placement_with_limits()` pose d'office ce qu'une statistique vient
d'ouvrir — acheter une upgrade doit faire apparaître son meuble — mais ne
repose jamais ce que le joueur a enlevé, sinon le × n'aurait aucun effet
durable. Un meuble à `cost` nul fait partie de la salle et se pose seul; un
meuble qui coûte quelque chose attend dans la palette.

Un poste qui cuisine et un tabouret occupé **ne se retirent pas**
(`removal_blocker`): un plat payé disparaîtrait avec eux. **La console ne se
retire jamais**, quel que soit son état: elle est le seul chemin vers
l'interrupteur du magasin *et* vers ce mode, donc la perdre bloquerait la partie.
Elle reste déplaçable.

**Kit minimum pour ouvrir** (`ESSENTIAL_FUNCTIONS`): un four, une plonge, une
poubelle, une place. `opening_blocker()` nomme ce qui manque et l'interrupteur
refuse. Le contrôle est **à l'ouverture, pas au retrait**: on réaménage
librement, on ne peut simplement pas ouvrir un magasin incapable de servir.

**Tourner une table tourne son tabouret autour d'elle.** Trois choses doivent
suivre la rotation, pas une: la place réellement occupée (`tables[i].seats`), le
dessin du siège, et le `footprint_offset` de l'élément — sinon on poserait une
table dont le tabouret finit dans un mur.

**Le mode construction s'achète** : `stat.build_permit` (0/1), accordé par le
nœud d'upgrade `build_permit`. Sans lui la console **n'affiche même pas le
bouton** — mieux vaut ne rien offrir qu'offrir un refus.

**On n'ouvre pas le chantier boutique ouverte** (`build_mode_blocker`): les deux
rideaux doivent être baissés **et** la salle vide. Déplacer une table sous un
client attablé le laisserait assis dans le vide. « Vide » veut dire les états
`ARRIVING`, `WAITING` et `EATING` — **pas** la liste `customers` entière, qui
contient aussi les passants du trottoir. La rue vivant même rideau baissé, les
compter rendait le chantier impossible à ouvrir, jamais.

Une table qui bouge **emmene ce qui est posé dessus** (`_carry_table_contents`).
Interdire le déplacement aurait reporté sur le joueur un problème que le code
règle seul.

**`apply_layout()` applique la présence, pas seulement la position.** Un meuble
retiré perd son dessin, son interaction et sa collision; un tabouret retiré sort
de `visible_table_indices()`. Sans ça le × marquait la fiche et laissait le
meuble à l'écran.

**`_restore_player_controls()` ne rend jamais la main pendant le chantier.** La
restauration est différée d'une frame: sans cette garde, le joueur reprenait les
commandes **dans** le mode construction et son clic gauche rouvrait la console
qu'il venait de quitter — le menu revenait sans arrêt par-dessus le chantier.

**Les bonus passifs se recalculent en bloc** à chaque `apply_layout()`, sous la
source `decor:<id>`, un exemplaire posé comptant une fois. La liste des
modificateurs est la vérité, l'accumulateur n'est qu'un cache (§3): retirer un
meuble retire donc son bonus sans que personne ait à s'en souvenir.

### Priorités des serveurs et service takeout

Les quatre valeurs `task_preferences` d’un serveur (`take_order`, `serve`,
`bus`, `takeout`) sont sur une échelle commune de 0 à 5 et décident seules de
son choix entre les tâches disponibles. Les compétences déterminent ensuite la
vitesse ou la qualité d’exécution; ne pas les réinjecter comme priorités cachées.

Le chronomètre takeout appartient uniquement au client en tête arrivé au
comptoir. L’arrivée ou le départ d’un client derrière lui ne doit jamais le
réinitialiser. Sa barre affiche l’avancement du service; la patience takeout
reste invisible.

Le comptoir possède deux zones physiques permanentes et non superposées :
`takeout_prepare` cuisine, `takeout_service` aide le service de 30 %. **Le stock
ne change jamais la fonction d’une zone**, et la présence d’un employé ne bloque
jamais le joueur. Les deux zones sont les deux moitiés contiguës et disjointes
de la **face accessible** du comptoir, chacune avec sa couleur.

Le joueur n'a qu'une vitesse de déplacement. Le profil expose `move_speed` et
`move_stamina_per_second`; ne pas réintroduire de bascule marche/course, de
`toggle_run`, ni deux vitesses cachées. La valeur maître est 400 px/s, milieu
exact des anciennes valeurs 300/500.

**Les tapis d'interaction n'existent plus.** Chaque `InteractiveProp` actif
déclare un **masque de faces utilisables** (`usable_faces`, drapeaux
`Top/Right/Bottom/Left`) et dessine sur chacune une **bordure intérieure** dont
la profondeur vaut `face_band_ratio` (0,15 par défaut) de la dimension
**perpendiculaire** à la face. Cette bordure est la zone d'interaction : elle
est *dans* le meuble, donc elle ne peut jamais recouvrir un personnage — il n'y
a plus de couche de sol à ordonner. Couleur et épaisseur sont configurables dans
le groupe d'inspecteur `Usable Faces`. Masque vide = ancien comportement, un
simple rayon autour de `interaction_offset` : c'est ce qui laisse le donjon
intact. Répartition en place : four face droite, console face basse, évier,
poubelle et étagère les quatre faces.

`interaction_offset` ne décide plus de la portée; il déclare **par quel côté on
aborde le meuble par défaut**, ce que rend `default_stand_position()`.
`get_interaction_position()` suit la face la plus proche du joueur, écartée de
`face_stand_gap` (26 px, l'écart des anciens tapis). Le HUD reprend
`Player.current_target` et place le prompt Action près de cette cible; ne pas
réintroduire une seconde recherche de proximité dans le HUD. Quand l'action est
possible, seul le meuble reçoit un contour statique **collé à son bord** (marge
0 par défaut), configurable dans le groupe d'inspecteur `Target Outline` via les
propriétés `target_highlight_*`; les deux zones takeout encadrent ainsi le
comptoir physique, pas leur hit zone invisible. Le prompt est une seule ligne
transparente et discrète : un verbe court quand `can_interact()` est vrai, puis
il disparaît pendant que la touche Action est maintenue. Une raison précise
reste visible en rouge quand un poste fixe est temporairement indisponible,
même si Action est maintenue. Dans ce second cas, Action appelle tout de même le
poste afin que son refus soit aussi transmis par le toast;
`allows_unavailable_prompt()` réserve ce comportement aux cibles qui savent
expliquer leur blocage.

La portée physique reste toujours le premier filtre d'une interaction. Si deux
cibles admissibles ont un score de proximité séparé d'au plus 32 px, le curseur
départage : il vise la **face dessinée** de l'une d'elles, ou le tapis invisible
juste devant elle (`face_apron_rect`, de la profondeur de `face_stand_gap`).
**Quand le curseur ne désigne aucune des deux, on retombe sur la proximité.**
Rendre « rien » était tenable tant que seules les deux zones takeout étaient
ambiguës; depuis que les meubles s'utilisent par plusieurs faces, deux postes
voisins de la cuisine sont presque toujours à portée égale, et la salle devenait
muette dès que la souris était ailleurs. La manette conserve le choix par
proximité. Toute action continue (four, préparation et service takeout) doit en
plus vérifier `Player.current_target`, sinon deux tâches proches pourraient
progresser pendant le même appui.

**Le clic gauche est aussi lié à `action`.** Quand il tombe sur un `BaseButton`
(ou l'un de ses enfants), il appartient au bouton : `Player._input()` retient
que la demande venait d'un clic (`action_came_from_click`) et
`_action_belongs_to_gui()` l'écarte. Sans ça, cliquer *Dungeon* ouvrait le
donjon **et** actionnait le meuble situé derrière. Space et la manette ne sont
jamais concernés — ils n'ont pas de curseur.

L'étagère expose ses emplacements : `get_station_slot_marks()` publie leur
position, le meuble les dessine, et chaque assiette est **au pixel près** sur sa
case. `_restack_shelf()` recale la pile à chaque dépôt et à chaque changement
d'état, sinon une upgrade de capacité redécoupe les cases et laisse les
assiettes flotter entre deux rangs. Une étagère pleine pose au sol **à côté** du
meuble, hors de son emprise : une assiette posée là ne doit pas être comptée
comme rangée.


### Le restaurant tourne tout le temps

> **Mis à jour le 04/10/2026 — le débit décrit plus bas n'est plus le modèle
> principal.** Depuis le 23/09/2026, quand le héros part, `park_restaurant()`
> déplace la salle **vivante** dans une vue non rendue (`RestaurantOffstage`,
> avec son propre monde physique). Clients, brigade et règles y tournent comme
> si le joueur était là, sans image ni son. Dans l'arbre d'upgrades sans
> brigade, elle est figée. Au retour, une nouvelle scène relit l'état que la
> salle vivante vient d'écrire. Le débit ne sert plus que de secours, quand
> aucune salle vivante n'existe.

Le restaurant ne s'arrête ni en plongée, ni dans un menu, ni dans l'arbre
d'upgrades, ni pendant une scène narrative. **Seul le menu Échap suspend le
temps**, et il suspend l'arbre entier.

Quand la salle est à l'écran elle se simule elle-même (`restaurant_scene_active`
le dit) et le relais hors scène se tait : sans ce drapeau le temps compterait
deux fois. Hors scène, `GameState` accumule des secondes puis
`settle_unattended_operations()` les convertit en travail réel : plats servis,
pièces encaissées, ingrédients déduits, réserve takeout regarnie et vendue.

Le modèle hors scène est un **débit**, pas une simulation pas à pas : sans
personnage à déplacer, seule la cadence compte, bornée par le maillon le plus
lent (cuisine, service, affluence, réserve). **Un poste sans employé ne produit
rien** — la production ne devient jamais magique. La salle exige au moins un
cuisinier *et* un serveur.

Le HUD ne porte ni liste permanente d'ingrédients, ni bloc d'assiette portée,
ni indicateur marche/course. Le stock vit dans l'onglet Ingrédients de la
console. Le plat porté est nommé par un petit texte au-dessus du joueur.
Objectif courant, toast et fermeture du restaurant restent de petites lignes
dans le coin inférieur gauche, jamais des messages centraux.

**La console n'a plus d'onglet Shop.** Son bandeau supérieur garde toujours
visibles l'interrupteur de la salle, celui du takeout s'il est installé, le
bouton Construction si le permis est acquis et le bouton Fermer. Les pages sont
Menu, Recettes, Brigade (après son premier déblocage), Takeout et Ingrédients.
La liste seule défile et le bouton Fermer reste dans le bandeau : aucun contrôle
ne doit dépendre du bas d'une liste pour rester accessible.

**La carte du jour se constate, elle ne se compose plus.** `is_recipe_on_menu()`
vaut exactement `can_cook()` : un plat est offert tant que la réserve permet de
le sortir, et il en disparaît dès qu'elle ne le permet plus. Cocher les recettes
une par une, dans un nombre de places, était un geste répété sans décision
derrière — le joueur cochait tout ce qu'il pouvait. **Ne pas réintroduire
`menu_enabled_recipes`, `menu_slots` ni de bascule par recette.** L'onglet Menu
liste le cuisinable du plus cher au moins cher avec les portions restantes;
l'onglet Recettes montre tout l'acquis avec ses ingrédients et le stock. **Le
takeout garde sa sélection manuelle d'un plat** : c'est un choix de gérant, pas
une conséquence du stock.

`affordable_recipes()` ne filtre que sur le **prix**, jamais sur le stock : la
réserve est jugée juste après, ce qui permet de dire « rupture » au lieu de
« trop cher » quand le buffet est vide. Confondre les deux donnait au joueur une
raison fausse et cachait la vraie.

**Un meuble porte son habillage.** `InteractiveProp` prend l'image de sa fiche
`restaurant_elements` pour son quart de tour et cède son rectangle dessiné, comme
le sol du donjon cède au fond de biome. Les bordures de face restent : elles
disent où on travaille, ce qu'une image ne garantit pas. Une table est dessinée
par la salle et non par un nœud, donc elle a son propre porteur; son tabouret
reste dessiné, aucune image ne le montre.

**Une plongée se termine sur un bilan, pas sur un toast.** `DiveResult` nomme la
raison, liste la récolte et attend un geste. À la mise hors service, l'écran vire
au rouge et la liste montre `gardé / trouvé` : le joueur voit ce que l'assurance
a sauvé et ce qui reste au fond. **L'épuisement n'interrompt jamais le geste en
cours** : `try_spend_stamina()` laisse passer le coup qui vide la jauge, puis un
sursis de `EXHAUSTION_GRACE` laisse au magnétisme le temps de ramener le butin.
Refuser ce coup faisait perdre au joueur son coup *et* sa plongée.
**Les trois issues ouvrent la même fenêtre** — assommé, épuisé, **et parti de
son plein gré**. La sortie volontaire était la seule à se contenter d'un toast,
alors que c'est la seule que le joueur choisit lui-même, donc celle où il veut
voir sa récolte. Son titre lui est propre (`title_left`) : « à bout de souffle »
serait faux pour quelqu'un qui repart en pleine forme.
Les rideaux utilisent le contrôle segmenté `OpenCloseButton` : **OPEN et CLOSE
restent tous deux visibles**, la moitié active est verte ou rouge et le contour
doré conserve un focus clavier/manette explicite.

**Le projectile a quitté l'arme.** La livraison — zone appliquée sur place ou
volée de corps mobiles — appartient désormais au **pattern d'attaque**, et la
munition est une entité à part entière dans la collection `projectiles`. Deux
raisons : une arme qui portait sa propre livraison redisait ce que son pattern
décrivait déjà, et une créature ne pouvait pas tirer du tout puisque la notion
vivait du côté du héros. Le même pattern sert maintenant les deux camps, et
plusieurs patterns peuvent partager une munition sans la recopier. L'arme garde
ce qui lui appartient vraiment : ses bonus, ses coefficients, sa traversée et sa
règle d'occlusion. Une créature dont le pattern livre une volée annonce des
couloirs de tir, plus un secteur : l'avertissement montre ce qui va réellement
arriver.

**Les objets historiques sont partis.** La collection `consumables` datait
d'avant le moment où les plats sont devenus les consommables de combat. Elle
n'était plus atteignable en jeu — la console ne construisait plus ses lignes —
mais elle continuait de traverser le schéma, le gestionnaire, l'espace
d'adressage et la sauvegarde. Une sauvegarde antérieure qui nomme encore un de
ces objets l'ignore simplement au chargement.

**Un plat dit ce qu'il est.** Les `food_categories` sont des étiquettes
partagées — viande, végétal, minéral, fruits de mer, dessert, boisson,
poubelle, épicé, malbouffe, grande cuisine — et chaque plat en porte une ou
plusieurs. La catégorie ne décrit rien d'autre : ni bornes, ni comportement,
ni effet. C'est **le client** qui lui donnera un poids, pas elle qui décide de
quoi que ce soit. Un plat sans catégorie reste servi ; il est simplement neutre
pour tous les goûts, et le gestionnaire le dit dans sa ligne de liste plutôt
que de le laisser passer inaperçu. La catégorie encore portée refuse d'être
supprimée : effacer un goût sans le dire serait pire qu'un refus.

**Le client a un goût, plus un favori.** Le plat préféré désignait un plat ;
il ne disait rien de ce que le client aimait, et il devenait muet dès que ce
plat n'était pas au menu. `category_preferences` pondère des **catégories**, et
le goût d'un plat est le produit des poids des siennes. 1 reste neutre, donc
une carte vide décrit un client sans exigence et n'a rien à écrire. Deux forces
se multiplient au moment du choix : ce goût, et un biais de prix lu
**relativement à l'éventail du jour** — un plat est cher ou bon marché par
comparaison, jamais dans l'absolu, sinon un client riche viserait toujours le
même plat quel que soit le menu.

**Ne rien commander est une réponse.** Un plat dont le goût reste sous 1 doit
passer un second jet : la part qui manque au poids devient la chance que le
client reparte les mains vides. C'est ce qui donne un sens à un poids
fractionnaire — sans ce jet, 0,25 et 0,26 auraient produit le même
comportement dès qu'aucun autre plat n'était disponible.

**La porte se décide sans dé.** `evaluate_queue_join()` promettait que le même
passant décide toujours pareil devant le même restaurant, et cette promesse
vaut mieux qu'un tirage : le goût y entre donc par un seuil, pas par un jet.
C'est la faim qui abaisse l'exigence — un affamé accepte une carte à moitié
plaisante, un difficile rassasié passe son chemin devant un comptoir libre.

**Un gourmand vaut mieux qu'une rotation.** Chaque plat déjà mangé par le même
client ajoute un palier au paiement du suivant. Servir trois plats à une
personne rapporte donc plus que servir trois personnes, ce qui donne enfin une
raison de protéger la patience d'un client au lieu de le laisser partir.

**La réputation vaut ce que vaut le client.** Trois montants absolus
disaient la même chose pour tout le monde : servir rapportait 1, un départ
coûtait 2. Le profil porte maintenant un `popularity_weight`, et neuf
situations le pondèrent — plat servi, servi vite, vendu au comptoir, parti sans
être servi, renvoyé par la fermeture, commande changée, file abandonnée, file
dispersée, comptoir abandonné. Les coefficients sont **tous positifs** : le
signe appartient à la situation, pas au chiffre, sinon un coefficient négatif
écrit par erreur transformerait une perte en gain sans que rien ne le dise.

**Un seul point d'entrée.** `adjust_popularity_for()` est le seul endroit où la
note se calcule, et c'est exactement pour ça que les deux multiplicateurs
globaux y mordent : un upgrade qui vise « toutes les hausses » n'a rien à
énumérer. Aucun appelant ne compose son propre montant.

**Une file dispersée coûte quelque chose.** L'ancienne note disait que fermer
n'était pas un abandon, donc ne coûtait rien. C'était vrai du geste et faux du
résultat : quelqu'un s'était déplacé pour rien. La ligne existe désormais, mais
c'est **la plus légère de la table** — un vingtième de ce que coûte un client
qui renonce de lui-même.

**Les chiffres ont changé d'échelle.** Un plat servi vaut `5 × 1` au lieu de
`1` : l'affichage `RÉPUTATION` du HUD monte donc cinq fois plus vite. Les sept
modificateurs déjà écrits ont été repris sans perdre leur force relative — un
pourcentage a migré tel quel vers le multiplicateur global, un ajout fixe a été
remis à l'échelle du nouveau coefficient. `-1` sur une perte de base 2, soit la
moitié, est devenu `-0,25` sur un coefficient de base 0,5 : toujours la moitié.

**Le coup ratait pour deux raisons, pas une.** « Je frappe et ça ne connecte
pas » venait d'abord de la **visée** : le curseur était résolu sur un plan à
hauteur de taille du héros, pas sur la cible. La caméra étant inclinée à 52°,
viser le centre visible d'une créature plus basse faisait atterrir la visée
jusqu'à 29 px derrière son sol — plus que la demi-largeur d'un cône de 15° à
pleine portée, et uniquement sur l'axe vertical de l'écran. Pointer une
créature rend maintenant son point **au sol** : l'erreur n'est pas réduite,
elle est supprimée, parce qu'on ne projette plus rien.

**La hurtbox est une capsule, et elle est écrite.** Le corps touché était un
cercle de 18 px × la taille, sans rapport avec le modèle affiché — alors que la
collision physique suivait déjà la forme déclarée. Trois notions de taille
indépendantes. La hurtbox devient un segment orienté sur le regard plus un
rayon : vue de dessus, c'est ce qu'un assemblage sphère + cylindre donne une
fois aplati, en un seul test. Longueur nulle ramène le segment à son centre,
donc au cercle d'avant, au pixel près — c'est ce qui permet de poser la capsule
sans rejouer l'équilibrage.

**Mesurer est un geste d'édition, jamais d'exécution.** Le format glTF déclare
l'encombrement de chaque maillage : l'outil le lit et **propose** une capsule,
l'auteur accepte ou corrige, et c'est son chiffre qui part dans le maître. Le
jeu n'ouvre jamais un GLB pour décider d'un coup. Sans cette séparation, la
moindre retouche d'art deviendrait une session d'équilibrage — la règle était
déjà écrite deux fois dans le code, elle vaut aussi pour la hurtbox.

**Un client peut ne pas avoir le droit d'entrer.** Trois portes le disent, et
chacune répond à une question différente : `conditions` demande si le monde est
prêt pour lui, `max_on_screen` combien peuvent être là à la fois,
`respawn_cooldown` depuis combien de temps il est parti. Aucune n'a eu besoin
d'un vocabulaire neuf : les conditions sont celles qui déverrouillent déjà un
plat, et la réputation étant un fact, « réservé aux restaurants connus »
s'écrit sans toucher au schéma.

**Quand personne n'est autorisé, personne ne vient.** Le tirage rend une chaîne
vide et la table reste libre. Le repli tentant — imposer le premier profil de
la liste — aurait fait apparaître en boucle le client précisément réservé à une
situation rare.

**Partir déçu se dit autrement que partir.** Un client qui repart sans avoir
mangé avait la réplique du départ ordinaire. Il a maintenant la sienne, et
c'est le **restaurant** qui la déclenche — comme pour « plat servi », lui seul
sait la différence entre s'en aller repu et renoncer. Sans message écrit pour
ce cas, `say()` rend faux et le départ ordinaire reprend la main.

**La déception n'a pas qu'une cause, donc elle n'a qu'une porte.** Renoncer
dans la file, attendre un plat qui ne viendra pas, se faire fermer la salle ou
le comptoir au nez : de l'intérieur c'est la même chose, on repart sans avoir
mangé. La réplique part donc de la **table des situations**, seul endroit qui
sache laquelle on vit, et chaque situation de départ y porte son propre
`disappointed: true`. Semer les appels aux points de départ aurait garanti
d'en oublier — c'est exactement ce qui était arrivé aux deux fermetures.

Le drapeau est séparé de `gain`, et pas déduit de lui : changer de commande
coûte de la réputation sans faire partir personne. Ce client-là encaisse et
reste ; il n'a pas d'adieu à faire.

**Apprendre une recette n'est pas un evenement, c'est un changement de
reponse.** `recipe_is_unlocked()` derive tout de ses conditions: il n'existe
nulle part d'instant ou le heros apprend. L'annonce a donc besoin d'une memoire
de session -- ce qui etait acquis a la derniere ecriture d'etat -- et compare a
chaque `_changed()`. Cette memoire ne part pas dans la sauvegarde: la recharger
rejouerait d'un coup les annonces de toute une carriere.

**Elle suit dans les deux sens.** Une recette qu'une remise a zero de dimension
reverrouille se reapprend, et se reannonce. Ne faire qu'ajouter aurait rendu la
deuxieme dimension silencieuse.

**L'annonce se pose sur la bulle du heros**, pas dans un coin de l'ecran: le
pont 3D reprend deja cette bulle sur son avatar, donc elle le suit sans une
ligne d'affichage de plus, et elle marche aussi bien en donjon qu'en salle. Les
points de reflexion passent devant -- la nouvelle arrive apres le geste de
comprendre. `say()` refuse pendant ce temps: une replique ordinaire ecraserait
l'annonce a moitie lue.

**Un controle ne nomme pas un plat.** Le controle du consommable de combat
cuisinait `squirrel_skewer` par son nom; le jour ou l'auteur lui a donne une
condition d'acquisition, deux verifications sans rapport sont tombees. Il
constate desormais la premiere recette acquise: ce qu'il eprouve, c'est qu'un
plat cuisine se range dans un slot, pas lequel.

**Le voile de l'annonce est un objet 3D, pas un rectangle d'interface.** Une
couche d'interface se dessine toujours après le monde 3D: elle serait passée
devant la bulle, qu'on cherche justement à faire ressortir. Le voile est donc un
quad sans test de profondeur, et l'ordre se règle à la priorité de rendu — après
le décor et les badges ordinaires, avant celui qui parle, dont le pont relève la
priorité le temps de l'annonce.

**La vedette est une rangée, pas un morceau de phrase.** `Label3D` ne connaît ni
balise ni gras: colorer le nom du plat dans la phrase était impossible. Il part
donc séparément depuis le héros et occupe sa propre rangée, et le gras se fait
au contour — de la même couleur que les lettres, ce qui les épaissit sans les
cerner.

**Une bulle se peint à taille normale, puis s'agrandit.** La silhouette est
peinte pixel par pixel en GDScript. En la repeignant à 2,25 fois sa taille, on
posait cinq fois plus de pixels d'un coup — assez pour faire tressauter la frame
au moment précis où l'on veut que le joueur regarde, et assez pour faire tomber
trois contrôles de trajectoire sans rapport. Le panneau porte désormais
l'agrandissement, et toutes les emphases retombent sur les mêmes entrées de
cache.

**La respiration est commandable.** Elle arrête le jeu pour de bon, physique
comprise. Le smoke test mesure des trajectoires sur des images réelles: un quart
de seconde d'arrêt au milieu d'un tir lui montre un projectile immobile. Il la
coupe donc, et c'est le contrôle du pont qui l'éprouve — là où elle a un sens.

**Une premiere fois ne se repete pas.** Le ceremonial d'une recette apprise se
reduit d'un cran a chaque decouverte: la premiere arrete le jeu et attend un
geste, la deuxieme accorde cinq secondes, et passe la cinquieme il ne reste
qu'une bulle de passage. Le compte vit dans les valeurs meta de la sauvegarde,
pas dans la partie en cours: recommencer une dimension ne redonne pas ses
premieres fois.

**L'annonce a trois temps, pas deux.** Entre les points et la nouvelle il a
fallu un *rideau*: le monde s'efface pendant que le heros pense encore. Sans ce
temps mort, la nouvelle paraissait avant l'obscurcissement, et l'effet tombait
a plat. Le heros ne connait que ces trois temps; c'est la mise en scene qui dit
quand passer de l'un a l'autre, et le heros garde ses propres delais pour le
cas ou personne ne mene.

**Un noeud en pause ne recoit plus rien -- ni image, ni touche.** Deux
consequences, decouvertes l'une apres l'autre. Le rideau est un noeud a part,
en mode ALWAYS, parce que c'est justement pendant l'arret que le joueur doit
pouvoir dire « j'ai lu ». Et le pont, lui, s'arrete avec le reste: sans un
rappel explicite du badge apres la revelation, le joueur voyait les points de
reflexion pendant toute sa lecture, la nouvelle n'arrivant qu'au redemarrage.

**Un `class_name` neuf n'existe pas pour un lancement sans editeur.** Le
registre des classes est ecrit par l'editeur, qu'on ne lance pas quand celui de
l'auteur est ouvert. Le pont parle donc au rideau par des membres que `Node`
connait deja -- `connect`, `set` -- et fait la synthese sur son propre signal.

**Il fallait une autorite sur les sequences.** Les points de reflexion
commencaient, la ronde se terminait, et le bilan de plongee se levait par
dessus: deux sequences arretaient et relachaient le jeu chacune de son cote,
et le voile restait suspendu. Le plateau n'appartient desormais qu'a une
sequence a la fois, arbitre par un regisseur qui ne sait rien de ce qui s'y
joue -- aucune decision de jeu ne passe par lui.

**Deux droits, pas un.** Toutes les sequences ne se valent pas: le bilan d'une
ronde n'est pas negociable et **prend** le plateau, tandis qu'une annonce de
recette le **demande** et attend son tour. Un seul verrou, poli pour tout le
monde, aurait soit fait patienter un bilan que le joueur attend, soit laisse
l'annonce ecraser ce bilan.

**Les annonces en attente vivent dans l'etat du jeu, pas dans le heros.** Une
ronde qui se termine emporte le heros avec sa scene: la nouvelle serait partie
avec lui, alors que la recette, elle, reste apprise. Celui qui arrive dans la
scene suivante reprend la file ou elle en etait. Le rang se prend a
l'apprentissage et non a l'affichage, pour qu'une premiere recette interrompue
reste une premiere recette.

**Qui se retire depend de qui est en scene.** Pendant les points, le heros est
seul et se retire lui-meme. Une fois le rideau leve, c'est la mise en scene qui
demonte -- elle a un arret et un voile a defaire avant de rendre l'annonce a la
file.

**Ce qu'un debutant ne doit pas manquer passe avant tout le reste.** Les cinq
premieres recettes d'une partie prennent l'ecran meme devant le bilan d'une
ronde: un joueur qui rate l'ouverture de son livre ne comprend pas ce qui vient
de se passer, alors que son bilan, lui, sera encore la dans trois secondes. Le
regisseur classe donc les sequences par rang au lieu de les servir dans
l'ordre d'arrivee.

**Il a fallu reserver la place, pas seulement la prendre.** Entre l'instant ou
une recette s'apprend et celui ou le heros commence a y penser il s'ecoule une
image -- et `end_run()` fait exactement les deux dans cet ordre. Le bilan se
levait dans ce battement et gagnait par simple avance. Une reservation tient
la place le temps que la sequence monte en scene.

**Vider la file, c'est aussi rendre la place.** Une reservation posee pour une
annonce qui n'existe plus bloquerait tout ce qui veut l'ecran, et pour
toujours. C'est pourquoi la file ne se vide que par une seule porte.

**Une salle declare des vagues, pas deux groupes.** Elle disait « une
ouverture, et un budget de renfort »: le budget se decoupait en tranches
egales, toutes tirees du meme sac, avec un declencheur unique pour la salle
entiere. Deux vagues de compositions differentes etaient impossibles a ecrire.
Elle declare desormais une liste ordonnee, chacune avec son effectif, son
propre sac et sa condition d'arrivee.

**Deux conditions, et l'auteur dit s'il en faut une ou les deux.** Le delai se
compte depuis la vague precedente; le seuil se lit par rapport a l'effectif
qu'il y avait juste apres elle -- « il en reste 35 % » veut dire 35 % de
ceux-la. Zero est le plateau vide, et `-1` dit « cette vague ne regarde pas
l'effectif ». Cette troisieme valeur a du exister: sans elle, une vague
purement minutee etait indistinguable d'une vague qui attend le plateau vide.

**Une condition non declaree ne declenche jamais a elle seule.** Sinon « aucun
seuil » se serait lu comme « seuil atteint », et toute vague minutee serait
arrivee des l'ouverture.

**La chance d'apparition se tire a l'ouverture de la salle, pas au moment du
declenchement.** Une vague qui ne vient pas est retiree de la liste, et les
delais des suivantes s'enchainent depuis la derniere vague reellement tombee:
tirer plus tard aurait laisse un trou dans la minuterie.

**Trois statistiques de repli sont devenues trois leviers.** Elles servaient a
peupler une salle qui ne declarait rien -- ce qui n'a plus lieu d'etre. Mais
deux recompenses de plongee et trois ameliorations s'en servaient comme
leviers: « les salles contiennent 3 creatures de moins », « la releve arrive
plus tot ». Les supprimer aurait tue cinq objets de jeu sans que rien ne le
dise. Elles sont renommees pour ce qu'elles font desormais: un bonus sur
l'ouverture, un bonus sur les vagues suivantes, une avance sur les seuils
declares. L'avance ne cree pas de seuil la ou l'auteur n'en a pas voulu.

**Un sac de vague vide herite, il ne devine pas.** « Aucune creature declaree »
voulait dire « tout le bestiaire a ses poids d'origine »: une vague ajoutee a
une salle du premier niveau y faisait apparaitre un boss. Une vague muette
reprend desormais les creatures de la derniere vague qui en declarait, et si
aucune ne l'a fait avant elle, elle ne fait venir personne. On ne devine pas ce
qu'une salle doit contenir, et la publication refuse une vague qui devrait
peupler sans rien a heriter.

**Une vague de releve se tient a distance du joueur.** Apparaitre dans son dos
a bout portant ne se joue pas, ca se subit. La regle est une distance, pas un
quadrant: un quadrant n'a pas de sens dans une salle qui defile, ou le joueur
occupe deja un bout de couloir, et n'en a pas davantage dans une petite salle ou
les quatre quarts sont a portee de main. Les creneaux de la grille sont essayes
a partir du sien et le premier assez loin gagne; si aucun ne l'est, on prend le
plus eloigne vu -- une vague qui n'apparait pas serait pire. L'ouverture, elle,
garde la grille telle quelle: le joueur entre par la porte et la salle l'attend.

**Une sonde ne mesure pas apres une image de physique.** Deux controles de
combat empruntaient les creatures de la salle et les posaient a distances fixes;
un troisieme mesurait des positions d'apparition apres avoir laisse tourner une
image. Dans les trois cas le verdict dependait de l'espece tiree ou de qui avait
bouscule qui. Ils amenent desormais leurs propres corps, les ecartent selon leur
emprise reelle, et mesurent a l'instant qui les interesse.

**Les objets de salle existaient dans la donnee et nulle part ailleurs.** Seize
objets ecrits, six genres, cent quatre-vingt-un placements, des crochets et des
compteurs declares -- et le donjon n'en faisait rien. Le noeud `RoomElement` est
ce qui manquait entre les deux; il ne decide de rien, tout vient de la fiche.

**Ce qui bloque et ce qui ne bloque pas vivent sur deux couches.** Un tas de
dechets se traverse mais se casse: il lui faut etre trouvable par une attaque
sans arreter le joueur. La couche des corps attaquables porte les objets
solides, une couche que personne ne masque porte les autres, et la requete
d'attaque couvre les deux.

**Un objet vide ne disparait pas.** Un coffre ouvert et une caisse eventree
restent au sol, plus sombres. Les effacer effacerait la trace de ce que le
joueur a fait, et une caisse qui bloquait le passage continue de le bloquer.

**Personnages et boutiques passent par leur crochet.** Plutot qu'inventer ici un
dialogue ou une boutique, ces deux genres lancent `on_triggered`: une scene
d'histoire ou tout autre systeme s'y branche depuis la donnee, le jour ou il
existera. L'objet, lui, est deja sur le plancher.

**Un rouge qui passe est un controle qui ne tient rien.** Le premier controle de
pose appelait le poseur directement: debrancher la pose a l'ouverture d'une
salle ne le faisait pas tomber. Il passe desormais par l'ouverture elle-meme,
qui est le branchement qu'on voulait eprouver.

---

## 29/09/2026 — une seule échelle de caméra (ligne 47 du Sheet)

**La vue retenue au panneau F3 devient celle du jeu** : 35° d'inclinaison,
perspective FOV 30°, pour le restaurant comme pour le donjon.

**Une seule échelle partout.** Chaque salle calculait son propre cadrage pour
tenir entière : Bob paraissait un tiers plus petit dans une arène fixe qu'au
restaurant, et à 35° une salle horizontale l'aurait grossi d'un tiers. La
hauteur d'image au point visé est désormais `Camera3DRig.FRAME_HEIGHT`, la
même dans toutes les scènes : 725 d'abord (l'ancien cadrage du restaurant,
référence choisie par l'auteur), puis 760, un léger recul demandé à l'essai.

**La caméra suit sur les deux axes**, bornée au monde plus `EDGE_MARGIN` (60),
et se centre sur un axe où le monde tient. L'axe déclaré de la salle ne pilote
plus la caméra. Conséquence assumée : une arène de 1780 de large ne tient plus
à l'écran (environ 1290 visibles), y compris une salle de fin de dimension. La
règle du schéma 34 « une salle de boss ne défile pas » reste vraie pour la
**salle écrite**, plus pour la caméra. Si un boss hors champ rend le combat
illisible, la parade envisagée est de réduire les arènes, pas de revenir à un
cadrage par salle.

**En perspective, le sol visible est un trapèze étroit en bas.** Les bornes
orthographiques (`size × aspect`) laissaient hors champ les coins bas, que le
joueur venait justement voir. Les bornes passent par `footprint()`, qui projette
les quatre coins de l'image au sol, et la largeur se borne sur la plus basse
rangée du monde à l'image. Bornée d'abord sur la rangée du joueur, la caméra
glissait vers l'extérieur quand il descendait le long d'un mur; corrigé le jour
même à la remarque de l'auteur.

**Le cadre du restaurant suit la salle, plus le terrain.** Avec une échelle
unique et un suivi, un cadre large comme le terrain faisait défiler un
restaurant étroit (dimension 1) sur du décor vide. Il va désormais du mur ouest
au bord du trottoir est : en dimension 1, la vue ne bouge presque plus.

**Le héros saccadait dès que l'écran défilait, pas le décor.** Il n'avance
qu'au pas de la physique, la caméra à chaque image; à 144 images par seconde,
87 images sur 149 ne recevaient aucun pas. `tick_smoother.gd` montre chaque
`CharacterBody2D` entre ses deux derniers pas, et la caméra vise ce même point.
Mesuré en jeu, la saccade du héros à l'écran passe de 6,8 px à 0,04 px, celle
du décor étant de 0,03. `bridge_3d_check` place le héros dans chaque coin, en 16:9, et exige
ses pieds, sa tête et les deux murs du coin à l'image.

---

## 01/10/2026 — la taille multiplie le modèle (ligne 63 du Sheet)

**Règle de l'auteur : `min_size` et `max_size` multiplient le modèle tel qu'il
est réglé**, et sa collision du même facteur : 1 le montre tel quel, 0,5 à
moitié, 5 cinq fois plus gros. Une valeur est tirée entre les deux à chaque
apparition; min = max donne toujours la même taille.

**Le pont ignorait la taille.** Le modèle, ou la forme, gardait la hauteur de
sa fiche et seule la collision grossissait : une taille de 5 ne se voyait pas.
Le pont multiplie désormais la hauteur visée par `size_multiplier` à la
construction de l'avatar; barre de vie et bulles suivent la méta `tall`.

**Conversion unique des données**, pour que rien ne change à l'écran : chaque
fiche (base et niveaux) divise ses tailles par leur milieu et multiplie d'autant
`body_radius`, la hurtbox et le décalage du corps. Les tailles se centrent sur
1, l'écart entre individus garde son rapport, et la collision est conservée à
0,6 % près (arrondi au centième). Exception choisie par l'auteur : la hurtbox de
Gab dans le brouillon reste 118 sous la nouvelle règle, 590 dépassant le
plafond.

**Le plafond de 256 des hurtboxes** n'a pas de raison technique : c'est un
garde-fou de saisie. Sous l'ancienne règle il autorisait jusqu'à 1280 px
effectifs; sous la nouvelle, environ 256.

---

## 02/10/2026 — le butin élargi (lignes 49 et 58 du Sheet)

**Une ligne de butin a un type et un déclencheur.** Type : matériau, rondelles,
arme, équipement, trouvaille. Déclencheur : à la mort, à chaque coup reçu, tous
les X coups, sous un seuil de vie (une fois), à l'entrée d'une phase. Un plafond
par combat retient les déclencheurs de coup et de phase. Une ligne sans type
reste un matériau tiré à la mort. Les éléments de salle partagent les types,
pas les déclencheurs : ils n'ont qu'une fin. Schéma 93.

**Décisions de l'auteur** (questionnaire du 01/10/2026) :

- une arme ou un équipement ramassé est **acquis tout de suite**, pour de bon ;
  un KO ne le reprend pas. Il ne tombe que si on ne l'a pas, ni déjà au sol ;
- une trouvaille qui ne servirait à rien (vie pleine, souffle plein, sans boost)
  **reste au sol**, ni attirée ni ramassée, et ne retient pas la fin de plongée ;
- les éclats ne sont pas codés en dur : ils forment la collection
  **Trouvailles** (`pickups`), pour que l'auteur les nomme selon le lore, une
  fiche par biome s'il veut. Effets d'un plat mangé : soin et endurance, chacun
  **en points ou en % du maximum au choix**, plus des boosts (modificateurs)
  pour une durée. Deux fiches génériques de départ : Éclat de vie, Éclat
  d'endurance.

**Choix techniques.** Un « coup reçu » est un coup qui porte : un bouclier ou
une immunité ne compte pas. Le multiplicateur de butin vaut pour tous les
déclencheurs, jamais pour une arme ou un équipement. Les boosts de trouvaille
ont une source `run:` : sans durée, ils tombent à la fin de la plongée. Les
rondelles vont au sac de plongée, comme celles des caisses. Pas encore de
visuel d'icône pour une trouvaille : couleur, forme 2D, volume réglable dans
l'outil Formes 3D. Une arme au sol n'a qu'un volume de repli tant que la
ligne 65 (objet physique des armes) n'est pas faite.

---

## 04/10/2026 — personne ne s'assoit sans être entré

**Bug signalé par l'auteur.** Salle ouverte, carte trop chère pour tous les
passants, brigade au travail : au retour de l'arbre d'upgrades, un client
était attablé. Il constatait qu'il ne pouvait rien payer, repartait, et la
réputation baissait.

**Cause.** `restaurant.tscn` contenait un convive d'amorce. À chaque
construction de la salle (retour de plongée ou d'upgrades, chargement de
partie), une salle vide le recevait d'office à la première table, sans la
décision du passant (`evaluate_queue_join`). La salle vivante, elle, n'avait
fait entrer personne.

**Règle de l'auteur.** Aucun client n'apparaît attablé de lui-même : seul un
passant qui a décidé d'entrer s'assoit. L'amorce est retirée de la scène, si
bien qu'en partie neuve aussi le premier client vient du trottoir.
`restaurant_live_check` couvre la salle neuve et l'aller-retour aux upgrades
avec la brigade au travail.

**Reste ouvert.** Le retour reconstruit encore la salle à partir de l'état
écrit, au lieu de remettre la salle vivante à l'écran. Les passants du
trottoir, les délais de retour des profils et l'historique d'attente y
repartent de zéro.

---

## 14/10/2026 — les bouches de métro en trois états (ligne 80 du Sheet)

**Demande de l'auteur.** Les planches ne tombent plus d'elles-mêmes quand la
passe est en poche. Une bouche de métro passe par trois états, sans surlignage
ni invite dans les deux premiers :

1. **Barricadée** : les coups du héros brisent les planches (boucane, son de
   bois cassé). La solidité est la vie de l'élément (`health`, 3 par défaut,
   soit trois coups de fourchette).
2. **Barrée** : planches tombées, passe absente. Le geste « interagir » tenté
   devant la porte affiche en bulle le texte `ui.passage.locked.<objet>`.
3. **Ouverte** : passe en poche, cible et invite habituelles. La première
   descente de la dimension joue la scène `passage_unlocked_<objet>` (boîte de
   dialogue modifiable dans l'outil), puis les marches. Ensuite, seulement le
   son `dungeon_barrier_open` avec la transition.

**Choix de l'auteur.** Planches sur les sept bouches D1-D4, Guimard comprise,
**à recasser à chaque dimension**. Refus en bulle, carte reconnue en boîte. Son
de barrière synthétisé maison (`metro_barrier_open.wav`). Les édicules reculent
de 40 unités dans le décor nord (`offset_y` −474 → −514) ; la Guimard ne bouge
pas.

**Mécanique.** La portée « dimension » s'appuie sur les compteurs d'entités,
que `complete_dimension()` remet à zéro : `room_elements.<id>.total_destroyed`
garde les planches brisées, `key_items.<objet>.barriers_opened` la première
ouverture. Les deux clés sont dérivées de l'objet de quête qu'exige la porte :
un autre passage à objet reçoit le même traitement en ajoutant son texte et sa
scène. `place_transitions_check` couvre les trois états, les gestes du joueur
et la seconde descente sans scène.

---
