# BIG EGG — directives de projet

Fichier chargé à **chaque** session : il doit rester court. Il sert de point de
reprise en cas de perte de contexte ou de reprise par un autre agent.

**Ce qui n'est pas ici** : le récit des fonctionnalités déjà construites,
l'historique des corrections, la justification détaillée des choix. Tout cela est
conservé mot pour mot dans `docs/journal-conception.md` (l'ancien fichier, 1400
lignes) et dans les quatre documents d'architecture. Les consulter avant de
rouvrir une décision.

**Règle de maintenance** : n'ajouter ici qu'une règle qui **change une décision
future**. Un bug corrigé n'y a sa place que si sa cause peut se reproduire. Tout
le reste va dans le journal.

Langue de travail : **français**. 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 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 existants.

### Génération d'art — serveur MCP PixelLab

Portée locale à ce projet (`~/.claude.json`). `create_character` puis
`animate_character`. Quatre choses à savoir, apprises au premier essai :

- **PixelLab rend des images séparées, jamais une feuille** (`frame_000.png`,
  `frame_001.png`…). Les composer en bande horizontale avant de les poser dans
  `assets/`, sinon la découpe `colonnes × rangées` n'a rien à découper.
- **Générer la vue sud seule** (`directions: ["south"]`) : `VisualHolder` ignore
  les directions (§6), donc les 4 ou 8 rotations sont du gâchis — 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** : déposer le fichier
  puis cliquer sur la fenêtre Godot suffit. Ne pas lancer un `--headless --import`
  concurrent.

---

## 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
for t in address localization render dive_editor room_editor delete_cascade \
         map_shape resync employee_editor visual_editor asset_layout enemy_bounds \
         upgrade_theme speech_bubble story_scene drop_editor drop_magnet \
         management_layout restaurant_element; do
  node "big-egg/tools/game-data-manager/tests/${t}_test.js" || echo "ECHEC $t"
done
# Après avoir ajouté une clé à Loc.UI_FALLBACKS:
node big-egg/tools/game-data-manager/tests/seed_ui_texts.js
php big-egg/tools/game-data-manager/tests/speech_bubble_validation_test.php
php big-egg/tools/game-data-manager/tests/story_scene_validation_test.php
php big-egg/tools/game-data-manager/tests/drop_validation_test.php
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. Écrire dans un fichier puis filtrer.
- **Un nouveau script avec `class_name` doit passer par l'éditeur avant tout
  lancement headless**, sinon `Identifier "X" not declared`.
- Le smoke test tourne en ~6 s. Au-delà, c'est un blocage réel.
- Un échec isolé sur le dash ou la détection ennemie (sensibles au timing
  physique) se revérifie par une seconde exécution avant d'être traité comme une
  régression.
- **Contrôler le code de sortie d'une suite JS, pas son texte** : une exception
  n'imprime aucun « ECHEC » et un `grep` la ferait passer pour verte.

---

## 3. Architecture — invariants

### 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`, `employees`,
`visuals`, `rooms`, `dimensions`, `biomes`, `room_elements`, `story_scenes`).

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

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

| 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 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
```

`global` est **séparé de `scaling`** 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` est un vrai multiplicatif isolé, rare.

- **La liste de modificateurs est la vérité, l'accumulateur est un cache.** Ne
  jamais muter une valeur finale : elle ne saurait pas se dé-muter et ne
  survivrait pas à une sauvegarde. **Un champ qui met une statistique en cache
  doit s'abonner à `state_changed`.**
- Sources nommées `<type>:<id>` (`upgrade:`, `equipment:`, `consumable:`,
  `debuff:`, `set:`, `run:`, `employee:`, `test:`). Retrait par
  `remove_modifiers_by_source()`.
