# 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
# 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.

**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 13**, **sauvegarde 6**.
Contenu : 5 ingrédients, 5 plats, 5 monstres + 5 gardiens, 3 profils clients,
15 nœuds d'upgrade, 7 biomes, 5 dimensions, 8 récompenses, 6 éléments de salle,
99 cases + 2 salles alternatives, 19 statistiques, 8 facts, 222 clés de texte.

**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 produit scalaire de direction : 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.
- 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.**

---

---

## 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.
- **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`).
- **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).
- **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`.
- **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 » |

### 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.

Plan détaillé : `docs/architecture-donjon.html`.

Document d'architecture détaillé : `docs/architecture-variables.html`.