- **Ordre 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 modificateurs de
statistiques, actions périodiques et 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`. `stack` = piles
  indépendantes, `stack_refresh` = expiration commune. Défaut de clé :
  l'identifiant de l'effet (seul cas où `max_stacks` plafonne vraiment).
- **Les instances ne sont pas adressables** : seuls les agrégats remontent.
- Les effets du joueur vivent dans `GameState.player_effects`, réhydratés après
  chaque changement de scène.

### Effets déclaratifs et crochets

Crochets par collection : `on_kill`, `on_spawn`, `on_damage_player`, `on_hurt`,
`on_cooked`, `on_served`, `on_collected`, `on_purchase`, `on_hired`,
`on_left_unserved`, `on_blocked`, `on_taken`.

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

`Registry.run_hook(collection, id, hook, origin)` porte à la fois ce que
l'événement change et l'effet visuel qu'on en voit. Un seul point d'appel.

### Réactivité et cohérence

- Recalcul global via `state_changed`. **Pas d'invalidation fine par chemin** : à
  cette échelle elle n'apporte rien et ajoute des abonnements fantômes.
  `Registry.write()` reste le point de passage unique si ça change un jour.
- Contrôles à 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.

### Apparence de l’arbre d’upgrades

`upgrade_themes` est l’unique autorité des formes, couleurs et segments par état.
Un nœud ne porte que `theme`, son éventuel `image_path` et
`show_parent_link`. **Ne pas réintroduire `appearance` ou `state_styles` par
nœud** : une variation réutilisable est un thème.

### 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.

### Quatre états d'ouverture

La salle et le comptoir ont **chacun leur rideau**, tenus par `GameState`
(`dining_close_reason`, `takeout_close_reason`) : tout fermé, salle seule,
comptoir seul, tout ouvert. `restaurant.opening_state()` les nomme.

**Un rideau ne se relève jamais seul.** Le contexte peut le baisser — plus
d'ingrédients, aucune recette à la carte, aucune recette takeout choisie, plus
rien à vendre —, seul le gérant rouvre, à la console.
`evaluate_restaurant_closure()` ne fait donc plus que *fermer*. Avant, le
magasin se rouvrait dès qu'un œuf rentrait et le joueur ne voyait jamais
pourquoi il avait fermé.

La raison est une **clé** (`CLOSE_REASON_*`), pas une phrase : elle traverse la
sauvegarde et se traduit à l'affichage. Le bandeau du HUD dit la raison et par
où rouvrir. Le comptoir tient tant qu'il lui reste des plats à vendre : c'est la
réserve cuisinée qui le garde ouvert, pas le stock d'ingrédients.

**Rideau baissé, la rue continue de vivre** : les passants traversent toujours
le trottoir, ils renoncent simplement à entrer.

### 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.

**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.

**`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.

### Le restaurant tourne tout le temps

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 le cinquième 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.

---

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

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

- Interface : clé dans `Loc.UI_FALLBACKS` (`scripts/core/loc.gd`) puis
  `Loc.ui("ui.domaine.nom")`.
- Entité : `DataCatalog.ingredient_name()`, `recipe_name()`, `enemy_name()`,
  `customer_name()`, `upgrade_name()`, `variable_name()`.
- Puis lancer le semis, ou ajouter la clé via l'onglet **Textes**.

**La clé de traduction est une adresse** (`ingredients.egg.display_name`,
`ui.restaurant.plates`) : le catalogue se dérive des données, aucun registre
manuel. Collections traduisibles déclarées dans `Loc.TEXT_FIELDS`, miroir de
`localization_text_fields()` (PHP) et `LOCALIZATION_TEXT_FIELDS` (JS).

Résolution : `langue courante → repli (en) → défaut d'auteur → texte du code → la
clé`. Le **défaut d'auteur** est le texte saisi dans l'outil ; il n'est pas
destiné à être livré (la version finale active `Loc.strict_mode`).

**Supprimer une entité emporte ses textes** (`dropLocalizationOf`), sinon
l'onglet Textes garde des lignes qui ne nomment plus rien.

Langues : `fr`, `en`. **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 : **schéma 28**, **sauvegarde 14**. 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, 43 statistiques, 27 nœuds d'upgrade, 6 thèmes d’upgrade,
5 employés, 8 facts, **8 meubles de restaurant**, **9 fonctions interactives**.

**Schéma 28 — meubles du restaurant.** Deux collections, pas une :

- `station_functions` : ce qu'un meuble sait faire. `kind` est le comportement,
  écrit en GDScript, et **ne se clone pas** ; `coefficients` (`speed`,
  `capacity`, `quality`, `reach`) sont de la donnée. Un « four rapide » est un
  `kind: stove` à `speed: 1.5`. C'est ce partage qui évite de recopier le
  comportement d'un four à chaque nouveau modèle.
- `restaurant_elements` : l'objet qu'on pose. Taille, `visuals` **par quart de
  tour** (`front`/`right`/`back`/`left`, avec la chaîne de repli habituelle
  jusqu'à la forme procédurale), `usable_faces`, `anchor`, `function` (vide =
  purement décoratif), `cost`, `modifiers` passifs, `conditions`.

**Le déblocage 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.

Les deux collections partagent l'onglet **Meubles** de l'outil, avec un
sous-onglet pour les fonctions — même mécanisme que les thèmes d'upgrade
atteints depuis l'arbre, pas un second inventé pour l'occasion.

**La limite par meuble est variabilisée** : `max_count_variable` nomme une
statistique qui remplace `max_count`. `stove_count` limite ainsi le four de
base, comme `table_count` limite les tabourets. Vide veut dire « la limite
écrite suffit ».

**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.

**Butin** : `drops` est une liste ordonnée de `{ingredient_id, chance}`. Chaque
ligne fait un jet indépendant de 0 à 100 %, y compris lorsque plusieurs lignes
nomment le même ingrédient. Un jet réussi produit exactement une unité; `100`
est garanti. `stat.drop_count_multiplier` multiplie le nombre de jets de chaque
ligne : sa partie entière est garantie et sa fraction est la chance d'un jet
supplémentaire (`1,25` = 1 jet + 25 % d'un second). Les quantités de recettes
conservent leur sémantique distincte.

**Magnétisme du butin** : `stat.drop_magnet_range` est le rayon en pixels (`0`
désactive) et `stat.drop_magnet_speed` la vitesse en pixels/seconde. Après
l'animation d'apparition, chaque `IngredientPickup` libre situé dans ce rayon
avance vers le héros à cette vitesse; la collecte reste déclenchée par le
contact normal. Sa collision suit le sol tandis que le visuel répète une
parabole configurée par `Magnet Bounce / magnet_hop_height` et
`magnet_hop_distance` dans la scène du ramassable.

**Page blanche assumée (18/08/2026)** : salles et gardiens supprimés, réécrits
case par case. Godot retombe alors sur `_generated_rooms` (4 cases par dimension,
la dernière clôturante). **Dès qu'une seule salle est écrite, le filet ne
s'applique plus** : une case sans `ends_dimension` rend 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 quand c'est praticable.

---

## 6. Carte du construit

| Domaine | État | Détail |
|---|---|---|
| Espace d'adressage, statistiques, effets | construit | `docs/architecture-variables.html` |
| Combat, patterns d'attaque et de mouvement | construit | §7 ci-dessous |
| Donjon, plongées, salles, dimensions | construit | `docs/architecture-donjon.html` |
| Trottoir, file d'attente, tables | construit | journal, §5d |
| Postes, console, brigade — **9 phases** | construit | `docs/architecture-employes.html` |
| Habillage visuel — **phases 1 à 6** | construit | `docs/architecture-visuels.html` |
| Équipement (arme, armure, sets, skills) | **non construit** | socle seulement |

### Trois portées de persistance — 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 gardien de fin |
| `meta` | upgrades spéciales, `dimension_index`, `gate_pending`, débloqué | jamais |

La portée est **déclarée par la donnée** (`scope`), 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.

Cycle : dimension 1 (16 cases) → 2 (21) → 3 (26) → 4 (31, case 26 sous
`fact.master_fork_owned`) → retour à la première. L'épreuve `fork_trial`
(5 cases) accorde la fourchette.

### Écrit, publié, mais **inerte** — à savoir avant de promettre quoi que ce soit

- **`room_elements` et `behaviours`** : validés et publiés, le jeu ne les fait ni
  apparaître ni ne les interprète.
- **Compétences d'employé `stamina`, `poise`, `thrift`, `plating`, `nose`** :
  éditables des trois côtés, **aucun code ne les lit**. `tray` non plus — un
  serveur porte une assiette à la fois.
- Le cuisinier tient les postes `stove` et `takeout` selon leur priorité :
  **l'épicerie reste manuelle**.
- La production hors scène couvre désormais les ventes **et** la production :
  comptoir regarni, salle servie, ingrédients déduits. Ce qu'elle ne rejoue pas,
  ce sont les **positions** des employés pendant l'absence — elles reprennent là
  où elles avaient été capturées au départ.
- **La plongée ne rapporte pas de pièces** : le volet monétaire de l'assurance
  est implémenté mais jamais sollicité.
- L'habillage du restaurant **par dimension** reste à faire.
- **`VisualHolder` ignore les directions** : il joue toutes les cases d'une
  feuille comme une seule animation, `face()` ne fait qu'un miroir horizontal.
  Poser une feuille directionnelle ferait défiler les orientations comme un cycle
  de marche. Support directionnel = une rangée choisie par l'orientation + un
  champ de schéma des trois côtés. **À décider avant de générer des animations
  par direction** (elles coûtent une génération par direction).

---

## 7. Invariants de conception — ne pas casser

**Ces règles se sont payées cher. Les enfreindre casse quelque chose qu'aucun
test ne rattrape forcément.**

### Données et autorité

- **Zéro ou vide sur une salle veut dire « demande au-dessus ».** Effectif à 0 →
  statistique. Table de monstres vide → table du biome, puis tout le bestiaire.
  Deux niveaux seulement : la salle, puis le défaut global — le biome n'entre
  dans aucun calcul, ce n'est qu'une **palette** (nom, description, 3 couleurs,
  ambiance).
- **Le repli doit être visible là où on écrit** : le formulaire d'une case
  affiche ce qu'elle héritera, la grille marque d'un `∅` ce qui n'a ni effectif
  ni table.
- **La salle est la seule unité écrite.** La dimension n'est qu'une colonne
  nommée. Le boss est une créature posée dans une case qui coche
  `ends_dimension` — exactement une par colonne. Le verrou n'existe pas comme
  mécanique : c'est une `conditions` non remplie + un crochet `on_blocked`.
- **Deux choses sont figées dans `run`** : la dimension (sinon franchir une porte
  après un gain changerait de colonne en cours de plongée) et la salle (sinon
  chaque relecture retirerait au sort le remplacement par une alternative).
- Sorties : `exit_level` prime sur `exit_offset` ; au-delà de la colonne = fin de
  plongée. Désignation des cases **positionnelle**, renumérotée à la suppression.
- Salles alternatives : la probabilité la plus **haute** décide s'il y a
  remplacement, puis répartition au poids. **Dépasser 100 % n'est pas une
  erreur** ; ajouter une alternative dilue les autres au lieu d'augmenter la
  fréquence des spéciales.
- **Suppression : cascade** sur une entrée de liste ou de carte, **blocage** sur
  un champ obligatoire à valeur unique. Une carte se compare par ses **clés**
  (`CASCADE_MAPS`) *ou* par ses **valeurs** (`CASCADE_VALUE_MAPS`) — un visuel
  est une valeur. Un refus doit dire **par où sortir** (`refuseDeletion`).
- **Une adresse se compare avec son point** : `fact.boss_defeated.` seul
  laisserait passer l'adresse nue, sans point emporterait `fact.boss_defeated_bis`.
- **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 et casse quand même le jeu. La confirmation le dit, faute de
  pouvoir le vérifier.
- **Un champ d'auteur que personne ne lit finit par mentir** — le supprimer.

### Physique, visuel, agents

- **Un visuel ne décide jamais de la collision.** `ShapeFactory` et
  `body_radius()` gardent physique et portée ; un visuel n'a qu'un décalage et
  une échelle. Sinon chaque retouche d'art devient une session d'équilibrage.
  **La réciproque tient aussi** : la créature pose `size_multiplier` sur
  `visuals.scale`, qui se multiplie avec celui du visuel. `size` dit quelle
  taille fait la bête, `scale` cale une image donnée sur un corps de référence.
- **La portée se mesure au corps de la cible, jamais à son centre**, et **depuis
  le centre de l'attaquant** — jamais corps à corps, sinon la règle cesse d'être
  symétrique. `Enemy.body_radius()` = 18 × `size_multiplier`,
  `Player.body_radius()` = 19. L'angle se compare en retirant la demi-ouverture
  angulaire du corps. La **détection** reste au centre : c'est de la vue.
- Couches physiques : joueur `1`, ennemis et murs `2`, ramassables `4`.
- La géométrie d'attaque vit dans des **patterns réutilisables**, pas dans la
  fiche du monstre. Chaque état a un chemin de fin explicite, sinon un visuel
  reste accroché.
- **Un agent qui agit à distance téléporte.** Une tâche de portage a deux temps :
  aller chercher, puis porter. Quand un poste produit quelque chose, il le pose
  **là où il est**.
- **Un objectif se vérifie pendant le trajet, pas à l'arrivée**
  (`_task_still_valid()` à chaque frame).
- **Une réservation doit couvrir la ressource visée, pas seulement la tâche** :
  la ligne d'ardoise réservée sans le four faisait converger deux cuisiniers.
- **Un agent sans tâche a quand même un comportement** : il regagne son poste
  d'attente.
- **Aucune coordination centrale** : chacun note les tâches disponibles pour lui
  et réserve ce qu'il vise.
- **Un seul point de passage par geste.** `pick_up` et `carry_by` faisaient le
  même geste et un seul encaissait : le paiement appartient au ramassage
  (`Dish._on_dirty_plate_taken`), pas au porteur.
- **Toute phase de la brigade est un déverrouillage** (compte à 0 = « ce poste
  n'existe pas encore »), **sauf la console** (`management_console`, base 1) :
  c'est le tableau de commande du magasin, pas un poste de travail.
- Courbe visée : celle des jeux d'accumulation. Compétences **multiplicatives et
  sans plafond dur** — à terme la brigade dépasse le joueur de plusieurs ordres
  de grandeur.
- **Ce qui n'est pas capturé au changement de scène disparaît sans un mot** :
  assiettes, convives, file, position des employés, cuissons en cours. En
  ajoutant un état à la salle, se demander tout de suite qui le rejoue au retour.

---

## 8. Pièges — durables

### Godot / GDScript

- **`action` est aussi lié au clic gauche** (`project.godot`). `Node._input()`
  tourne **avant** le routage GUI : toujours laisser passer les
  `InputEventMouseButton`, sinon aucun bouton ne reçoit le clic.
- **Une garde `is_instance_valid()` dans une boucle typée ne protège pas** :
  l'assignation de `for x: Type in ...` échoue avant le corps. Une entité qui
  s'inscrit dans une liste s'en retire elle-même dans `_exit_tree()`.
- **Une lambda GDScript capture par valeur** : sonder un signal demande un
  conteneur partagé, pas une variable locale.
- **Un `z_index` négatif sur un enfant le fait passer sous son parent**
  (`z_as_relative` vrai par défaut). `visible` ne prouve rien, c'est le `z` qu'il
  faut contrôler.
- **Un nœud retiré par `queue_free()` reste compté jusqu'à la fin de la frame** :
  `remove_child()` d'abord, libération ensuite.
- **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é.
- **Un `call_deferred("_focus_...")` vole le focus à la frame suivante.**
- **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. (Les `.tscn` et
  `.gdshader` sont natifs : ils n'ont pas de `.import`, c'est normal.)

### PHP / JS / outil

- **PHP encode un tableau associatif vide en `[]`, pas `{}`** — et Godot refuse
  `[]` là où il attend un dictionnaire. Le fichier peut contenir `"rooms": {}` et
  `api.php` le relire en tableau vide puis le renvoyer en `[]` : le défaut
  n'apparaît que quand une collection est **vide au chargement**, et tout semble
  se publier « avec succès ». `json_map()` avant publication ;
  `restoreMapShapes()` rétablit la forme à la lecture, pour les collections **et**
  leurs sous-cartes. Un normaliseur doit rendre une carte encodable
  (`normalize_amount_map`, `normalize_visual_map`) — et ne pas être ré-enveloppé
  dans `json_map()`, ce qui lève une TypeError. Côté JS : ne jamais écrire une
  clé texte sur un tableau, `JSON.stringify` la supprime en silence. Une carte
  vide normalisée devient un `stdClass` : employer `collection_count()` dans les
  comptes-rendus PHP, jamais `count()` directement.
- **Publier depuis l'outil écrase les écritures fichier** : le navigateur publie
  son état **en mémoire**. Une page ouverte avant une modification faite hors de
  l'outil la réécrit à la publication, en silence et sans conflit visible.
  **Cliquer « Abandonner et recharger » avant de publier** dès que les données
  ont été touchées ailleurs (cette commande repart du maître publié et remplace
  aussi le brouillon serveur).
- **Les assets de l'outil sont versionnés par `filemtime`** dans `index.php`.
  Sans ça le navigateur sert du cache et une correction publiée reste invisible :
  on croit le code faux alors qu'il ne s'exécute pas.
- **Une valeur hors bornes se ramène à la borne la plus proche**
  (`clamp_to_bounds`), pas au minimum — sinon `max_size: 8` donne 0,25, l'inverse
  exact de l'intention et de ce que fait `clampf`. L'erreur reste signalée.
  **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. Le relever = trois
  lignes plus le miroir dans `enemy_bounds_test`.
- **Masquer un enfant de grille CSS décale les autres d'une piste** : une piste
  masquée se **retire** du `grid-template-columns`, elle ne se met pas à zéro.
- **Un compte rendu de publication doit compter ce qu'on vient d'éditer**, sinon
  une publication qui perd tout dit quand même « succès ».

### Environnement

- **Le dossier de travail est sur Google Drive.** Une synchronisation a rétabli
  un fichier dans une version antérieure **en conservant sa date** — donc
  invisible à `ls`. Comparer au dernier `_backups/` avant de conclure qu'une
  correction n'a jamais été appliquée. **Le 23/08/2026 le retour a emporté six
  fichiers d'un coup et remontait très loin** : `config.php` était revenu en
  schéma 19, `data_catalog.gd` avait retrouvé `appearance`/`state_styles`. Donc:
  devant une erreur inexplicable — surtout un `Cannot find member` sur une
  constante qui existe — **balayer tout l'arbre contre le dernier `_backups/`
  avant de chercher la cause dans son propre code**:
  `for f in $(cd BACKUP && find . -type f); do ... comparer les tailles ...`
  Un fichier plus court que sa sauvegarde sans raison connue est un retour en
  arrière, pas un bug.
- **Un script de correction qui écrit à la fin n'applique rien s'il échoue au
  milieu** : vérifier ce qui a réellement été écrit avant de relancer.
- **Un `.replace(..., 1)` peut viser la mauvaise occurrence** (`if coins < cost:`
  existe dans `purchase_upgrade` **et** `hire_employee`). Vérifier l'unicité du
  motif avant de conclure qu'un test est creux.
- **Une insertion automatique peut tomber dans la fonction voisine** : le fichier
  compile et se lit comme un miroir correct. Contrôler que chaque appel
  appartient bien à la fonction qui porte son nom.

### Tests — les six règles qui comptent

1. **Isoler l'état persistant.** Le smoke test purge assiettes, compteurs, menu,
   effets, `meta_values`, et **tout** modificateur `run:` et `test:` — un
   reliquat d'une exécution interrompue fausse des contrôles sans rapport.
   `GameState.save_locked` se pose **au démarrage** (dans `_enter_tree()` pour
   `upgrade_return_smoke`, le `_ready` d'un enfant passant avant celui du
   parent), et la copie de sauvegarde se prend dans `load_game()` — pas champ par
   champ, mais **le fichier octet pour octet** (`save_file_at_boot`) : énumérer
   les champs a déjà versé deux employés de test dans la partie du joueur.
2. **Imposer ce dont on dépend, ne rien supposer.** Le pattern d'attaque, la
   distance relative à `attack_range`, la statistique plutôt que les upgrades, sa
   propre colonne de salles plutôt que le contenu publié, une vie plancher avant
   un `take_damage`, le bonus qu'on prétend vérifier, une mise de départ avant de
   mesurer une perte bornée à zéro, de quoi cuisiner avant d'attendre un client.
   **Ne jamais figer un numéro de case** ni **choisir une entité par index** —
   sélectionner par capacité et écarter les instances inactives. **Figer** ce qui
   bouge tout seul (une créature erre d'elle-même).
3. **Tester le geste du joueur, pas la fonction.** `toggle_open_closed()` et
   `hire_employee()` étaient écrits, testés, et injoignables : aucun bouton ne
   les atteignait. De même, chercher un nom dans un source ne teste pas son
   exécution — vérifier la fonction *et* la forme exacte de son appel.
4. **Vérifier que le test attrape vraiment la régression** en cassant
   temporairement le code. Un test qui passe sans avoir jamais échoué ne prouve
   rien. La forme cassée doit être **unique dans les deux sens** et **jamais
   vide** (`s.count('')` n'est jamais 1 : le fichier reste cassé en silence), et
   la série doit **vérifier que l'arbre est revenu** après chaque annulation.
5. **Casser la donnée publiée, pas la valeur de secours.**
   `DataCatalog.GAME_VARIABLES` est écrasé par `data/game_data_master.json` : le
   piège s'est produit deux fois.
6. **Un test qui lit une référence nulle bloque au lieu d'échouer** (timeout,
   aucun rapport) — garder les gardes. Ne **jamais appeler
   `complete_dimension()` au milieu du smoke test** : il vide économie et
   upgrades et avance le cycle, tout ce qui suit s'effondre.

---

## 9. 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. Échelle logique 1920×1080.
- Ajouter une couverture de test proportionnée, puis la vérifier en cassant le
  code (§8, règle 4).
- **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 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é |
| Dimensions | 4 dans le cycle, plus `fork_trial` hors cycle |
| Récompenses | source `run:`, sans remise dans une offre, avec remise entre deux |
| Salles | la salle est l'unité écrite, le biome n'est qu'une palette |
| Boss | une créature du bestiaire posée dans une case qui coche `ends_dimension` |
| Verrou | `conditions` de case + crochet `on_blocked`, jamais codé en dur |
| 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 |
| Employés | portée déclarée par la fiche (défaut `dimension`), aucun coût continu |
| Comptoir takeout | recette choisie **directement** dans l'onglet Takeout de la console; zone préparation jusqu'au plafond, cuisiniers sous X jusqu'à Y; zone service +30 % quel que soit le stock, cumulable avec le +30 % d'un serveur; seconde file et service automatique sans vaisselle (30 s de base); prix de vente = `stat.takeout_price_ratio` (0,75) du prix de table |
| Ouverture | deux rideaux indépendants (salle, comptoir) = quatre états; aucune réouverture automatique, jamais |
| Temps | le restaurant tourne toujours; seul le menu Échap suspend l'arbre |
| Interaction | faces utilisables + bordure intérieure à 15 %; plus aucun tapis au sol |
| Plan de la salle | fiches `RestaurantElement`, déplaçables en mode construction, persistées en portée `dimension` |
| Meubles | deux collections: `station_functions` (comportement + coefficients) et `restaurant_elements` (l'objet posé) |
| Déblocage d'un meuble | `conditions` seules; une upgrade s'écrit comme condition |
| Limite par meuble | `max_count_variable` — une statistique, donc améliorable |
| Placement | jamais collé à un mur, jamais superposé; le comptoir takeout est ancré au mur nord à gauche de l'entrée |
| Sauvegardes | trois slots sous `user://saves`, écriture temporaire validée + `.bak`; options globales séparées dans `user://settings.json`; l'ancien fichier migre vers le slot 1 sans suppression |
| Visuels | collection `visuals` partagée, animations en feuille + grille, décor en image de fond |
| Scènes narratives | acteurs présentationnels ancrés à la scène ou entre eux; événements séquentiels par ID, choix/branchements, bulles, déplacements, VFX et écritures limitées aux adresses inscriptibles |
| Rangement | par type (`creatures/`, `characters/`, `props/`, `items/`, `backgrounds/`, `effects/`, `shared/`) — propose, n'interdit jamais |
| Shaders | matériau posé sur un visuel, formulaire déduit des `uniform` |
| Restaurant | habillé **par données** d'une dimension à l'autre, pas une scène par dimension |
| Équipement | prévu (arme, armure, objet, consommables, 1–3 skills, sets resto/donjon) mais **non construit** |

---

## 10. Documents détaillés

| | |
|---|---|
| `docs/journal-conception.md` | **archive intégrale** de l'ancien CLAUDE.md : récits de construction, historique des corrections, justifications |
| `docs/architecture-variables.html` | espace d'adressage, statistiques, effets |
| `docs/architecture-donjon.html` | plongées, salles, dimensions, portées |
| `docs/architecture-employes.html` | les neuf phases de la brigade |
| `docs/architecture-visuels.html` | habillage visuel, six phases |
