diff --git a/.gitignore b/.gitignore index 3cbbc7e..a7354e2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.tmp *.log builds/ +.claude/worktrees/ diff --git a/docs/briefs/terrain-site-zone1-gameplay.md b/docs/briefs/terrain-site-zone1-gameplay.md new file mode 100644 index 0000000..0777fca --- /dev/null +++ b/docs/briefs/terrain-site-zone1-gameplay.md @@ -0,0 +1,317 @@ +# Proposition — terrain du site de la zone I : « Banc du Vigilant » + +**Statut : validé par Olivier le 2026-10-06** (plan de masse, objet secondaire à 62 m, pipeline par script de cuisson, nom « Banc du Vigilant » conservé). Sert de spécification de référence pour l'implémentation. + +Rédigée le 2026-10-06 à partir de `docs/GAME_DESIGN_PLAN.md`, +`docs/VERTICAL_SLICE_TECHNICAL_PLAN.md`, `docs/POC_B_TECHNICAL_BASELINE.md`, +des ADR 0004, 0006, 0008, 0010 et du code sous `game/world/`. Rien n'a été +exécuté ; aucun fichier de jeu n'est modifié par ce brief. + +--- + +## 1. Objectif + +Donner au premier site de plongée (navire de prospection, zone I, 35 m) un +terrain statique, crédible et porteur de gameplay : le relief doit cacher +l'épave, se lire au sonar, offrir une route sûre et une route risquée, et +rendre la recherche intéressante sans marqueur. C'est la pièce « terrain +graybox » de la tranche 3 du plan technique. + +Terminé quand : + +- un joueur sans aide trouve l'accès à l'épave cible en 5 à 10 minutes après + l'arrivée dans la zone (critère §5 du plan technique) ; +- les deux routes existent, se distinguent au sonar et se justifient par les + ressources (profondeur, batterie, collisions) ; +- le sonar renvoie roche, sable et métal selon la cellule touchée, pas un seul + matériau pour tout le fond ; +- l'environnement de test du véhicule utilise la même implémentation de terrain + que le site (une seule implémentation par concept) ; +- `bash scripts/quality_gate.sh` passe. + +## 2. Ce qui existe et ce qui contraint + +| Élément | État | Conséquence pour le terrain | +|---|---|---| +| `game/world/sites/vehicle_test_environment.gd` | heightmap analytique 61×69, 180×200 m, -40 à -78 m, `HeightMapShape3D` | graybox de test ; devient un client du nouvel acteur terrain | +| `game/world/sonar/reference_bathymetry_acquisition.gd` | rayons verticaux, lit `sonar_material` / `sonar_reflectivity` sur le collider | le sonar suit tout terrain qui a une collision ; matériau par corps, pas par cellule | +| ADR 0010 | surface unique en V1 | pas de grotte, tunnel ni surplomb porteur d'information | +| Plan technique §3 | terrain conçu à la main, route sûre + route risquée | pas de génération procédurale à l'exécution | +| Plan technique §13 | chargement du site ≤ 3 s, 60 FPS 1080p | taille et découpage du maillage bornés | +| ADR 0010 | les limites de zone ne sont pas un relief à interpréter | la frontière jouable est une règle applicative, pas un mur de terrain | +| GDP §18 | low-poly atmosphérique, textures simples, identité par lumière et brouillard | réalisme = formes et lumière crédibles, pas photoréalisme | +| GDP §4.1 | coque I certifiée 60 m ; 100–115 % = dégâts croissants | une route ou un objet entre 60 et 69 m est un risque lisible, pas une mort | +| GDP §7.1 | zone d'écho de surface : 150 m, puis 75 m, minimum 50 m | le joueur arrive à ≤ 75 m de l'épave ; la portée sonar est 95 m | +| GDP §13.2 | une cible récente, deux leurres légers inscrits au registre | trois sites d'épave sur un seul terrain | + +**Point à vérifier avant de bâtir dessus** (non vérifié à l'exécution) : dans +`vehicle_test_environment.gd`, le maillage visuel place la ligne 0 à z = +28 +(proche) alors que `HeightMapShape3D` place la ligne 0 au z local le plus +négatif. Si c'est confirmé, la collision actuelle est le terrain visuel +retourné avant/arrière, et le sonar scanne la collision, pas le maillage. Les +tests d'intégration ne le détecteraient pas (ils comptent des échantillons, +pas une forme). Le nouvel acteur terrain doit avoir un test qui compare +hauteur visuelle et hauteur de collision en quelques points. + +## 3. Partis pris de réalisme + +Le site est une **plate-forme carbonatée de bordure de plateau** caraïbe, +l'environnement réel le plus courant à 25–45 m dans l'arc antillais : + +- **Plateau** à 22–32 m : dalle de calcaire corallien recouverte de sable + biogène, patch reefs isolés, rides de sable orientées par la houle. +- **Système d'éperons et sillons** (« spur and groove ») sur la bordure + externe : crêtes rocheuses parallèles séparées de couloirs de sable de 8 à + 20 m de large, descendant vers la rupture de pente. C'est la forme la plus + lisible au sonar et la plus naturelle pour créer des couloirs de navigation. +- **Rupture de pente** à 42–48 m puis **tombant** jusqu'à 85 m, avec un + **cône d'éboulis** au pied des sillons. +- **Sédimentation** : un navire échoué sur une pente de sable biogène + s'enfonce et se recouvre partiellement en quelques mois ; les objets lourds + glissent vers le bas de la pente. Les deux phénomènes servent le gameplay. + +Ce qui reste hors réalisme volontairement : pas de corail animé, pas de +végétation dense, pas de courant (V0.5), pas de houle. Les formes, la lumière +décroissante avec la profondeur, le brouillard et les particules portent la +crédibilité, conformément à la direction artistique. + +## 4. Le site + +### 4.1 Dimensions + +| Donnée | Valeur proposée | +|---|---:| +| Zone jouable | 600 × 600 m | +| Terrain physique | 768 × 768 m (marge de 84 m de chaque côté, noyée dans le brouillard) | +| Heightmap | 512 × 512 px, 1,5 m par cellule, 16 bits | +| Profondeur min / max du terrain | 20 m / 88 m | +| Profondeur d'écrasement coque I | hors terrain : le fond ne tue jamais par lui-même | +| Mouillage du navire | centre nord du plateau, 24 m de fond | +| Point de départ du sous-marin | sous le navire, 10 m de profondeur | + +Les distances sont dimensionnées pour l'autonomie Standard (environ 1,2 km, +16 minutes d'oxygène) : aucune épave à plus de 320 m du mouillage, aucune +paire d'épaves à plus de 260 m l'une de l'autre. + +### 4.2 Plan de masse (nord en haut, navire au centre nord) + +```text + N + +---------------------------+ 20 m plateau clair, sable + patch reefs + | [Navire] | + | Rides . . | 26 m [B] leurre 1 : voilier de plaisance + | ~ ~ ~ [B] | posé à plat sur le sable, visible + | ~ ~ ~ ~ | + | les Deux ^^ l'Échine | 28 m crête longue nord-sud = fil d'Ariane + | Frères ^^ |||| | + | |||| | + | la Saignée |||| | 40 m [C] leurre 2 : caboteur acier dans + | \\\\\\ || [C] | un patch reef, échos roche + métal + | \\\\\\ || | + | \\\\\\ ====== | 45 m rupture de pente (ligne continue) + | \\\\\ [A] | 35 m [A] CIBLE : navire de prospection + | ----- .. . | dans un sillon, poupe sur la rupture + | tombant . . . | 50-64 traîne de débris descendant la pente + | ======== . | 62 m objet secondaire, légèrement + | 85 m | sous la profondeur certifiée + +---------------------------+ + S +``` + +Noms de travail : **l'Échine** (crête), **les Deux Frères** (deux pitons +jumeaux), **les Rides** (champ de rides de sable), **la Saignée** (sillon +principal), **le Tombant**. + +### 4.3 Les trois épaves et leur terrain + +**[A] Cible — navire de prospection (35 m).** Couché dans le sillon de la +Saignée, à 15 m de la rupture de pente. Coque enfoncée d'un tiers dans le +sable (GDP §7.6, « coque enterrée dans le sédiment ») : au sonar, elle se lit +comme une bosse allongée métallique dans un couloir, pas comme une silhouette +évidente. La poupe dépasse au-dessus de la rupture ; le portique, un conteneur +et une partie du gréement ont glissé en contrebas et forment une **traîne de +débris** de 50 à 64 m le long du tombant. La traîne est un fil conducteur : +un joueur qui descend trop tôt sur le tombant la rencontre et peut la +remonter jusqu'à la coque. Caché par : la bosse de l'Échine à l'est, la +profondeur du sillon, le sédiment. + +**[B] Leurre 1 — voilier de plaisance (26 m).** Posé à plat sur le sable, en +plein plateau, au bord des Rides. Facile à trouver, visible de loin. Bois et +résine : écho faible, aucun métal massif. Il apprend au joueur à lire une +silhouette et une matière au sonar. Contradiction découvrable : pas de +portique, un seul mât, taille moitié moindre. + +**[C] Leurre 2 — caboteur en acier (40 m).** Encastré dans un patch reef sur +le flanc est de l'Échine. Même ordre de taille que la cible, même matériau : +le vrai piège. Les échos roche et métal se mêlent ; il faut contourner et +scanner deux fois pour isoler le contact. Contradictions : immatriculation +différente, deux hélices au lieu d'une, pas de portique, avarie qui ne +correspond pas au rapport. + +### 4.4 Les deux routes vers la cible + +**Route sûre — par l'Échine (≈ 420 m, 26–32 m).** Depuis le navire, suivre la +crête longue vers le sud. Elle est lumineuse, large, sans obstacle, et se lit +d'un seul scan. Elle mène au bord du plateau au-dessus de la Saignée : il +faut ensuite descendre dans le sillon par son extrémité nord, à faible +pente. Coût : temps et batterie. Récompense : zéro dégât, retour facile à +estimer. + +**Route risquée — par la Saignée (≈ 260 m, 36–48 m).** Un sillon de 12 à +18 m de large, aux parois de 8 à 10 m, qui coupe le plateau en diagonale et +débouche directement sur la proue de la cible. Sombre (les projecteurs +deviennent nécessaires), étroit (collisions), en pente (la batterie de +remontée compte). Au sonar : une dépression continue, lisible comme une +« ouverture possible » (GDP §7.2, point 4). Aucune paroi ne surplombe, donc +compatible ADR 0010. Récompense : moitié moins de distance. + +**Le tombant — la tentation (50–64 m).** Pas une route : une pente qui +descend sous la rupture. L'objet secondaire (1 unité, ~200 €) repose à 62 m, +dans la zone 100–107 % de la profondeur certifiée : craquements, dégâts lents, +alerte claire. Le joueur voit la traîne de débris descendre et doit décider +de s'arrêter. C'est le « site légèrement sous la profondeur certifiée » de +GDP §7.6 appliqué à un objet optionnel, jamais à la cible. + +### 4.5 Repères pour naviguer sans GPS + +Le joueur n'a ni carte locale ni marqueur. Le terrain doit donc fournir des +amers reconnaissables au sonar à 95 m : + +| Amer | Forme sonar | Rôle | +|---|---|---| +| l'Échine | crête longue, orientée nord-sud | fil d'Ariane entre le navire et la cible | +| les Deux Frères | deux pitons jumeaux, 9 m de haut, 20 m d'écart | repère ouest unique, sert à se recaler | +| les Rides | ondulations régulières de 1 m | signe « plateau nord, navire proche » | +| la Saignée | sillon rectiligne, bords nets | entrée de la route risquée | +| la rupture de pente | ligne continue de rupture | « ne pas dépasser » et limite sud | + +Chaque amer est unique sur le site : aucune forme n'est répétée ailleurs, +pour que la reconnaissance ne trompe pas. + +## 5. Ce que le terrain apporte au gameplay + +1. **Chercher avant de récupérer** (pilier GDP §1.4) : la cible n'est ni la + plus visible [B] ni la plus « métallique » [C] ; elle est la plus cachée. + L'écho de surface amène à ≤ 75 m, le sillon et le sédiment font le reste. +2. **Lecture du sonar comme compétence** : rides = plateau, crête = route, + dépression = passage, bosse dans un couloir = chose enfouie. Le site + enseigne un vocabulaire que les sites suivants réutiliseront. +3. **Risque prévisible** : chaque danger s'annonce avant de coûter. Le sillon + est visiblement étroit, la pente visiblement longue, l'objet à 62 m + visiblement sous la ligne de rupture. +4. **Décision de retour** : la route risquée à l'aller et l'Échine au retour + est le plan « malin » ; le terrain rend ce raisonnement possible. +5. **Enquête matérielle** : la traîne de débris raconte le naufrage (impact + sur la rupture, glissement, enfouissement) et concorde avec le rapport de + la société, ce que les leurres ne font pas. +6. **Rejouabilité** : revenir au site après identification (GDP §8, « repère + local ») pour récupérer l'objet secondaire est une mini-expédition avec un + seul but : gérer la profondeur. + +## 6. Sonar et matériaux par cellule + +Aujourd'hui le matériau sonar est une meta par corps. Le site a besoin de +roche, sable et métal selon l'endroit touché : + +- un **masque de matériaux** 512 × 512 (R = roche, G = sable, B = réservé + débris/concrétion) accompagne la heightmap ; +- l'acteur terrain expose `sonar_material_at(world_position)` et + `sonar_reflectivity_at(world_position)` ; l'acquisition les appelle quand + le collider les possède, sinon lit la meta comme aujourd'hui ; +- réflectivités de départ : roche 0,72 (valeur actuelle), sable 0,45, métal + 1,0. Les valeurs vivent dans la définition de site, pas dans le code. + +Les épaves restent des corps distincts avec leur propre meta ; le clustering +de contacts existant les sépare du fond. + +## 7. Rendu + +- Un seul matériau terrain, shader Forward+ : mélange **triplanaire** sable / + roche piloté par la pente (sable sous 25°, roche au-dessus de 40°, fondu + entre), normal map de rides sur le sable, teinte assombrie avec la + profondeur pour suivre la palette GDP §18.2 (vert clair à 25 m, bleu froid + à 45 m, indigo au pied du tombant). +- Maillage découpé en **64 tuiles de 96 × 96 m** (64 × 64 cellules), avec + `visibility_range` pour ne dessiner que les tuiles dans le brouillard + utile ; une seule `HeightMapShape3D` pour la collision. +- Props low-poly posés à la main, en nombre borné : blocs d'éboulis au pied + du tombant, têtes de corail sur les patch reefs, rien d'animé. +- Brouillard, lumière directionnelle et particules de la scène de test + actuelle sont conservés et réglés par site. + +## 8. Architecture proposée + +| Couche | Élément | Responsabilité | +|---|---|---| +| `game/domain/sites/` | `DiveSiteDefinition` (Resource typée) | id, chemin de scène, heightmap, masque, origine, taille de cellule, profondeur de référence, limites jouables, position de départ, mouillage, placements d'épaves, zones d'écho, réflectivités | +| `game/domain/sites/` | `TerrainHeightField` (RefCounted pur) | lecture de hauteur et de matériau à une position, conversion monde ↔ cellule, bornes ; testable sans scène | +| `game/application/sites/` | `PlayableBoundsRule` | « hors zone » comme résultat typé depuis la télémétrie ; émet un avertissement, jamais un mur | +| `game/world/sites/` | `BathymetricTerrainActor` | construit tuiles, collision et matériau depuis la définition ; expose matériau sonar par position | +| `game/world/sites/` | `dive_site_zone1.tscn` | scène du site : terrain, environnement, points d'injection pour épaves et navire | +| `game/composition/` | câblage | charge la définition, instancie le site, injecte la règle de bornes | +| `content/sites/` | `zone1_vigilant.tres` + `zone1_vigilant_height.png` + `zone1_vigilant_mask.png` | données du site | +| `tools/` (hors runtime) | script de cuisson | produit les PNG depuis une description de primitives géomorphologiques ; déterministe, versionné, jamais appelé par le jeu | + +Les conventions du contrat : hauteur en mètres négatifs vers le bas, +X est, +-Z nord (convention Godot, convertie une seule fois dans +`TerrainHeightField`), heightmap 16 bits avec `depth_min_m` et `depth_max_m` +explicites. Ces points vont dans un **ADR 0012 — terrain de site en champ de +hauteur authoré**, requis par les guardrails §5 et §11 (nouveau contrat de +données persistant dans le dépôt). + +`vehicle_test_environment.gd` perd sa formule analytique : la formule devient +le premier cas d'usage du script de cuisson, et la scène de test charge une +petite définition de site (fixture) comme le ferait le vrai site. + +## 9. Pipeline d'auteur + +1. **Graybox cuit** (cette tranche) : une description déclarative des + primitives (plateau, crête, pitons, sillon, rupture, tombant, rides) + dans le script de cuisson. Itération en minutes, diff lisible, aucun outil + externe. C'est « conçu à la main » : chaque forme est placée et nommée. +2. **Passe de sculpture** (tranche 6) : export de la heightmap vers Blender, + retouche manuelle, réimport. Le contrat ne change pas. +3. **Jamais** de génération à l'exécution ni de dépendance à un plugin + terrain ; si un plugin devenait souhaitable, ADR d'abord. + +## 10. Tests + +- **Unitaires** : `TerrainHeightField` (interpolation bilinéaire, bornes, + conversion monde ↔ cellule, matériau par cellule) ; `PlayableBoundsRule`. +- **Acteur** : hauteur de collision égale à la hauteur du champ en plusieurs + points, dont les quatre coins (ferme le doute du §2). +- **Contrats** sur `zone1_vigilant.tres` : trois placements d'épave dans les + limites ; cible et objets requis au-dessus de 60 m ; objet secondaire entre + 60 et 69 m ; les amers listés sont uniques (pas deux crêtes de même + signature) ; les deux routes existent sous forme de chemins de contrôle + (liste de points) dont chaque point respecte sa profondeur annoncée et + n'est pas dans la roche. +- **Intégration** : charger le site, lancer un scan de référence depuis la + position de départ et vérifier roche et sable dans le buffer ; un rayon + vers le sillon touche le terrain ; temps de chargement sous 3 s. +- **Humains** (playtest tranche 3) : 8/10 trouvent l'accès en 5–10 min ; les + testeurs nomment spontanément au moins un amer. + +## 11. Ordre de construction + +1. ADR 0012, `DiveSiteDefinition`, `TerrainHeightField` et leurs tests. +2. `BathymetricTerrainActor` avec tuiles, collision et matériau sonar par + position ; migration de l'environnement de test sur une fixture. +3. Script de cuisson et première heightmap du Banc du Vigilant (graybox) ; + bornes jouables ; scène `dive_site_zone1.tscn`. +4. Shader triplanaire et props bornés. +5. Points d'injection pour les trois épaves et le navire (les épaves + elles-mêmes appartiennent au reste de la tranche 3). + +Chaque étape laisse la barrière verte et la scène jouable. + +## 12. Décisions demandées à Olivier + +1. **Validation du plan de masse** (§4.2–4.4) : trois épaves, deux routes, un + tombant tentateur. Variante possible : déplacer le leurre 2 dans la Saignée + pour piéger la route risquée. +2. **Objet secondaire à 62 m** : garder la tentation sous la profondeur + certifiée, ou le remonter à 55 m pour un premier site plus doux ? +3. **Pipeline** : script de cuisson d'abord (recommandé) ou sculpture Blender + dès le graybox ? +4. **Nom** : « Banc du Vigilant » ou autre. diff --git a/docs/superpowers/plans/2026-10-06-terrain-site-zone1.md b/docs/superpowers/plans/2026-10-06-terrain-site-zone1.md new file mode 100644 index 0000000..4cf861a --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-terrain-site-zone1.md @@ -0,0 +1,2965 @@ +# Terrain du site zone I (« Banc du Vigilant ») — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the analytic test terrain with a data-driven, authored heightfield site (terrain, bounds, spawn, placements, routes), bake the zone I site "Banc du Vigilant" from a declarative recipe, and make it the terrain the player dives on from `Nouvelle partie`. + +**Architecture:** A pure domain model (`SiteTerrainData` Resource + `TerrainHeightField` sampler + `DiveSiteDefinition` Resource) is produced offline by a GDScript bake tool under `tools/` and stored under `content/sites/`. One world actor (`BathymetricTerrainActor`) builds tiled meshes and a `HeightMapShape3D` from the same field, and answers per-position sonar material queries. A generic world scene (`dive_site.tscn`) places graybox placeholders from the definition. Composition selects a site definition, instantiates the world, spawns the submarine, and evaluates an application `PlayableBoundsRule` each tick. + +**Tech Stack:** Godot 4.7.2 (Forward+), GDScript, headless SceneTree test runners, `bash scripts/quality_gate.sh`. + +**Spec:** `docs/briefs/terrain-site-zone1-gameplay.md` (validated 2026-10-06). Read it first. + +## Global Constraints + +- Godot exactly 4.7.2. Local binary for the executor (Git Bash): + `export GODOT_BIN="/c/Users/olivi/Downloads/Godot_v4.7.2-stable_win64.exe/Godot_v4.7.2-stable_win64_console.exe"`. + Every Godot command below is written as `"$GODOT_BIN" --headless --path . ...`. +- Layer rules from `AGENTS.md`: domain imports nothing from other layers; application imports only domain; world imports application and domain; presentation never imports world; composition wires everything. `scripts/check_architecture.sh` enforces this with `rg`. +- No `res://src/`, `res://scenes/`, `res://tests/` imports in `game/`. No autoload. No plugin. +- No dictionaries across production boundaries: typed Resources / RefCounted with `validation_error() -> String` (existing convention, see `game/domain/rules/dive_difficulty_profile.gd`). +- GDScript style in repo: tabs, static typing, `preload` constants at top, `class_name`, snake_case, French user-facing strings via `tr()`. +- Test runner convention: `extends SceneTree`, `_failures: Array[String]`, `_expect(condition, label)`, `const TEST_COUNT := N` equal to the number of `_expect` calls, prints `": %d passed, 0 failed."` and `quit(0)`, else `push_error` each failure and `quit(1)`. Every new suite MUST be added to `scripts/quality_gate.sh` (a suite not in the gate is not a validation). +- Spec numbers (verbatim): playable zone 600 × 600 m; terrain 768 × 768 m; cell 1.5 m; terrain depth 20 m to 88 m; vessel anchor on ~24 m of seabed, north centre; spawn 10 m deep under the vessel; target wreck ~35 m in the groove; decoy 1 (sail yacht) ~26 m on open sand; decoy 2 (steel coaster) in a patch reef east of the ridge; secondary item at 62 m (between 100 % and 115 % of the 60 m certified depth, i.e. 60–69 m); safe route via the ridge 26–32 m; risky route via the groove 36–48 m, groove 12–18 m wide, walls 8–10 m; sonar reflectivity rock 0.72, sand 0.45, metal 1.0. +- Spec deviation (decided here, record in ADR 0012): heights are NOT stored as a 16-bit PNG. Godot's `Image` PNG loader converts 16-bit PNGs to 8-bit, which at a 68 m range gives 0.27 m steps, too coarse for sonar relief. Heights are stored as `PackedFloat32Array` inside a binary `.res` `SiteTerrainData` resource written by the bake tool. An 8-bit preview PNG is written only for humans. +- Working tree warning: another session has uncommitted cockpit changes (`game/presentation/cockpit/*`, `game/world/sites/vehicle_test_environment.tscn`, `tests/integration/run_vertical_slice_foundation.gd`, `project.godot`). This plan modifies the last two. Before Task 1, either commit that work with its owner or execute this plan in a worktree branched from `main` (superpowers:using-git-worktrees) and rebase at the end. Never `git checkout --`/`stash` someone else's changes. +- Commit after each task. Commit messages end with `Co-Authored-By: Claude Fable 5.1 `. + +## File Structure + +| Path | Responsibility | +|---|---| +| `docs/adr/0012-authored-heightfield-dive-sites.md` | Contract: authored heightfield, units, axes, storage, bounds as rule not relief | +| `game/domain/sonar/sonar_material.gd` (modify) | Add `SAND` | +| `game/domain/sites/site_terrain_data.gd` | Resource: grid metadata + elevations + materials + reflectivities | +| `game/domain/sites/terrain_height_field.gd` | Pure sampler: bilinear height, nearest material, normals, bounds, cell↔world | +| `game/domain/sites/site_placement.gd` | Resource: one authored object (wreck, decoy, item, debris, landmark, anchor) | +| `game/domain/sites/site_route.gd` | Resource: control waypoints for one route | +| `game/domain/sites/dive_site_definition.gd` | Resource: site id, terrain, bounds, spawn, placements, routes + validation | +| `game/application/sites/playable_bounds_status.gd` | Typed result of the bounds rule | +| `game/application/sites/playable_bounds_rule.gd` | Inside / near edge / outside from a position | +| `game/world/sites/bathymetric_terrain_actor.gd` | StaticBody3D: tiled meshes + HeightMapShape3D + `sonar_material_at` | +| `game/world/sites/seabed_terrain.gdshader` | Slope-blended sand/rock, depth tint, ripples | +| `game/world/sites/dive_site.gd` + `dive_site.tscn` | Generic site scene: environment, terrain actor, placeholders from definition | +| `game/world/sites/vehicle_test_environment.gd/.tscn` (delete) | Replaced by `dive_site.tscn` + `content/sites/vehicle_test_site.tres` | +| `game/world/sonar/reference_bathymetry_acquisition.gd` (modify) | Per-position material via `sonar_material_at` when available | +| `game/composition/vertical_slice/dive_world.tscn` | Replaces `vehicle_test_world.tscn`: DiveSite + SubmarineActor (+ particles) + cockpit | +| `game/composition/vertical_slice/vertical_slice_root.gd` (modify) | `select_dive_site`, spawn from definition, bounds rule each tick | +| `game/presentation/cockpit/cockpit_hud.gd/.tscn` (modify) | `present_bounds(status)` warning panel | +| `tools/site_terrain_baker.gd` | Primitives + bake to `SiteTerrainData` + preview PNG | +| `tools/sites/vehicle_test_terrain_recipe.gd` | Recipe reproducing the current analytic calibration terrain | +| `tools/sites/zone1_vigilant_recipe.gd` | Recipe of the Banc du Vigilant | +| `tools/bake_site_terrain.gd` | Headless entry point: `-- ` | +| `content/sites/vehicle_test_terrain.res`, `vehicle_test.tres` | Calibration site data | +| `content/sites/zone1_vigilant_terrain.res`, `zone1_vigilant.tres` | Zone I site data | +| `tests/unit/run_site_domain_tests.gd` | Terrain field, data, definition validation | +| `tests/unit/run_site_application_tests.gd` | Bounds rule | +| `tests/unit/run_site_baker_tests.gd` | Baker determinism and primitives | +| `tests/integration/run_site_terrain_integration.gd` | Actor collision = field, sonar materials per cell, scene load time | +| `tests/contract/run_site_content_tests.gd` | Zone I content rules from the spec | +| `scripts/quality_gate.sh` (modify) | Add the four suites | +| `tests/run_load_checks.gd` (modify) | Add `res://tools` to `SEARCH_ROOTS` | +| `tests/README.md`, `content/README.md`, `docs/VERTICAL_SLICE_TECHNICAL_PLAN.md`, `docs/briefs/terrain-site-zone1-gameplay.md` (modify) | Document the new suites, data, status, and the `.res` deviation | + +Coordinate contract used everywhere below: Godot world axes. `+X` = east, `+Z` = south (so `-Z` = north), `+Y` = up; elevation values are world `Y` in metres (negative under the surface; depth = `-Y`). Grid column index grows with `+X`, row index grows with `+Z`. Cell `(column, row)` is at world `(origin_x_m + column * cell_size_m, elevation, origin_z_m + row * cell_size_m)`. `Rect2` bounds: `position = (x_min, z_min)`, `size = (width_x, depth_z)` in metres. + +--- + +### Task 1: ADR 0012 — authored heightfield dive sites + +**Files:** +- Create: `docs/adr/0012-authored-heightfield-dive-sites.md` + +- [ ] **Step 1: Write the ADR** + +```markdown +# 0012 — Sites de plongée en champ de hauteur authoré + +- Statut : accepté +- Date : 2026-10-06 +- Remplace : aucun + +## Contexte + +Le seul terrain de production est une formule analytique codée dans +`vehicle_test_environment.gd`. La tranche 3 exige un site conçu à la main, +lisible au sonar (ADR 0010 : surface unique), avec une route sûre et une route +risquée, et le sonar a besoin de distinguer roche et sable par cellule, pas par +corps. Il faut un contrat de données pour le terrain, son stockage dans le +dépôt et sa production. + +## Décision + +1. Un site de plongée est décrit par une ressource typée `DiveSiteDefinition` + (`game/domain/sites/`) : identifiant, terrain, limites jouables, point de + départ, placements d'objets et routes de contrôle. +2. Le terrain est un champ de hauteur à surface unique `SiteTerrainData` : + grille régulière de `columns × rows` cellules de `cell_size_m` mètres, + élévations en mètres (axe Y monde, négatif sous la surface) dans un + `PackedFloat32Array`, matériau sonar par cellule dans un `PackedByteArray` + (`SonarMaterial.Value`, ROCK ou SAND). Repère : +X est, +Z sud, +Y haut ; + la colonne croît avec +X, la ligne avec +Z ; la cellule `(0, 0)` est à + `(origin_x_m, origin_z_m)`. +3. Les données sont cuites hors exécution par `tools/bake_site_terrain.gd` à + partir d'une recette déclarative de primitives géomorphologiques nommées + (plateau, crête, piton, sillon, rupture de pente, rides, rugosité + déterministe par graine). La recette est le document d'auteur versionné ; + le résultat est écrit en `.res` binaire sous `content/sites/`. Le jeu ne + génère jamais de terrain à l'exécution. +4. Un PNG 16 bits n'est pas utilisé pour les hauteurs : le chargeur d'images + de Godot ramène les PNG 16 bits en 8 bits, soit des marches de 0,27 m sur + 68 m de dénivelé. Un PNG 8 bits de prévisualisation est produit pour la + revue humaine seulement. +5. Un seul acteur de monde, `BathymetricTerrainActor`, construit le maillage + visuel et la `HeightMapShape3D` depuis le même champ ; un test compare les + deux. Il expose `sonar_material_at(position)` et + `sonar_reflectivity_at(position)` que l'acquisition sonar privilégie sur + les métadonnées de corps. +6. Les limites de la zone jouable sont une règle applicative + (`PlayableBoundsRule`) qui produit un avertissement ; elles ne sont jamais + un mur de terrain (ADR 0010). + +## Conséquences + +- L'environnement de test du véhicule devient une définition de site cuite, + sans formule analytique dans `game/`. +- Toute modification de terrain passe par la recette puis une cuisson ; les + fichiers `.res` sont des artefacts dérivés mais versionnés pour que le + chargement du site reste sous 3 s et que le jeu n'ait pas besoin de l'outil. +- Les tests de contrat de contenu peuvent vérifier profondeur, position et + dégagement des placements contre le champ de hauteur réel. +- Une sculpture manuelle (Blender) ultérieure devra ré-exporter vers le même + `SiteTerrainData` ; le contrat ne change pas. + +## Alternatives considérées + +- **Plugin de terrain externe** : rejeté, dépendance et ADR supplémentaires + sans besoin démontré ; une heightmap tuilée suffit à la V1. +- **Génération à l'exécution depuis la recette** : rejeté, le plan impose un + site conçu à la main et un chargement borné ; la cuisson garantit un + artefact figé et revu. +- **Heightmap PNG 16 bits** : rejeté pour la raison de précision ci-dessus. +- **Maillage sculpté sans champ de hauteur** : rejeté, le sonar et les tests + ont besoin d'une requête de hauteur et de matériau par position. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/adr/0012-authored-heightfield-dive-sites.md +git commit -m "docs: ADR 0012 authored heightfield dive sites + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: `SonarMaterial.SAND` + +**Files:** +- Modify: `game/domain/sonar/sonar_material.gd` +- Test: `tests/unit/run_sonar_application_tests.gd` + +**Interfaces:** +- Produces: `SonarMaterial.Value.SAND` (int 3). `SonarMaterial.is_valid(3) == true`. + +- [ ] **Step 1: Add failing assertions** + +In `tests/unit/run_sonar_application_tests.gd`, inside `_test_sample_buffer_contract()` after the first `_expect(sample.validation_error()...)` line, add: + +```gdscript + var sand_sample := SonarSample.new( + Vector3(2.0, -4.0, 9.0), + Vector3.UP, + 0.4, + -4.0, + SonarMaterial.Value.SAND, + true + ) + _expect(sand_sample.validation_error().is_empty(), "sand is a valid terrain material") + _expect(SonarMaterial.is_valid(SonarMaterial.Value.SAND), "sand is inside the material range") +``` + +Increase `TEST_COUNT` by 2 (32 → 34). + +- [ ] **Step 2: Run, expect failure** + +```bash +"$GODOT_BIN" --headless --path . --script tests/unit/run_sonar_application_tests.gd +``` +Expected: parse error `Identifier "SAND" not declared` (quit non-zero). + +- [ ] **Step 3: Implement** + +`game/domain/sonar/sonar_material.gd`: + +```gdscript +class_name SonarMaterial +extends RefCounted + +enum Value { + UNKNOWN, + ROCK, + METAL, + SAND, +} + + +static func is_valid(value: int) -> bool: + return value >= Value.UNKNOWN and value <= Value.SAND + + +static func is_terrain(value: int) -> bool: + return value == Value.ROCK or value == Value.SAND +``` + +- [ ] **Step 4: Run, expect pass** + +Same command. Expected: `Sonar application unit tests: 34 passed, 0 failed.` +Also run `bash scripts/check_architecture.sh` → `Architecture checks: passed.` + +- [ ] **Step 5: Commit** + +```bash +git add game/domain/sonar/sonar_material.gd tests/unit/run_sonar_application_tests.gd +git commit -m "feat(sonar): add SAND terrain material + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: `SiteTerrainData` and `TerrainHeightField` (domain) + +**Files:** +- Create: `game/domain/sites/site_terrain_data.gd` +- Create: `game/domain/sites/terrain_height_field.gd` +- Create: `tests/unit/run_site_domain_tests.gd` +- Modify: `scripts/quality_gate.sh` + +**Interfaces:** +- Produces `SiteTerrainData extends Resource` with `@export` fields `columns: int`, `rows: int`, `cell_size_m: float`, `origin_x_m: float`, `origin_z_m: float`, `elevations_m: PackedFloat32Array`, `materials: PackedByteArray`, `rock_reflectivity: float`, `sand_reflectivity: float`; `validation_error() -> String`; `index_of(column, row) -> int`. +- Produces `TerrainHeightField extends RefCounted`: `static func from_data(data: SiteTerrainData) -> TerrainHeightField` (null when invalid); `columns()`, `rows()`, `cell_size_m()`, `origin_m() -> Vector2` (x, z), `bounds_m() -> Rect2`, `contains(x_m, z_m) -> bool`, `elevation_at_cell(column, row) -> float`, `material_at_cell(column, row) -> int`, `cell_world_position(column, row) -> Vector3`, `normal_at_cell(column, row) -> Vector3`, `elevation_at(x_m, z_m) -> float` (bilinear, clamped to the grid), `material_at(x_m, z_m) -> int` (nearest cell), `reflectivity_for(material: int) -> float`, `minimum_elevation_m()`, `maximum_elevation_m()`. + +- [ ] **Step 1: Write the failing test suite** + +`tests/unit/run_site_domain_tests.gd`: + +```gdscript +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") + +const TEST_COUNT := 20 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_terrain_data_validation() + _test_height_field_sampling() + + if _failures.is_empty(): + print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site domain unit tests: %d failed." % _failures.size()) + quit(1) + + +func _make_terrain(columns: int, rows: int) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = columns + data.rows = rows + data.cell_size_m = 2.0 + data.origin_x_m = -10.0 + data.origin_z_m = 100.0 + # Packed arrays are passed by value in GDScript: build locally, then assign. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(columns * rows) + materials.resize(columns * rows) + for row in rows: + for column in columns: + var index := row * columns + column + # Plane sloping down toward +Z (south): -20 at row 0, -2 m per row. + elevations[index] = -20.0 - 2.0 * float(row) + materials[index] = ( + SonarMaterial.Value.ROCK if column == 0 else SonarMaterial.Value.SAND + ) + data.elevations_m = elevations + data.materials = materials + return data + + +func _test_terrain_data_validation() -> void: + var data := _make_terrain(3, 4) + _expect(data.validation_error().is_empty(), "regular terrain data is valid") + _expect(data.index_of(2, 3) == 11, "index is row-major with columns along X") + + var tiny := _make_terrain(1, 4) + _expect(not tiny.validation_error().is_empty(), "terrain needs at least two columns") + + var mismatched := _make_terrain(3, 4) + mismatched.elevations_m.resize(5) + _expect(not mismatched.validation_error().is_empty(), "elevation array must match the grid") + + var non_finite := _make_terrain(3, 4) + non_finite.elevations_m[0] = INF + _expect(not non_finite.validation_error().is_empty(), "elevations must be finite") + + var metal_terrain := _make_terrain(3, 4) + metal_terrain.materials[1] = SonarMaterial.Value.METAL + _expect(not metal_terrain.validation_error().is_empty(), "terrain cells are rock or sand only") + + var bad_cell := _make_terrain(3, 4) + bad_cell.cell_size_m = 0.0 + _expect(not bad_cell.validation_error().is_empty(), "cell size must be positive") + + var bad_reflectivity := _make_terrain(3, 4) + bad_reflectivity.sand_reflectivity = 1.5 + _expect(not bad_reflectivity.validation_error().is_empty(), "reflectivity stays within 0..1") + _expect(TerrainHeightField.from_data(bad_reflectivity) == null, "invalid data yields no field") + + +func _test_height_field_sampling() -> void: + var field := TerrainHeightField.from_data(_make_terrain(3, 4)) + _expect(field != null, "valid data yields a field") + if field == null: + return + _expect(field.bounds_m().is_equal_approx(Rect2(-10.0, 100.0, 4.0, 6.0)), "bounds cover (columns-1) by (rows-1) cells") + _expect(field.cell_world_position(2, 3).is_equal_approx(Vector3(-6.0, -26.0, 106.0)), "cell position follows origin and cell size") + _expect(is_equal_approx(field.elevation_at(-10.0, 100.0), -20.0), "elevation at a vertex is exact") + _expect(is_equal_approx(field.elevation_at(-9.0, 101.0), -21.0), "elevation is bilinear between vertices") + _expect(is_equal_approx(field.elevation_at(-50.0, 1000.0), -26.0), "sampling outside clamps to the nearest edge") + _expect(field.material_at(-10.4, 100.0) == SonarMaterial.Value.ROCK, "material is the nearest cell") + _expect(field.material_at(-6.0, 103.0) == SonarMaterial.Value.SAND, "material of an interior cell") + _expect(field.normal_at_cell(1, 1).is_equal_approx(Vector3(0.0, 1.0, 1.0).normalized()), "normal leans against the downhill direction") + _expect(not field.contains(-11.0, 100.0) and field.contains(-8.0, 103.0), "contains follows the bounds") + _expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +Note on the normal: for the test plane, elevation decreases by 1 m per metre of `+Z` (`-2 m` per `2 m` cell), so `dz = -1`, `dx = 0`, normal = `normalize(Vector3(-dx, 1, -dz)) = normalize(0, 1, 1)`. + +- [ ] **Step 2: Run, expect failure** + +```bash +"$GODOT_BIN" --headless --path . --script tests/unit/run_site_domain_tests.gd +``` +Expected: failure loading `res://game/domain/sites/site_terrain_data.gd` (file missing). + +- [ ] **Step 3: Implement `SiteTerrainData`** + +`game/domain/sites/site_terrain_data.gd`: + +```gdscript +class_name SiteTerrainData +extends Resource + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") + +## Regular single-surface heightfield. Column grows with world +X (east), +## row grows with world +Z (south). Elevations are world Y in metres. +@export var columns: int = 0 +@export var rows: int = 0 +@export var cell_size_m: float = 1.0 +@export var origin_x_m: float = 0.0 +@export var origin_z_m: float = 0.0 +@export var elevations_m := PackedFloat32Array() +@export var materials := PackedByteArray() +@export var rock_reflectivity: float = 0.72 +@export var sand_reflectivity: float = 0.45 + + +func index_of(column: int, row: int) -> int: + return row * columns + column + + +func validation_error() -> String: + if columns < 2 or rows < 2: + return "terrain needs at least 2 columns and 2 rows" + if not is_finite(cell_size_m) or cell_size_m <= 0.0: + return "terrain cell size must be positive" + if not is_finite(origin_x_m) or not is_finite(origin_z_m): + return "terrain origin must be finite" + var cell_count := columns * rows + if elevations_m.size() != cell_count: + return "terrain elevation count does not match the grid" + if materials.size() != cell_count: + return "terrain material count does not match the grid" + for elevation: float in elevations_m: + if not is_finite(elevation): + return "terrain elevations must be finite" + for material: int in materials: + if not SonarMaterial.is_terrain(material): + return "terrain materials must be rock or sand" + for reflectivity: float in [rock_reflectivity, sand_reflectivity]: + if not is_finite(reflectivity) or reflectivity <= 0.0 or reflectivity > 1.0: + return "terrain reflectivity must be within ]0, 1]" + return "" +``` + +- [ ] **Step 4: Implement `TerrainHeightField`** + +`game/domain/sites/terrain_height_field.gd`: + +```gdscript +class_name TerrainHeightField +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") + +var _data: SiteTerrainData +var _minimum_elevation_m := 0.0 +var _maximum_elevation_m := 0.0 + + +static func from_data(data: SiteTerrainData) -> TerrainHeightField: + if data == null or not data.validation_error().is_empty(): + return null + var field := TerrainHeightField.new() + field._data = data + field._minimum_elevation_m = data.elevations_m[0] + field._maximum_elevation_m = data.elevations_m[0] + for elevation: float in data.elevations_m: + field._minimum_elevation_m = minf(field._minimum_elevation_m, elevation) + field._maximum_elevation_m = maxf(field._maximum_elevation_m, elevation) + return field + + +func columns() -> int: + return _data.columns + + +func rows() -> int: + return _data.rows + + +func cell_size_m() -> float: + return _data.cell_size_m + + +func origin_m() -> Vector2: + return Vector2(_data.origin_x_m, _data.origin_z_m) + + +func bounds_m() -> Rect2: + return Rect2( + _data.origin_x_m, + _data.origin_z_m, + float(_data.columns - 1) * _data.cell_size_m, + float(_data.rows - 1) * _data.cell_size_m + ) + + +func contains(x_m: float, z_m: float) -> bool: + return bounds_m().has_point(Vector2(x_m, z_m)) + + +func minimum_elevation_m() -> float: + return _minimum_elevation_m + + +func maximum_elevation_m() -> float: + return _maximum_elevation_m + + +func elevation_at_cell(column: int, row: int) -> float: + return _data.elevations_m[_data.index_of(_clamp_column(column), _clamp_row(row))] + + +func material_at_cell(column: int, row: int) -> int: + return _data.materials[_data.index_of(_clamp_column(column), _clamp_row(row))] + + +func cell_world_position(column: int, row: int) -> Vector3: + return Vector3( + _data.origin_x_m + float(column) * _data.cell_size_m, + elevation_at_cell(column, row), + _data.origin_z_m + float(row) * _data.cell_size_m + ) + + +func normal_at_cell(column: int, row: int) -> Vector3: + var left := elevation_at_cell(column - 1, row) + var right := elevation_at_cell(column + 1, row) + var north := elevation_at_cell(column, row - 1) + var south := elevation_at_cell(column, row + 1) + var span_x := float(_clamp_column(column + 1) - _clamp_column(column - 1)) * _data.cell_size_m + var span_z := float(_clamp_row(row + 1) - _clamp_row(row - 1)) * _data.cell_size_m + var slope_x := (right - left) / span_x + var slope_z := (south - north) / span_z + return Vector3(-slope_x, 1.0, -slope_z).normalized() + + +func elevation_at(x_m: float, z_m: float) -> float: + var column_f := clampf( + (x_m - _data.origin_x_m) / _data.cell_size_m, + 0.0, + float(_data.columns - 1) + ) + var row_f := clampf( + (z_m - _data.origin_z_m) / _data.cell_size_m, + 0.0, + float(_data.rows - 1) + ) + var column := mini(int(floorf(column_f)), _data.columns - 2) + var row := mini(int(floorf(row_f)), _data.rows - 2) + var tx := column_f - float(column) + var tz := row_f - float(row) + var top := lerpf(elevation_at_cell(column, row), elevation_at_cell(column + 1, row), tx) + var bottom := lerpf( + elevation_at_cell(column, row + 1), + elevation_at_cell(column + 1, row + 1), + tx + ) + return lerpf(top, bottom, tz) + + +func material_at(x_m: float, z_m: float) -> int: + var column := int(roundf((x_m - _data.origin_x_m) / _data.cell_size_m)) + var row := int(roundf((z_m - _data.origin_z_m) / _data.cell_size_m)) + return material_at_cell(column, row) + + +func reflectivity_for(material: int) -> float: + match material: + SonarMaterial.Value.SAND: + return _data.sand_reflectivity + _: + return _data.rock_reflectivity + + +func _clamp_column(column: int) -> int: + return clampi(column, 0, _data.columns - 1) + + +func _clamp_row(row: int) -> int: + return clampi(row, 0, _data.rows - 1) +``` + +- [ ] **Step 5: Run, expect pass** + +Same command. Expected: `Site domain unit tests: 20 passed, 0 failed.` If a count differs, fix the assertion, not `TEST_COUNT`, unless you added or removed an `_expect`. + +- [ ] **Step 6: Add the suite to the gate** + +In `scripts/quality_gate.sh`, after the "production sonar application unit tests" block, insert: + +```bash +printf 'Quality gate: production site domain unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-domain-unit.log" \ + --path . \ + --script tests/unit/run_site_domain_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log" +check_godot_log \ + "production site domain unit tests" \ + "$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log" +``` + +Run `bash -n scripts/quality_gate.sh` → no output. + +- [ ] **Step 7: Commit** + +```bash +git add game/domain/sites tests/unit/run_site_domain_tests.gd scripts/quality_gate.sh +git commit -m "feat(sites): typed terrain data and height field sampler + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: `SitePlacement`, `SiteRoute`, `DiveSiteDefinition` (domain) + +**Files:** +- Create: `game/domain/sites/site_placement.gd` +- Create: `game/domain/sites/site_route.gd` +- Create: `game/domain/sites/dive_site_definition.gd` +- Modify: `tests/unit/run_site_domain_tests.gd` + +**Interfaces:** +- `SitePlacement extends Resource`: `enum Kind { WRECK_TARGET, WRECK_DECOY, SECONDARY_ITEM, DEBRIS, LANDMARK, VESSEL_ANCHOR }`; `@export placement_id: StringName`, `kind: Kind`, `position_m: Vector3` (world, absolute Y), `yaw_radians: float`, `footprint_m: Vector3` (graybox size; `Vector3.ZERO` allowed for LANDMARK / VESSEL_ANCHOR); `validation_error() -> String`; `is_sonar_metal() -> bool` (true for WRECK_*, SECONDARY_ITEM, DEBRIS). +- `SiteRoute extends Resource`: `@export route_id: StringName`, `waypoints_m: PackedVector3Array`, `maximum_depth_m: float`; `validation_error() -> String`. +- `DiveSiteDefinition extends Resource`: `@export site_id: StringName`, `display_name: String`, `terrain: SiteTerrainData`, `playable_bounds_m: Rect2`, `spawn_position_m: Vector3`, `spawn_yaw_radians: float`, `placements: Array[SitePlacement]`, `routes: Array[SiteRoute]`; `validation_error() -> String`; `height_field() -> TerrainHeightField` (null when invalid); `placement(placement_id) -> SitePlacement`; `placements_of_kind(kind) -> Array[SitePlacement]`; `route(route_id) -> SiteRoute`; `spawn_transform() -> Transform3D` (rotation about Y by `spawn_yaw_radians`, origin `spawn_position_m`). + +- [ ] **Step 1: Add failing tests** + +In `tests/unit/run_site_domain_tests.gd` add the preloads + +```gdscript +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +``` + +call `_test_site_definition_validation()` from `_init()`, set `TEST_COUNT := 38` (20 + 18 new `_expect` calls), and add: + +```gdscript +func _make_placement(id: String, kind: int, position: Vector3) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = StringName(id) + placement.kind = kind + placement.position_m = position + placement.footprint_m = Vector3(4.0, 2.0, 3.0) + return placement + + +func _make_route(id: String, waypoints: PackedVector3Array, maximum_depth: float) -> SiteRoute: + var route := SiteRoute.new() + route.route_id = StringName(id) + route.waypoints_m = waypoints + route.maximum_depth_m = maximum_depth + return route + + +func _make_definition() -> DiveSiteDefinition: + var definition := DiveSiteDefinition.new() + definition.site_id = &"test_site" + definition.display_name = "Site de test" + definition.terrain = _make_terrain(3, 4) + definition.playable_bounds_m = Rect2(-10.0, 100.0, 4.0, 6.0) + definition.spawn_position_m = Vector3(-8.0, -10.0, 102.0) + definition.spawn_yaw_radians = PI * 0.5 + definition.placements = [ + _make_placement("target", SitePlacement.Kind.WRECK_TARGET, Vector3(-8.0, -23.0, 103.0)), + _make_placement("anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(-8.0, -20.0, 100.5)), + ] + definition.routes = [ + _make_route( + "safe", + PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -14.0, 103.0)]), + 20.0 + ), + ] + return definition + + +func _test_site_definition_validation() -> void: + var definition := _make_definition() + _expect(definition.validation_error().is_empty(), "complete definition is valid") + _expect(definition.height_field() != null, "definition exposes its height field") + _expect(definition.placement(&"target").kind == SitePlacement.Kind.WRECK_TARGET, "placement lookup by id") + _expect(definition.placement(&"missing") == null, "unknown placement is null") + _expect(definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR).size() == 1, "placements filter by kind") + _expect(definition.route(&"safe").waypoints_m.size() == 2, "route lookup by id") + _expect(definition.spawn_transform().origin.is_equal_approx(Vector3(-8.0, -10.0, 102.0)), "spawn transform origin") + _expect( + (definition.spawn_transform().basis * Vector3.FORWARD).is_equal_approx(Vector3.LEFT), + "spawn yaw rotates the forward axis about Y" + ) + _expect(definition.placement(&"target").is_sonar_metal(), "wrecks read as metal on sonar") + _expect(not definition.placement(&"anchor").is_sonar_metal(), "anchor is not a sonar body") + + var no_id := _make_definition() + no_id.site_id = &"" + _expect(not no_id.validation_error().is_empty(), "site id is required") + + var no_terrain := _make_definition() + no_terrain.terrain = null + _expect(not no_terrain.validation_error().is_empty(), "terrain is required") + + var bounds_outside := _make_definition() + bounds_outside.playable_bounds_m = Rect2(-20.0, 100.0, 4.0, 6.0) + _expect(not bounds_outside.validation_error().is_empty(), "playable bounds must lie inside the terrain") + + var spawn_outside := _make_definition() + spawn_outside.spawn_position_m = Vector3(50.0, -10.0, 102.0) + _expect(not spawn_outside.validation_error().is_empty(), "spawn must lie inside the bounds") + + var duplicate_ids := _make_definition() + duplicate_ids.placements.append( + _make_placement("target", SitePlacement.Kind.WRECK_DECOY, Vector3(-7.0, -23.0, 104.0)) + ) + _expect(not duplicate_ids.validation_error().is_empty(), "placement ids are unique") + + var placement_outside := _make_definition() + placement_outside.placements.append( + _make_placement("far", SitePlacement.Kind.DEBRIS, Vector3(80.0, -23.0, 104.0)) + ) + _expect(not placement_outside.validation_error().is_empty(), "placements must lie inside the bounds") + + var empty_route := _make_definition() + empty_route.routes.append(_make_route("empty", PackedVector3Array(), 20.0)) + _expect(not empty_route.validation_error().is_empty(), "routes need at least two waypoints") + + var deep_route := _make_definition() + deep_route.routes.append( + _make_route("deep", PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -30.0, 103.0)]), 20.0) + ) + _expect(not deep_route.validation_error().is_empty(), "route waypoints respect the route maximum depth") +``` + +- [ ] **Step 2: Run, expect failure** (missing scripts). Same command as Task 3. + +- [ ] **Step 3: Implement `SitePlacement`** + +`game/domain/sites/site_placement.gd`: + +```gdscript +class_name SitePlacement +extends Resource + +enum Kind { + WRECK_TARGET, + WRECK_DECOY, + SECONDARY_ITEM, + DEBRIS, + LANDMARK, + VESSEL_ANCHOR, +} + +@export var placement_id: StringName +@export var kind: Kind = Kind.LANDMARK +@export var position_m: Vector3 = Vector3.ZERO +@export var yaw_radians: float = 0.0 +@export var footprint_m: Vector3 = Vector3.ZERO + + +func is_sonar_metal() -> bool: + return kind in [Kind.WRECK_TARGET, Kind.WRECK_DECOY, Kind.SECONDARY_ITEM, Kind.DEBRIS] + + +func validation_error() -> String: + if placement_id.is_empty(): + return "placement id is empty" + if not (is_finite(position_m.x) and is_finite(position_m.y) and is_finite(position_m.z)): + return "placement position must be finite" + if not is_finite(yaw_radians): + return "placement yaw must be finite" + if footprint_m.x < 0.0 or footprint_m.y < 0.0 or footprint_m.z < 0.0: + return "placement footprint must not be negative" + if is_sonar_metal() and footprint_m.length_squared() == 0.0: + return "sonar bodies need a footprint" + return "" +``` + +- [ ] **Step 4: Implement `SiteRoute`** + +`game/domain/sites/site_route.gd`: + +```gdscript +class_name SiteRoute +extends Resource + +@export var route_id: StringName +@export var waypoints_m := PackedVector3Array() +@export var maximum_depth_m: float = 0.0 + + +func validation_error() -> String: + if route_id.is_empty(): + return "route id is empty" + if waypoints_m.size() < 2: + return "route needs at least two waypoints" + if not is_finite(maximum_depth_m) or maximum_depth_m <= 0.0: + return "route maximum depth must be positive" + for waypoint: Vector3 in waypoints_m: + if not (is_finite(waypoint.x) and is_finite(waypoint.y) and is_finite(waypoint.z)): + return "route waypoints must be finite" + if -waypoint.y > maximum_depth_m: + return "route waypoint exceeds the route maximum depth" + return "" +``` + +- [ ] **Step 5: Implement `DiveSiteDefinition`** + +`game/domain/sites/dive_site_definition.gd`: + +```gdscript +class_name DiveSiteDefinition +extends Resource + +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") + +@export var site_id: StringName +@export var display_name: String = "" +@export var terrain: SiteTerrainData +## Metres, world XZ: position = (x_min, z_min), size = (width along +X, depth along +Z). +@export var playable_bounds_m: Rect2 = Rect2() +@export var spawn_position_m: Vector3 = Vector3.ZERO +@export var spawn_yaw_radians: float = 0.0 +@export var placements: Array[SitePlacement] = [] +@export var routes: Array[SiteRoute] = [] + + +func height_field() -> TerrainHeightField: + return TerrainHeightField.from_data(terrain) + + +func spawn_transform() -> Transform3D: + return Transform3D(Basis(Vector3.UP, spawn_yaw_radians), spawn_position_m) + + +func placement(placement_id: StringName) -> SitePlacement: + for candidate: SitePlacement in placements: + if candidate != null and candidate.placement_id == placement_id: + return candidate + return null + + +func placements_of_kind(kind: SitePlacement.Kind) -> Array[SitePlacement]: + var result: Array[SitePlacement] = [] + for candidate: SitePlacement in placements: + if candidate != null and candidate.kind == kind: + result.append(candidate) + return result + + +func route(route_id: StringName) -> SiteRoute: + for candidate: SiteRoute in routes: + if candidate != null and candidate.route_id == route_id: + return candidate + return null + + +func validation_error() -> String: + if site_id.is_empty(): + return "site id is empty" + if terrain == null: + return "site terrain is missing" + var terrain_error := terrain.validation_error() + if not terrain_error.is_empty(): + return terrain_error + var field := TerrainHeightField.from_data(terrain) + if playable_bounds_m.size.x <= 0.0 or playable_bounds_m.size.y <= 0.0: + return "playable bounds must have a positive size" + if not field.bounds_m().encloses(playable_bounds_m): + return "playable bounds must lie inside the terrain" + if not _inside_bounds(spawn_position_m): + return "spawn position must lie inside the playable bounds" + if not is_finite(spawn_yaw_radians): + return "spawn yaw must be finite" + var seen_ids: Dictionary = {} + for candidate: SitePlacement in placements: + if candidate == null: + return "placement entry is null" + var placement_error := candidate.validation_error() + if not placement_error.is_empty(): + return placement_error + if seen_ids.has(candidate.placement_id): + return "placement id %s is duplicated" % candidate.placement_id + seen_ids[candidate.placement_id] = true + if not _inside_bounds(candidate.position_m): + return "placement %s lies outside the playable bounds" % candidate.placement_id + var seen_routes: Dictionary = {} + for candidate_route: SiteRoute in routes: + if candidate_route == null: + return "route entry is null" + var route_error := candidate_route.validation_error() + if not route_error.is_empty(): + return route_error + if seen_routes.has(candidate_route.route_id): + return "route id %s is duplicated" % candidate_route.route_id + seen_routes[candidate_route.route_id] = true + for waypoint: Vector3 in candidate_route.waypoints_m: + if not _inside_bounds(waypoint): + return "route %s leaves the playable bounds" % candidate_route.route_id + return "" + + +func _inside_bounds(position: Vector3) -> bool: + return playable_bounds_m.has_point(Vector2(position.x, position.z)) +``` + +The local `Dictionary` for id uniqueness never crosses a boundary; it is allowed. + +- [ ] **Step 6: Run, expect pass** → `Site domain unit tests: 38 passed, 0 failed.` Then `bash scripts/check_architecture.sh`. + +- [ ] **Step 7: Commit** + +```bash +git add game/domain/sites tests/unit/run_site_domain_tests.gd +git commit -m "feat(sites): typed dive site definition with placements and routes + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: `PlayableBoundsRule` (application) + +**Files:** +- Create: `game/application/sites/playable_bounds_status.gd` +- Create: `game/application/sites/playable_bounds_rule.gd` +- Create: `tests/unit/run_site_application_tests.gd` +- Modify: `scripts/quality_gate.sh` + +**Interfaces:** +- `PlayableBoundsStatus extends RefCounted`: `enum State { INSIDE, NEAR_EDGE, OUTSIDE }`; `state: State`; `distance_to_edge_m: float` (positive inside, negative outside); `return_direction: Vector3` (unit XZ vector pointing toward the bounds centre, `Vector3.ZERO` when `INSIDE`); `requires_warning() -> bool` (NEAR_EDGE or OUTSIDE). +- `PlayableBoundsRule extends RefCounted`: `_init(bounds_m: Rect2, warning_margin_m: float = 40.0)`; `evaluate(position_m: Vector3) -> PlayableBoundsStatus`. + +- [ ] **Step 1: Write the failing suite** + +`tests/unit/run_site_application_tests.gd`: + +```gdscript +extends SceneTree + +const PlayableBoundsRule = preload("res://game/application/sites/playable_bounds_rule.gd") +const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd") + +const TEST_COUNT := 9 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_bounds_rule() + + if _failures.is_empty(): + print("Site application unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site application unit tests: %d failed." % _failures.size()) + quit(1) + + +func _test_bounds_rule() -> void: + var rule := PlayableBoundsRule.new(Rect2(-300.0, -300.0, 600.0, 600.0), 40.0) + var centre := rule.evaluate(Vector3(0.0, -20.0, 0.0)) + _expect(centre.state == PlayableBoundsStatus.State.INSIDE, "centre is inside") + _expect(is_equal_approx(centre.distance_to_edge_m, 300.0), "distance to the nearest edge") + _expect(not centre.requires_warning(), "inside needs no warning") + var near := rule.evaluate(Vector3(275.0, -20.0, 10.0)) + _expect(near.state == PlayableBoundsStatus.State.NEAR_EDGE, "within the margin is near the edge") + _expect(is_equal_approx(near.distance_to_edge_m, 25.0), "near edge distance is measured to the east edge") + _expect(near.return_direction.is_equal_approx(Vector3.LEFT), "return direction points back toward the centre") + var outside := rule.evaluate(Vector3(0.0, -20.0, -340.0)) + _expect(outside.state == PlayableBoundsStatus.State.OUTSIDE, "beyond the edge is outside") + _expect(is_equal_approx(outside.distance_to_edge_m, -40.0), "outside distance is negative") + _expect(outside.return_direction.is_equal_approx(Vector3.BACK), "outside north returns south") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +- [ ] **Step 2: Run, expect failure** (missing scripts): + +```bash +"$GODOT_BIN" --headless --path . --script tests/unit/run_site_application_tests.gd +``` + +- [ ] **Step 3: Implement** + +`game/application/sites/playable_bounds_status.gd`: + +```gdscript +class_name PlayableBoundsStatus +extends RefCounted + +enum State { + INSIDE, + NEAR_EDGE, + OUTSIDE, +} + +var state: State +var distance_to_edge_m: float +var return_direction: Vector3 + + +func _init(p_state: State, p_distance_to_edge_m: float, p_return_direction: Vector3) -> void: + state = p_state + distance_to_edge_m = p_distance_to_edge_m + return_direction = p_return_direction + + +func requires_warning() -> bool: + return state != State.INSIDE +``` + +`game/application/sites/playable_bounds_rule.gd`: + +```gdscript +class_name PlayableBoundsRule +extends RefCounted + +const PlayableBoundsStatus = preload( + "res://game/application/sites/playable_bounds_status.gd" +) + +var _bounds_m: Rect2 +var _warning_margin_m: float + + +func _init(bounds_m: Rect2, warning_margin_m: float = 40.0) -> void: + _bounds_m = bounds_m + _warning_margin_m = maxf(warning_margin_m, 0.0) + + +func evaluate(position_m: Vector3) -> PlayableBoundsStatus: + var point := Vector2(position_m.x, position_m.z) + var to_west := point.x - _bounds_m.position.x + var to_east := _bounds_m.end.x - point.x + var to_north := point.y - _bounds_m.position.y + var to_south := _bounds_m.end.y - point.y + var distance := minf(minf(to_west, to_east), minf(to_north, to_south)) + var state := PlayableBoundsStatus.State.INSIDE + if distance < 0.0: + state = PlayableBoundsStatus.State.OUTSIDE + elif distance < _warning_margin_m: + state = PlayableBoundsStatus.State.NEAR_EDGE + var direction := Vector3.ZERO + if state != PlayableBoundsStatus.State.INSIDE: + var centre := _bounds_m.get_center() + var toward := Vector2(centre.x - point.x, centre.y - point.y) + if distance == to_west or distance == to_east: + toward = Vector2(signf(centre.x - point.x), 0.0) + else: + toward = Vector2(0.0, signf(centre.y - point.y)) + direction = Vector3(toward.x, 0.0, toward.y) + return PlayableBoundsStatus.new(state, distance, direction) +``` + +- [ ] **Step 4: Run, expect pass** → `Site application unit tests: 9 passed, 0 failed.` + +- [ ] **Step 5: Add to the gate**, after the site domain block: + +```bash +printf 'Quality gate: production site application unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-application-unit.log" \ + --path . \ + --script tests/unit/run_site_application_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-application-unit-console.log" +check_godot_log \ + "production site application unit tests" \ + "$QUALITY_TEMP_DIRECTORY/site-application-unit-console.log" +``` + +- [ ] **Step 6: Commit** + +```bash +git add game/application/sites tests/unit/run_site_application_tests.gd scripts/quality_gate.sh +git commit -m "feat(sites): playable bounds rule with typed status + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 6: Terrain baker, calibration recipe, bake entry point + +**Files:** +- Create: `tools/site_terrain_baker.gd` +- Create: `tools/sites/vehicle_test_terrain_recipe.gd` +- Create: `tools/bake_site_terrain.gd` +- Create: `tests/unit/run_site_baker_tests.gd` +- Create (generated): `content/sites/vehicle_test_terrain.res`, `docs/briefs/captures/vehicle_test_terrain_preview.png` +- Modify: `tests/run_load_checks.gd` (add `"res://tools"` to `SEARCH_ROOTS`), `scripts/quality_gate.sh` + +**Interfaces:** +- `SiteTerrainBaker extends RefCounted` with nested classes: + - `Recipe`: `columns: int`, `rows: int`, `cell_size_m: float`, `origin_x_m: float`, `origin_z_m: float`, `floor_elevation_m: float` (clamp), `rock_slope_degrees: float` (default 20), `rock_reflectivity`, `sand_reflectivity`, `primitives: Array[Primitive]`, `material_patches: Array[MaterialPatch]`. + - `Primitive` (base): `func elevation_delta(x_m: float, z_m: float) -> float`. + - `Plane(depth_m, slope_x, slope_z, reference_x, reference_z)`: `-depth_m + slope_x * (x - reference_x) + slope_z * (z - reference_z)`. + - `Blob(centre: Vector2, sigma_x_m, sigma_z_m, amplitude_m)`: `amplitude * exp(-(dx²/sigma_x² + dz²/sigma_z²))`. + - `Ridge(start: Vector2, end: Vector2, sigma_m, amplitude_m, taper_m)`: gaussian of the distance to the segment, multiplied by `smoothstep(0, taper_m, distance_from_each_end_along_segment)`. + - `Groove(start, end, half_width_m, depth_m, wall_softness_m)`: `-depth * (1 - smoothstep(half_width - softness, half_width + softness, distance_to_segment))`, same end taper as Ridge with `taper_m = half_width_m`. + - `Step(point: Vector2, normal: Vector2, drop_m, softness_m, slope_beyond)`: signed distance `d = dot(p - point, normal)`; delta `= -(drop * smoothstep(-softness, softness, d) + max(d - softness, 0) * slope_beyond)`. + - `Ripples(direction: Vector2, wavelength_m, amplitude_m, centre: Vector2, radius_m)`: `amplitude * sin(dot(p, dir_normalised) * TAU / wavelength) * (1 - smoothstep(radius*0.6, radius, |p - centre|))`; `radius_m <= 0` means global. + - `Roughness(seed: int, frequency, amplitude_m)`: `FastNoiseLite` (`TYPE_SIMPLEX_SMOOTH`, fractal FBM 3 octaves) `get_noise_2d(x, z) * amplitude`. + - `MaterialPatch(centre: Vector2, radius_m, material: int)`: override inside the radius. + - `static func bake(recipe: Recipe) -> SiteTerrainData`: elevation = sum of deltas, clamped to `>= floor_elevation_m`; material = `ROCK` when the slope (from `TerrainHeightField.normal_at_cell` of a provisional field) exceeds `rock_slope_degrees`, else `SAND`; then patches override. + - `static func write_preview_png(data: SiteTerrainData, path: String) -> Error`: 8-bit grayscale from min to max elevation, rock cells tinted red channel +40. +- `tools/bake_site_terrain.gd extends SceneTree`: reads `OS.get_cmdline_user_args()[0]` as recipe name, looks it up in a `const RECIPES := { "vehicle_test": ..., "zone1_vigilant": ... }` of script paths, calls `recipe_script.recipe()`, bakes, saves `res://content/sites/_terrain.res` with `ResourceSaver.save`, writes `res://docs/briefs/captures/_terrain_preview.png`, prints the min/max elevation and the rock cell ratio, `quit(0)`; any error → `push_error` + `quit(1)`. +- `tools/sites/vehicle_test_terrain_recipe.gd`: `static func recipe() -> SiteTerrainBaker.Recipe` reproducing the current analytic terrain of `vehicle_test_environment.gd` (61 × 69 cells, 3 m × ~2.94 m — see below). + +- [ ] **Step 1: Write the failing baker tests** + +`tests/unit/run_site_baker_tests.gd`: + +```gdscript +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const VehicleTestRecipe = preload("res://tools/sites/vehicle_test_terrain_recipe.gd") + +const TEST_COUNT := 10 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_plane_and_blob() + _test_materials_and_determinism() + + if _failures.is_empty(): + print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site baker unit tests: %d failed." % _failures.size()) + quit(1) + + +func _small_recipe() -> SiteTerrainBaker.Recipe: + var recipe := SiteTerrainBaker.Recipe.new() + recipe.columns = 21 + recipe.rows = 21 + recipe.cell_size_m = 2.0 + recipe.origin_x_m = -20.0 + recipe.origin_z_m = -20.0 + recipe.floor_elevation_m = -80.0 + recipe.rock_slope_degrees = 20.0 + return recipe + + +func _test_plane_and_blob() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.Plane.new(30.0, 0.0, 0.1, 0.0, 0.0)) + var data := SiteTerrainBaker.bake(recipe) + _expect(data != null and data.validation_error().is_empty(), "plane recipe bakes valid data") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -30.0), "plane depth at the reference point") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 20)], -28.0), "plane slopes along +Z") + _expect(data.materials[data.index_of(10, 10)] == SonarMaterial.Value.SAND, "gentle plane is sand") + + recipe.primitives.append(SiteTerrainBaker.Blob.new(Vector2(0.0, 0.0), 4.0, 4.0, 10.0)) + data = SiteTerrainBaker.bake(recipe) + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -20.0), "blob adds its amplitude at the centre") + _expect(data.materials[data.index_of(12, 10)] == SonarMaterial.Value.ROCK, "steep blob flank is rock") + + +func _test_materials_and_determinism() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.Plane.new(30.0, 0.0, 0.0, 0.0, 0.0)) + recipe.primitives.append(SiteTerrainBaker.Roughness.new(1933, 0.05, 0.5)) + recipe.material_patches.append( + SiteTerrainBaker.MaterialPatch.new(Vector2(10.0, 10.0), 3.0, SonarMaterial.Value.ROCK) + ) + var first := SiteTerrainBaker.bake(recipe) + var second := SiteTerrainBaker.bake(recipe) + _expect(first.elevations_m == second.elevations_m, "same recipe bakes identical elevations") + _expect(first.materials == second.materials, "same recipe bakes identical materials") + _expect(first.materials[first.index_of(15, 15)] == SonarMaterial.Value.ROCK, "material patch overrides the slope rule") + var calibration := SiteTerrainBaker.bake(VehicleTestRecipe.recipe()) + _expect(calibration != null and calibration.validation_error().is_empty(), "calibration recipe bakes valid data") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +- [ ] **Step 2: Run, expect failure** (missing `tools/site_terrain_baker.gd`): + +```bash +"$GODOT_BIN" --headless --path . --script tests/unit/run_site_baker_tests.gd +``` + +- [ ] **Step 3: Implement `tools/site_terrain_baker.gd`** + +```gdscript +class_name SiteTerrainBaker +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") + + +class Primitive extends RefCounted: + func elevation_delta(_x_m: float, _z_m: float) -> float: + return 0.0 + + +class Plane extends Primitive: + var depth_m: float + var slope_x: float + var slope_z: float + var reference_x_m: float + var reference_z_m: float + + func _init(p_depth_m: float, p_slope_x: float, p_slope_z: float, p_reference_x_m: float, p_reference_z_m: float) -> void: + depth_m = p_depth_m + slope_x = p_slope_x + slope_z = p_slope_z + reference_x_m = p_reference_x_m + reference_z_m = p_reference_z_m + + func elevation_delta(x_m: float, z_m: float) -> float: + return -depth_m + slope_x * (x_m - reference_x_m) + slope_z * (z_m - reference_z_m) + + +class Blob extends Primitive: + var centre: Vector2 + var sigma_x_m: float + var sigma_z_m: float + var amplitude_m: float + + func _init(p_centre: Vector2, p_sigma_x_m: float, p_sigma_z_m: float, p_amplitude_m: float) -> void: + centre = p_centre + sigma_x_m = p_sigma_x_m + sigma_z_m = p_sigma_z_m + amplitude_m = p_amplitude_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var dx := (x_m - centre.x) / sigma_x_m + var dz := (z_m - centre.y) / sigma_z_m + return amplitude_m * exp(-(dx * dx + dz * dz)) + + +class Ridge extends Primitive: + var start: Vector2 + var end: Vector2 + var sigma_m: float + var amplitude_m: float + var taper_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_sigma_m: float, p_amplitude_m: float, p_taper_m: float) -> void: + start = p_start + end = p_end + sigma_m = p_sigma_m + amplitude_m = p_amplitude_m + taper_m = p_taper_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := SiteTerrainBaker.distance_to_segment(point, start, end) + var profile := exp(-pow(distance / sigma_m, 2.0)) + return amplitude_m * profile * SiteTerrainBaker.end_taper(point, start, end, taper_m) + + +class Groove extends Primitive: + var start: Vector2 + var end: Vector2 + var half_width_m: float + var depth_m: float + var wall_softness_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_half_width_m: float, p_depth_m: float, p_wall_softness_m: float) -> void: + start = p_start + end = p_end + half_width_m = p_half_width_m + depth_m = p_depth_m + wall_softness_m = p_wall_softness_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := SiteTerrainBaker.distance_to_segment(point, start, end) + var inside := 1.0 - smoothstep( + half_width_m - wall_softness_m, + half_width_m + wall_softness_m, + distance + ) + return -depth_m * inside * SiteTerrainBaker.end_taper(point, start, end, half_width_m) + + +class Step extends Primitive: + var point: Vector2 + var normal: Vector2 + var drop_m: float + var softness_m: float + var slope_beyond: float + + func _init(p_point: Vector2, p_normal: Vector2, p_drop_m: float, p_softness_m: float, p_slope_beyond: float) -> void: + point = p_point + normal = p_normal.normalized() + drop_m = p_drop_m + softness_m = p_softness_m + slope_beyond = p_slope_beyond + + func elevation_delta(x_m: float, z_m: float) -> float: + var signed_distance := (Vector2(x_m, z_m) - point).dot(normal) + var step := drop_m * smoothstep(-softness_m, softness_m, signed_distance) + var slope := maxf(signed_distance - softness_m, 0.0) * slope_beyond + return -(step + slope) + + +class Ripples extends Primitive: + var direction: Vector2 + var wavelength_m: float + var amplitude_m: float + var centre: Vector2 + var radius_m: float + + func _init(p_direction: Vector2, p_wavelength_m: float, p_amplitude_m: float, p_centre: Vector2, p_radius_m: float) -> void: + direction = p_direction.normalized() + wavelength_m = p_wavelength_m + amplitude_m = p_amplitude_m + centre = p_centre + radius_m = p_radius_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var phase := point.dot(direction) * TAU / wavelength_m + var falloff := 1.0 + if radius_m > 0.0: + falloff = 1.0 - smoothstep(radius_m * 0.6, radius_m, point.distance_to(centre)) + return amplitude_m * sin(phase) * falloff + + +class Roughness extends Primitive: + var amplitude_m: float + var _noise: FastNoiseLite + + func _init(p_seed: int, p_frequency: float, p_amplitude_m: float) -> void: + amplitude_m = p_amplitude_m + _noise = FastNoiseLite.new() + _noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _noise.seed = p_seed + _noise.frequency = p_frequency + _noise.fractal_type = FastNoiseLite.FRACTAL_FBM + _noise.fractal_octaves = 3 + + func elevation_delta(x_m: float, z_m: float) -> float: + return _noise.get_noise_2d(x_m, z_m) * amplitude_m + + +class MaterialPatch extends RefCounted: + var centre: Vector2 + var radius_m: float + var material: int + + func _init(p_centre: Vector2, p_radius_m: float, p_material: int) -> void: + centre = p_centre + radius_m = p_radius_m + material = p_material + + +class Recipe extends RefCounted: + var columns: int = 2 + var rows: int = 2 + var cell_size_m: float = 1.0 + var origin_x_m: float = 0.0 + var origin_z_m: float = 0.0 + var floor_elevation_m: float = -1000.0 + var rock_slope_degrees: float = 20.0 + var rock_reflectivity: float = 0.72 + var sand_reflectivity: float = 0.45 + var primitives: Array[Primitive] = [] + var material_patches: Array[MaterialPatch] = [] + + +static func bake(recipe: Recipe) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = recipe.columns + data.rows = recipe.rows + data.cell_size_m = recipe.cell_size_m + data.origin_x_m = recipe.origin_x_m + data.origin_z_m = recipe.origin_z_m + data.rock_reflectivity = recipe.rock_reflectivity + data.sand_reflectivity = recipe.sand_reflectivity + var cell_count := recipe.columns * recipe.rows + # Packed arrays are passed by value in GDScript: fill locals, assign once. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(cell_count) + materials.resize(cell_count) + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + var elevation := 0.0 + for primitive: Primitive in recipe.primitives: + elevation += primitive.elevation_delta(x_m, z_m) + elevations[data.index_of(column, row)] = maxf(elevation, recipe.floor_elevation_m) + materials[data.index_of(column, row)] = SonarMaterial.Value.SAND + data.elevations_m = elevations + data.materials = materials + var field := TerrainHeightField.from_data(data) + if field == null: + return null + var rock_cosine := cos(deg_to_rad(recipe.rock_slope_degrees)) + for row in recipe.rows: + for column in recipe.columns: + if field.normal_at_cell(column, row).y < rock_cosine: + materials[data.index_of(column, row)] = SonarMaterial.Value.ROCK + for patch: MaterialPatch in recipe.material_patches: + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + if Vector2(x_m, z_m).distance_to(patch.centre) <= patch.radius_m: + materials[data.index_of(column, row)] = patch.material + data.materials = materials + return data + + +static func write_preview_png(data: SiteTerrainData, path: String) -> Error: + var field := TerrainHeightField.from_data(data) + if field == null: + return ERR_INVALID_DATA + var image := Image.create(data.columns, data.rows, false, Image.FORMAT_RGB8) + var span := maxf(field.maximum_elevation_m() - field.minimum_elevation_m(), 0.001) + for row in data.rows: + for column in data.columns: + var ratio := (field.elevation_at_cell(column, row) - field.minimum_elevation_m()) / span + var color := Color(ratio, ratio, ratio) + if field.material_at_cell(column, row) == SonarMaterial.Value.ROCK: + color.r = minf(color.r + 0.16, 1.0) + image.set_pixel(column, row, color) + return image.save_png(path) + + +static func distance_to_segment(point: Vector2, start: Vector2, end: Vector2) -> float: + var segment := end - start + var length_squared := segment.length_squared() + if length_squared == 0.0: + return point.distance_to(start) + var t := clampf((point - start).dot(segment) / length_squared, 0.0, 1.0) + return point.distance_to(start + segment * t) + + +static func end_taper(point: Vector2, start: Vector2, end: Vector2, taper_m: float) -> float: + if taper_m <= 0.0: + return 1.0 + var segment := end - start + var length := segment.length() + if length == 0.0: + return 1.0 + var along := clampf((point - start).dot(segment) / length, 0.0, length) + return smoothstep(0.0, taper_m, along) * smoothstep(0.0, taper_m, length - along) +``` + +- [ ] **Step 4: Implement the calibration recipe** + +`tools/sites/vehicle_test_terrain_recipe.gd` reproduces `vehicle_test_environment.gd` (`_terrain_height`): base `lerp(-40, -78, (28 - z) / 200)` is a plane of depth 40 at `z = 28` with slope `+0.19` per metre of `+Z` (shallower toward +Z); gaussians `A * exp(-(dx²/a + dz²/b))` map to `Blob(centre, sqrt(a), sqrt(b), A)`; `1.8 * sin(0.075x) * cos(0.048z)` is approximated by two `Ripples` (one along X with wavelength `TAU / 0.075`, one along Z with wavelength `TAU / 0.048`, amplitude 0.9 each). Exact reproduction is not required: this is a calibration scene, not a level-design rule (POC B baseline). + +```gdscript +class_name VehicleTestTerrainRecipe +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 61 + result.rows = 69 + result.cell_size_m = 3.0 + result.origin_x_m = -90.0 + result.origin_z_m = -176.0 + result.floor_elevation_m = -95.0 + result.rock_slope_degrees = 20.0 + result.primitives = [ + SiteTerrainBaker.Plane.new(40.0, 0.0, 0.19, 0.0, 28.0), + SiteTerrainBaker.Blob.new(Vector2(-24.0, -48.0), sqrt(210.0), sqrt(980.0), 13.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, -58.0), sqrt(250.0), sqrt(1150.0), 15.0), + SiteTerrainBaker.Blob.new(Vector2(0.0, -66.0), sqrt(120.0), sqrt(2500.0), -8.5), + SiteTerrainBaker.Blob.new(Vector2(-5.0, -125.0), sqrt(900.0), sqrt(620.0), 8.0), + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.0), TAU / 0.075, 0.9, Vector2.ZERO, 0.0), + SiteTerrainBaker.Ripples.new(Vector2(0.0, 1.0), TAU / 0.048, 0.9, Vector2.ZERO, 0.0), + ] + return result +``` + +Grid: 61 columns × 3 m = 180 m wide (x from -90 to +90); 69 rows × 3 m = 204 m (z from -176 to +28). The original spanned z from -172 to +28 with 2.94 m rows; a square 3 m cell is used instead. + +- [ ] **Step 5: Implement the entry point** + +`tools/bake_site_terrain.gd`: + +```gdscript +extends SceneTree + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + +const RECIPES := { + "vehicle_test": "res://tools/sites/vehicle_test_terrain_recipe.gd", + "zone1_vigilant": "res://tools/sites/zone1_vigilant_recipe.gd", +} +const OUTPUT_DIRECTORY := "res://content/sites" +const PREVIEW_DIRECTORY := "res://docs/briefs/captures" + + +func _init() -> void: + var arguments := OS.get_cmdline_user_args() + if arguments.is_empty() or not RECIPES.has(arguments[0]): + push_error("usage: -- <%s>" % " | ".join(RECIPES.keys())) + quit(1) + return + var name: String = arguments[0] + var recipe_script := load(RECIPES[name]) as GDScript + if recipe_script == null: + push_error("recipe script %s is missing" % RECIPES[name]) + quit(1) + return + var recipe: SiteTerrainBaker.Recipe = recipe_script.recipe() + var data := SiteTerrainBaker.bake(recipe) + if data == null: + push_error("recipe %s did not bake valid terrain" % name) + quit(1) + return + DirAccess.make_dir_recursive_absolute(OUTPUT_DIRECTORY) + DirAccess.make_dir_recursive_absolute(PREVIEW_DIRECTORY) + var output_path := "%s/%s_terrain.res" % [OUTPUT_DIRECTORY, name] + var save_error := ResourceSaver.save(data, output_path) + if save_error != OK: + push_error("cannot save %s (%d)" % [output_path, save_error]) + quit(1) + return + var preview_path := "%s/%s_terrain_preview.png" % [PREVIEW_DIRECTORY, name] + var preview_error := SiteTerrainBaker.write_preview_png(data, preview_path) + if preview_error != OK: + push_error("cannot write %s (%d)" % [preview_path, preview_error]) + quit(1) + return + var rock_cells := 0 + for material: int in data.materials: + if material == SiteTerrainBaker.SonarMaterial.Value.ROCK: + rock_cells += 1 + var minimum := data.elevations_m[0] + var maximum := data.elevations_m[0] + for elevation: float in data.elevations_m: + minimum = minf(minimum, elevation) + maximum = maxf(maximum, elevation) + print("Baked %s: %d x %d cells, elevation %.1f .. %.1f m, rock %.0f %%" % [ + name, data.columns, data.rows, minimum, maximum, + 100.0 * float(rock_cells) / float(data.materials.size()) + ]) + quit(0) +``` + +The zone 1 recipe script does not exist yet; the `RECIPES` entry is harmless until Task 9 (the `load` only runs for the requested name). + +- [ ] **Step 6: Run the baker tests, expect pass** → `Site baker unit tests: 10 passed, 0 failed.` If "steep blob flank is rock" fails, check the normal: at column 12 (x = +4 m, one sigma from the centre) the blob slope is `10 * 2 * (4/16) * exp(-1) ≈ 4.6`, far steeper than 20°. + +- [ ] **Step 7: Bake the calibration terrain** + +```bash +"$GODOT_BIN" --headless --path . --script tools/bake_site_terrain.gd -- vehicle_test +``` +Expected: `Baked vehicle_test: 61 x 69 cells, elevation -8x.x .. -2x.x m, rock xx %` and the files `content/sites/vehicle_test_terrain.res` and `docs/briefs/captures/vehicle_test_terrain_preview.png`. Open the PNG: two bright ridges, a dark channel between them, brighter toward the bottom (south, z = +28). + +- [ ] **Step 8: Load checks and gate** + +In `tests/run_load_checks.gd` add `"res://tools",` to `SEARCH_ROOTS` (after `"res://content"`). Run: + +```bash +"$GODOT_BIN" --headless --path . --script tests/run_load_checks.gd +``` +Expected: `Resource loading: N passed, 0 failed.` + +Add to `scripts/quality_gate.sh` after the site application block: + +```bash +printf 'Quality gate: site baker unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-baker-unit.log" \ + --path . \ + --script tests/unit/run_site_baker_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" +check_godot_log "site baker unit tests" "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" +``` + +- [ ] **Step 9: Commit** + +```bash +git add tools tests/unit/run_site_baker_tests.gd tests/run_load_checks.gd scripts/quality_gate.sh content/sites/vehicle_test_terrain.res docs/briefs/captures/vehicle_test_terrain_preview.png +git commit -m "feat(tools): declarative site terrain baker and calibration recipe + +Co-Authored-By: Claude Fable 5.1 " +``` + +Godot writes `.import` side files for the PNG; commit `docs/briefs/captures/vehicle_test_terrain_preview.png.import` too if it appears. + +--- + +### Task 7: `BathymetricTerrainActor` + seabed shader (world) + +**Files:** +- Create: `game/world/sites/bathymetric_terrain_actor.gd` +- Create: `game/world/sites/seabed_terrain.gdshader` +- Create: `tests/integration/run_site_terrain_integration.gd` +- Modify: `scripts/quality_gate.sh` + +**Interfaces:** +- `BathymetricTerrainActor extends StaticBody3D`: `configure(field: TerrainHeightField) -> String` (empty on success; rebuilds tiles and collision, replaces any previous build); `height_field() -> TerrainHeightField`; `tile_count() -> int`; `sonar_material_at(world_position: Vector3) -> int`; `sonar_reflectivity_at(world_position: Vector3) -> float`. Constants `TILE_CELLS := 64`, `VISIBILITY_RANGE_END_M := 260.0`. Collision layer stays the default (1), which is what `ReferenceBathymetryAcquisition.COLLISION_MASK` and the submarine use. + +- [ ] **Step 1: Write the failing integration test** + +`tests/integration/run_site_terrain_integration.gd`: + +```gdscript +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const BathymetricTerrainActor = preload( + "res://game/world/sites/bathymetric_terrain_actor.gd" +) + +const TEST_COUNT := 12 + +var _failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _test_actor_matches_field() + + var exit_code := 0 + if _failures.is_empty(): + print("Site terrain integration: %d passed, 0 failed." % TEST_COUNT) + else: + for failure: String in _failures: + push_error(failure) + print("Site terrain integration: %d failed." % _failures.size()) + exit_code = 1 + await process_frame + quit(exit_code) + + +func _asymmetric_field() -> TerrainHeightField: + var recipe := SiteTerrainBaker.Recipe.new() + recipe.columns = 81 + recipe.rows = 81 + recipe.cell_size_m = 2.0 + recipe.origin_x_m = -80.0 + recipe.origin_z_m = -80.0 + recipe.floor_elevation_m = -90.0 + recipe.primitives = [ + SiteTerrainBaker.Plane.new(40.0, 0.0, 0.0, 0.0, 0.0), + # Tall bump in the north-west corner only: any front/back flip shows up. + SiteTerrainBaker.Blob.new(Vector2(-60.0, -60.0), 10.0, 10.0, 18.0), + ] + return TerrainHeightField.from_data(SiteTerrainBaker.bake(recipe)) + + +func _test_actor_matches_field() -> void: + var field := _asymmetric_field() + _expect(field != null, "asymmetric test field bakes") + if field == null: + return + var actor := BathymetricTerrainActor.new() + root.add_child(actor) + _expect(actor.configure(field).is_empty(), "actor accepts a valid field") + await physics_frame + await physics_frame + + _expect(actor.tile_count() == 4, "80 cells per side split into 2 x 2 tiles of 64") + var space := actor.get_world_3d().direct_space_state + var worst_error := 0.0 + for cell: Vector2i in [ + Vector2i(0, 0), Vector2i(80, 0), Vector2i(0, 80), Vector2i(80, 80), + Vector2i(10, 10), Vector2i(40, 40), Vector2i(64, 20), + ]: + var expected := field.cell_world_position(cell.x, cell.y) + var query := PhysicsRayQueryParameters3D.create( + Vector3(expected.x, 50.0, expected.z), + Vector3(expected.x, -200.0, expected.z) + ) + var hit := space.intersect_ray(query) + if hit.is_empty(): + _failures.append("collision ray at cell %s hits nothing" % cell) + continue + worst_error = maxf(worst_error, absf(float(hit["position"].y) - expected.y)) + _expect(worst_error < 0.05, "collision height equals the field height at vertices (worst %.3f m)" % worst_error) + + var north_west := _collision_height(space, -60.0, -60.0) + var south_east := _collision_height(space, 60.0, 60.0) + _expect(north_west > -30.0, "north-west bump is where the field puts it (collision)") + _expect(south_east < -39.0, "south-east stays flat (collision)") + _expect(absf(north_west - field.elevation_at(-60.0, -60.0)) < 0.05, "bump height matches the field") + + _expect(actor.sonar_material_at(Vector3(-52.0, -30.0, -60.0)) == SonarMaterial.Value.ROCK, "steep flank reports rock") + _expect(actor.sonar_material_at(Vector3(40.0, -40.0, 40.0)) == SonarMaterial.Value.SAND, "flat plain reports sand") + _expect(is_equal_approx(actor.sonar_reflectivity_at(Vector3(40.0, -40.0, 40.0)), 0.45), "sand reflectivity from the data") + + _expect(actor.configure(field).is_empty() and actor.tile_count() == 4, "reconfiguring rebuilds without duplicating tiles") + _expect(not actor.configure(null).is_empty(), "null field is refused") + actor.queue_free() + + +func _collision_height(space: PhysicsDirectSpaceState3D, x: float, z: float) -> float: + var hit := space.intersect_ray( + PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z)) + ) + if hit.is_empty(): + return INF + return float(hit["position"].y) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +- [ ] **Step 2: Run, expect failure** (missing actor script): + +```bash +"$GODOT_BIN" --headless --path . --script tests/integration/run_site_terrain_integration.gd +``` + +- [ ] **Step 3: Write the shader** + +`game/world/sites/seabed_terrain.gdshader`: + +```glsl +shader_type spatial; +render_mode cull_disabled, depth_draw_opaque; + +uniform vec3 sand_color : source_color = vec3(0.50, 0.46, 0.34); +uniform vec3 rock_color : source_color = vec3(0.21, 0.20, 0.18); +uniform vec3 deep_tint : source_color = vec3(0.04, 0.09, 0.15); +uniform float sand_slope_cosine = 0.9397; // cos(20 deg) +uniform float rock_slope_cosine = 0.7660; // cos(40 deg) +uniform float depth_tint_start_m = 20.0; +uniform float depth_tint_end_m = 90.0; +uniform float ripple_wavelength_m = 1.6; +uniform float ripple_contrast = 0.12; + +varying vec3 world_position; +varying vec3 world_normal; + +void vertex() { + world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); +} + +void fragment() { + float up = clamp(world_normal.y, 0.0, 1.0); + float rockness = 1.0 - smoothstep(rock_slope_cosine, sand_slope_cosine, up); + vec3 albedo = mix(sand_color, rock_color, rockness); + float phase = (world_position.x * 0.8 + world_position.z * 0.6) * TAU / ripple_wavelength_m; + float ripple = sin(phase) * ripple_contrast * (1.0 - rockness); + albedo *= 1.0 + ripple; + float depth_m = -world_position.y; + float tint = smoothstep(depth_tint_start_m, depth_tint_end_m, depth_m); + albedo = mix(albedo, deep_tint, tint * 0.6); + ALBEDO = albedo; + ROUGHNESS = mix(0.95, 0.82, rockness); + METALLIC = 0.0; +} +``` + +`cull_disabled` makes the winding order of the tile triangles irrelevant for visibility; normals come from the field, not from the winding. + +- [ ] **Step 4: Implement the actor** + +`game/world/sites/bathymetric_terrain_actor.gd`: + +```gdscript +class_name BathymetricTerrainActor +extends StaticBody3D + +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const TERRAIN_SHADER = preload("res://game/world/sites/seabed_terrain.gdshader") + +const TILE_CELLS := 64 +const VISIBILITY_RANGE_END_M := 260.0 +const VISIBILITY_RANGE_MARGIN_M := 40.0 + +var _field: TerrainHeightField +var _material: ShaderMaterial +var _tiles_root: Node3D +var _collision: CollisionShape3D + + +func configure(field: TerrainHeightField) -> String: + if field == null: + return "terrain field is missing" + _clear() + _field = field + _material = ShaderMaterial.new() + _material.shader = TERRAIN_SHADER + _build_tiles() + _build_collision() + return "" + + +func height_field() -> TerrainHeightField: + return _field + + +func tile_count() -> int: + return 0 if _tiles_root == null else _tiles_root.get_child_count() + + +func sonar_material_at(world_position: Vector3) -> int: + return _field.material_at(world_position.x, world_position.z) + + +func sonar_reflectivity_at(world_position: Vector3) -> float: + return _field.reflectivity_for(sonar_material_at(world_position)) + + +func _clear() -> void: + if _tiles_root != null: + remove_child(_tiles_root) + _tiles_root.free() + _tiles_root = null + if _collision != null: + remove_child(_collision) + _collision.free() + _collision = null + + +func _build_tiles() -> void: + _tiles_root = Node3D.new() + _tiles_root.name = "Tiles" + add_child(_tiles_root) + var last_column := _field.columns() - 1 + var last_row := _field.rows() - 1 + var start_row := 0 + while start_row < last_row: + var end_row := mini(start_row + TILE_CELLS, last_row) + var start_column := 0 + while start_column < last_column: + var end_column := mini(start_column + TILE_CELLS, last_column) + _tiles_root.add_child(_build_tile(start_column, end_column, start_row, end_row)) + start_column = end_column + start_row = end_row + + +func _build_tile(start_column: int, end_column: int, start_row: int, end_row: int) -> MeshInstance3D: + var corner_min := _field.cell_world_position(start_column, start_row) + var corner_max := _field.cell_world_position(end_column, end_row) + var centre := Vector3( + (corner_min.x + corner_max.x) * 0.5, + 0.0, + (corner_min.z + corner_max.z) * 0.5 + ) + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var indices := PackedInt32Array() + var columns_in_tile := end_column - start_column + 1 + for row in range(start_row, end_row + 1): + for column in range(start_column, end_column + 1): + vertices.append(_field.cell_world_position(column, row) - centre) + normals.append(_field.normal_at_cell(column, row)) + for row in range(end_row - start_row): + for column in range(end_column - start_column): + var top_left := row * columns_in_tile + column + var top_right := top_left + 1 + var bottom_left := top_left + columns_in_tile + var bottom_right := bottom_left + 1 + indices.append_array([top_left, bottom_left, top_right]) + indices.append_array([top_right, bottom_left, bottom_right]) + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var instance := MeshInstance3D.new() + instance.name = "Tile_%d_%d" % [start_column / TILE_CELLS, start_row / TILE_CELLS] + instance.mesh = mesh + instance.material_override = _material + instance.position = centre + instance.visibility_range_end = VISIBILITY_RANGE_END_M + instance.visibility_range_end_margin = VISIBILITY_RANGE_MARGIN_M + instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF + return instance + + +func _build_collision() -> void: + var shape := HeightMapShape3D.new() + shape.map_width = _field.columns() + shape.map_depth = _field.rows() + var heights := PackedFloat32Array() + heights.resize(_field.columns() * _field.rows()) + for row in _field.rows(): + for column in _field.columns(): + heights[row * _field.columns() + column] = _field.elevation_at_cell(column, row) + shape.map_data = heights + _collision = CollisionShape3D.new() + _collision.name = "Collision" + _collision.shape = shape + var bounds := _field.bounds_m() + _collision.position = Vector3( + bounds.position.x + bounds.size.x * 0.5, + 0.0, + bounds.position.y + bounds.size.y * 0.5 + ) + _collision.scale = Vector3(_field.cell_size_m(), 1.0, _field.cell_size_m()) + add_child(_collision) +``` + +`HeightMapShape3D` indexes `map_data[z * map_width + x]` with `x` along local `+X` and `z` along local `+Z`, both centred on the shape origin. Row index grows with `+Z` in the field, so the two agree; the integration test proves it. + +- [ ] **Step 5: Run, expect pass** → `Site terrain integration: 12 passed, 0 failed.` If "collision height equals the field height" fails with a large error only at one side, the heightmap is flipped: check the row loop order in `_build_collision`, never patch the test tolerance. + +- [ ] **Step 6: Add to the gate**, after the foundation integration block: + +```bash +printf 'Quality gate: production site terrain integration\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-terrain-integration.log" \ + --path . \ + --script tests/integration/run_site_terrain_integration.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-terrain-integration-console.log" +check_godot_log \ + "production site terrain integration" \ + "$QUALITY_TEMP_DIRECTORY/site-terrain-integration-console.log" +``` + +- [ ] **Step 7: Commit** + +```bash +git add game/world/sites/bathymetric_terrain_actor.gd game/world/sites/seabed_terrain.gdshader tests/integration/run_site_terrain_integration.gd scripts/quality_gate.sh +git commit -m "feat(world): tiled heightfield terrain actor with per-cell sonar materials + +Co-Authored-By: Claude Fable 5.1 " +``` + +Godot creates `.uid` files next to new scripts and shaders; commit them with the scripts (the repo already tracks `.uid` files). + +--- + +### Task 8: Generic `DiveSite` scene, per-cell sonar acquisition, baked site definitions + +**Files:** +- Create: `game/world/sites/dive_site.gd`, `game/world/sites/dive_site.tscn` +- Modify: `game/world/sonar/reference_bathymetry_acquisition.gd:67-74` +- Modify: `tools/site_terrain_baker.gd` (no change), `tools/sites/vehicle_test_terrain_recipe.gd` (add `definition`), `tools/bake_site_terrain.gd` (save the definition) +- Create (generated): `content/sites/vehicle_test.tres` +- Modify: `tests/integration/run_site_terrain_integration.gd` + +**Interfaces:** +- `DiveSite extends Node3D`: `configure(definition: DiveSiteDefinition) -> String`; `definition() -> DiveSiteDefinition`; `terrain() -> BathymetricTerrainActor`; `spawn_transform() -> Transform3D`; `placeholder_count() -> int` (sonar bodies + beacons created). Scene unique names: `%Terrain` (BathymetricTerrainActor), `%Placements` (Node3D). +- Recipe scripts gain `static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition`. +- `tools/bake_site_terrain.gd` writes `res://content/sites/_terrain.res` (with `ResourceSaver.FLAG_CHANGE_PATH`) then `res://content/sites/.tres`. +- `ReferenceBathymetryAcquisition` prefers `collider.sonar_material_at(position)` / `sonar_reflectivity_at(position)` when the collider has those methods; otherwise metadata as today. + +- [ ] **Step 1: Extend the integration test (failing)** + +Add preloads to `tests/integration/run_site_terrain_integration.gd`: + +```gdscript +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const DiveSite = preload("res://game/world/sites/dive_site.gd") +const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd") +const ReferenceBathymetryAcquisition = preload( + "res://game/world/sonar/reference_bathymetry_acquisition.gd" +) +const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn") +``` + +Call `await _test_dive_site_scene()` after the actor test in `_run()`, set `TEST_COUNT := 23` (12 + 11 new `_expect` calls), and add: + +```gdscript +func _test_dive_site_scene() -> void: + var definition := load("res://content/sites/vehicle_test.tres") as DiveSiteDefinition + _expect(definition != null, "calibration site definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "calibration site definition is valid") + var started_usec := Time.get_ticks_usec() + var site := DIVE_SITE_SCENE.instantiate() as DiveSite + root.add_child(site) + _expect(site.configure(definition).is_empty(), "dive site scene accepts the definition") + var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0 + _expect(configure_ms < 3000.0, "site configures under the 3 s load budget (%.0f ms)" % configure_ms) + await physics_frame + await physics_frame + _expect(site.terrain().tile_count() > 0, "site builds its terrain tiles") + _expect(site.placeholder_count() == 2, "calibration target and beacon are placed") + var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D + _expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body") + _expect(site.spawn_transform().origin.is_equal_approx(Vector3(0.0, -12.0, 12.0)), "spawn comes from the definition") + + var sensor := Transform3D(Basis(Vector3.UP, 0.0), Vector3(0.0, -12.0, 10.0)) + sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z) + var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d()) + var request := SonarCaptureRequest.new( + &"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31 + ) + var result = acquisition.capture(request) + _expect(result.success, "reference acquisition scans the baked terrain") + var materials: Dictionary = {} + for sample in result.samples: + materials[sample.material] = true + _expect(materials.has(SonarMaterial.Value.SAND) and materials.has(SonarMaterial.Value.ROCK), "acquisition reports per-cell sand and rock") + _expect(materials.has(SonarMaterial.Value.METAL), "acquisition still reports the metal placeholder") + site.queue_free() +``` + +The `Dictionary` here is a test-local set, not a production contract. + +- [ ] **Step 2: Run, expect failure** (missing scene / resource). + +- [ ] **Step 3: Change the acquisition** + +In `game/world/sonar/reference_bathymetry_acquisition.gd`, replace lines 67-74 (`var material := ...` through the reflectivity read) with: + +```gdscript + var material := SonarMaterial.Value.ROCK + var reflectivity := 0.72 + if collider != null: + if collider.has_method("sonar_material_at"): + material = int(collider.call("sonar_material_at", world_position)) + reflectivity = float( + collider.call("sonar_reflectivity_at", world_position) + ) + else: + material = int(collider.get_meta("sonar_material", material)) + reflectivity = float( + collider.get_meta("sonar_reflectivity", reflectivity) + ) +``` + +- [ ] **Step 4: Write `dive_site.gd`** + +```gdscript +class_name DiveSite +extends Node3D + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const BathymetricTerrainActor = preload( + "res://game/world/sites/bathymetric_terrain_actor.gd" +) + +const METAL_REFLECTIVITY := 1.0 +const BEACON_COLOR := Color(0.2, 0.78, 0.9) + +@onready var _terrain: BathymetricTerrainActor = %Terrain +@onready var _placements_root: Node3D = %Placements + +var _definition: DiveSiteDefinition +var _placeholder_count := 0 + + +func configure(definition: DiveSiteDefinition) -> String: + if definition == null: + return "dive site definition is missing" + var error := definition.validation_error() + if not error.is_empty(): + return error + var terrain_error := _terrain.configure(definition.height_field()) + if not terrain_error.is_empty(): + return terrain_error + _definition = definition + for child in _placements_root.get_children(): + _placements_root.remove_child(child) + child.free() + _placeholder_count = 0 + for placement: SitePlacement in definition.placements: + _place(placement) + return "" + + +func definition() -> DiveSiteDefinition: + return _definition + + +func terrain() -> BathymetricTerrainActor: + return _terrain + + +func spawn_transform() -> Transform3D: + return _definition.spawn_transform() if _definition != null else Transform3D.IDENTITY + + +func placeholder_count() -> int: + return _placeholder_count + + +func _place(placement: SitePlacement) -> void: + if placement.is_sonar_metal(): + _placements_root.add_child(_metal_placeholder(placement)) + _placeholder_count += 1 + elif placement.kind == SitePlacement.Kind.VESSEL_ANCHOR: + _placements_root.add_child(_beacon(placement)) + _placeholder_count += 1 + + +func _metal_placeholder(placement: SitePlacement) -> StaticBody3D: + var body := StaticBody3D.new() + body.name = String(placement.placement_id) + body.position = placement.position_m + body.rotation.y = placement.yaw_radians + body.set_meta("sonar_material", SonarMaterial.Value.METAL) + body.set_meta("sonar_reflectivity", METAL_REFLECTIVITY) + var mesh := BoxMesh.new() + mesh.size = placement.footprint_m + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "Mesh" + mesh_instance.mesh = mesh + var material := StandardMaterial3D.new() + material.albedo_color = Color(0.24, 0.18, 0.08) + material.metallic = 0.88 + material.roughness = 0.27 + mesh_instance.material_override = material + body.add_child(mesh_instance) + var shape := BoxShape3D.new() + shape.size = placement.footprint_m + var collision := CollisionShape3D.new() + collision.name = "Collision" + collision.shape = shape + body.add_child(collision) + return body + + +func _beacon(placement: SitePlacement) -> MeshInstance3D: + var mesh := SphereMesh.new() + mesh.radius = 0.55 + mesh.height = 1.1 + var beacon := MeshInstance3D.new() + beacon.name = String(placement.placement_id) + beacon.mesh = mesh + beacon.position = placement.position_m + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = BEACON_COLOR + material.emission_enabled = true + material.emission = BEACON_COLOR + material.emission_energy_multiplier = 5.0 + beacon.material_override = material + var light := OmniLight3D.new() + light.name = "BeaconLight" + light.omni_range = 12.0 + light.light_color = BEACON_COLOR + light.light_energy = 4.0 + beacon.add_child(light) + return beacon +``` + +- [ ] **Step 5: Write `dive_site.tscn`** + +Before writing, run `git diff game/world/sites/vehicle_test_environment.tscn` and carry any uncommitted environment tweak (fog, light) into the sub-resource below. + +```text +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://game/world/sites/dive_site.gd" id="1_site"] +[ext_resource type="Script" path="res://game/world/sites/bathymetric_terrain_actor.gd" id="2_terrain"] + +[sub_resource type="Environment" id="EnvironmentUnderwater"] +background_mode = 1 +background_color = Color(0.005, 0.045, 0.065, 1) +ambient_light_source = 3 +ambient_light_color = Color(0.12, 0.32, 0.37, 1) +ambient_light_energy = 0.42 +glow_enabled = true +glow_levels/1 = 1.0 +glow_levels/2 = 1.0 +glow_levels/3 = 0.6 +glow_levels/4 = 0.2 +glow_intensity = 1.0 +glow_blend_mode = 0 +glow_hdr_threshold = 1.0 +fog_enabled = true +fog_light_color = Color(0.07, 0.25, 0.3, 1) +fog_light_energy = 0.48 +fog_density = 0.014 +fog_height = -4.0 +fog_height_density = 0.025 +volumetric_fog_enabled = true +volumetric_fog_density = 0.025 +volumetric_fog_length = 72.0 + +[node name="DiveSite" type="Node3D"] +script = ExtResource("1_site") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("EnvironmentUnderwater") + +[node name="AmbientDirection" type="DirectionalLight3D" parent="."] +rotation_degrees = Vector3(-52, -24, 0) +light_color = Color(0.39, 0.65, 0.7, 1) +light_energy = 0.38 +shadow_enabled = true + +[node name="Terrain" type="StaticBody3D" parent="."] +unique_name_in_owner = true +script = ExtResource("2_terrain") + +[node name="Placements" type="Node3D" parent="."] +unique_name_in_owner = true +``` + +- [ ] **Step 6: Add `definition()` to the calibration recipe and save it from the baker** + +Append to `tools/sites/vehicle_test_terrain_recipe.gd`: + +```gdscript +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var field := TerrainHeightField.from_data(terrain) + var result := DiveSiteDefinition.new() + result.site_id = &"vehicle_test" + result.display_name = "Site de calibration du véhicule" + result.terrain = terrain + result.playable_bounds_m = Rect2(-90.0, -176.0, 180.0, 204.0) + result.spawn_position_m = Vector3(0.0, -12.0, 12.0) + result.spawn_yaw_radians = 0.0 + var target := SitePlacement.new() + target.placement_id = &"calibration_target" + target.kind = SitePlacement.Kind.WRECK_DECOY + target.position_m = Vector3(0.0, field.elevation_at(0.0, -58.0) + 2.5, -58.0) + target.footprint_m = Vector3(8.0, 5.0, 7.0) + var anchor := SitePlacement.new() + anchor.placement_id = &"navigation_beacon" + anchor.kind = SitePlacement.Kind.VESSEL_ANCHOR + anchor.position_m = Vector3(0.0, -15.0, -22.0) + result.placements = [target, anchor] + return result +``` + +(The `const` lines go at the top of the file with the existing preload.) + +In `tools/bake_site_terrain.gd`, change the terrain save to `ResourceSaver.save(data, output_path, ResourceSaver.FLAG_CHANGE_PATH)` and, after the preview is written, add: + +```gdscript + var definition = recipe_script.definition(data) + var definition_error: String = definition.validation_error() + if not definition_error.is_empty(): + push_error("recipe %s definition is invalid: %s" % [name, definition_error]) + quit(1) + return + var definition_path := "%s/%s.tres" % [OUTPUT_DIRECTORY, name] + var definition_save_error := ResourceSaver.save(definition, definition_path) + if definition_save_error != OK: + push_error("cannot save %s (%d)" % [definition_path, definition_save_error]) + quit(1) + return +``` + +Re-bake: + +```bash +"$GODOT_BIN" --headless --path . --script tools/bake_site_terrain.gd -- vehicle_test +``` + +Expected: `content/sites/vehicle_test.tres` exists and its text contains `[ext_resource type="Resource" path="res://content/sites/vehicle_test_terrain.res"` and two `[sub_resource type="Resource"` placement blocks. If the terrain was embedded as a sub-resource instead, the `FLAG_CHANGE_PATH` is missing. Site definition paths are always `res://content/sites/.tres`; zone 1 will be `res://content/sites/zone1_vigilant.tres`. + +- [ ] **Step 7: Run the integration test, expect pass** → `Site terrain integration: 23 passed, 0 failed.` Then `"$GODOT_BIN" --headless --path . --script tests/run_load_checks.gd` (the `.tres`, `.res`, scene and tool scripts all load) and `bash scripts/check_architecture.sh`. + +- [ ] **Step 8: Commit** + +```bash +git add game/world/sites/dive_site.gd game/world/sites/dive_site.tscn game/world/sites/*.uid game/world/sonar/reference_bathymetry_acquisition.gd tools content/sites tests/integration/run_site_terrain_integration.gd docs/briefs/captures +git commit -m "feat(world): generic dive site scene fed by a baked site definition + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 9: Composition loads a selected site; bounds warning on the HUD + +**Files:** +- Create: `game/composition/vertical_slice/dive_world.tscn` +- Delete: `game/composition/vertical_slice/vehicle_test_world.tscn`, `game/world/sites/vehicle_test_environment.gd`, `game/world/sites/vehicle_test_environment.tscn` (+ their `.uid`) +- Modify: `game/composition/vertical_slice/vertical_slice_root.gd` +- Modify: `game/presentation/cockpit/cockpit_hud.gd`, `game/presentation/cockpit/cockpit_hud.tscn` +- Modify: `tests/integration/run_vertical_slice_foundation.gd` + +**Interfaces:** +- `VerticalSliceRoot`: `const DEFAULT_SITE_PATH := "res://content/sites/vehicle_test.tres"` (flipped to zone 1 in Task 11); `select_dive_site(definition_path: String) -> void` (takes effect at the next `Nouvelle partie`); `dive_site() -> DiveSite`; `latest_bounds_status() -> PlayableBoundsStatus`. +- `CockpitHud.present_bounds(status: PlayableBoundsStatus) -> void`; `bounds_warning_visible() -> bool`. Scene nodes `%BoundsPanel`, `%BoundsLabel`. +- `dive_world.tscn` unique names: `%DiveSite`, `%SubmarineActor`, `%CockpitInterior`, `%CockpitCameraRig`. + +- [ ] **Step 1: Extend the foundation test (failing)** + +In `tests/integration/run_vertical_slice_foundation.gd`: +- add `const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd")`; +- in `_test_vehicle_slice`, immediately before `new_game_button.pressed.emit()`, add `composition.select_dive_site("res://content/sites/vehicle_test.tres")`; +- after `_expect(composition.latest_update() != null, ...)` add: + +```gdscript + _expect(composition.dive_site() != null, "composition instantiates the selected dive site") + _expect( + composition.dive_site() != null + and composition.dive_site().definition().site_id == &"vehicle_test", + "selected site definition drives the world" + ) + _expect( + actor != null and actor.global_position.is_equal_approx(Vector3(0.0, -12.0, 12.0)), + "submarine spawns at the site spawn point" + ) + _expect( + composition.latest_bounds_status() != null + and composition.latest_bounds_status().state == PlayableBoundsStatus.State.INSIDE, + "spawn is inside the playable bounds" + ) + _expect(hud != null and not hud.bounds_warning_visible(), "no bounds warning inside the zone") +``` + +Set `TEST_COUNT := 96` (91 + 5). + +- [ ] **Step 2: Run, expect failure** (`select_dive_site` undefined): + +```bash +"$GODOT_BIN" --headless --path . --script tests/integration/run_vertical_slice_foundation.gd +``` + +- [ ] **Step 3: Write `dive_world.tscn`** + +```text +[gd_scene load_steps=7 format=3] + +[ext_resource type="PackedScene" path="res://game/world/sites/dive_site.tscn" id="1_site"] +[ext_resource type="PackedScene" path="res://game/world/submarine/submarine_actor.tscn" id="2_submarine"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_camera_rig.tscn" id="3_camera"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_interior.tscn" id="4_cockpit"] + +[sub_resource type="ParticleProcessMaterial" id="SuspendedMotion"] +emission_shape = 3 +emission_box_extents = Vector3(45, 28, 70) +direction = Vector3(0.1, 0.04, 0) +spread = 180.0 +initial_velocity_min = 0.02 +initial_velocity_max = 0.09 +gravity = Vector3(0, 0, 0) +scale_min = 0.025 +scale_max = 0.08 + +[sub_resource type="SphereMesh" id="SuspendedParticleMesh"] +radius = 0.035 +height = 0.07 + +[node name="DiveWorld" type="Node3D"] + +[node name="DiveSite" parent="." instance=ExtResource("1_site")] +unique_name_in_owner = true + +[node name="SubmarineActor" parent="." instance=ExtResource("2_submarine")] +unique_name_in_owner = true + +[node name="SuspendedParticles" type="GPUParticles3D" parent="SubmarineActor"] +amount = 360 +lifetime = 14.0 +preprocess = 14.0 +visibility_aabb = AABB(-50, -35, -78, 100, 70, 156) +process_material = SubResource("SuspendedMotion") +draw_pass_1 = SubResource("SuspendedParticleMesh") + +[node name="CockpitInterior" parent="SubmarineActor" instance=ExtResource("4_cockpit")] +unique_name_in_owner = true + +[node name="CockpitCameraRig" parent="SubmarineActor" instance=ExtResource("3_camera")] +unique_name_in_owner = true +``` + +`emission_shape = 3` is `EMISSION_SHAPE_BOX`. Particles are emitted in world space (`local_coords` default false), so they stay put while the submarine moves through them. + +- [ ] **Step 4: Update `vertical_slice_root.gd`** + +Add preloads after `ReferenceBathymetryAcquisition`: + +```gdscript +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const DiveSite = preload("res://game/world/sites/dive_site.gd") +const PlayableBoundsRule = preload("res://game/application/sites/playable_bounds_rule.gd") +const PlayableBoundsStatus = preload( + "res://game/application/sites/playable_bounds_status.gd" +) +``` + +Replace `VEHICLE_TEST_WORLD_SCENE_PATH` with: + +```gdscript +const DIVE_WORLD_SCENE_PATH := "res://game/composition/vertical_slice/dive_world.tscn" +const DEFAULT_SITE_PATH := "res://content/sites/vehicle_test.tres" +``` + +Add state next to `_active_world`: + +```gdscript +var _selected_site_path := DEFAULT_SITE_PATH +var _dive_site: DiveSite +var _bounds_rule: PlayableBoundsRule +var _bounds_status: PlayableBoundsStatus +``` + +In `_start_vehicle_test()` (rename it `_start_dive` and update the `new_game_requested.connect` call in `_show_main_menu`), replace the block from `var world_scene := load(VEHICLE_TEST_WORLD_SCENE_PATH)` through `_instrument_panel = ...` with: + +```gdscript + var definition := load(_selected_site_path) as DiveSiteDefinition + if definition == null: + push_error("dive site definition %s cannot be loaded" % _selected_site_path) + return + var definition_error := definition.validation_error() + if not definition_error.is_empty(): + push_error("dive site %s is invalid: %s" % [_selected_site_path, definition_error]) + return + var world_scene := load(DIVE_WORLD_SCENE_PATH) as PackedScene + if world_scene == null: + return + var world := world_scene.instantiate() as Node3D + _replace_active_world(world) + _dive_site = world.get_node("%DiveSite") as DiveSite + var site_error := _dive_site.configure(definition) + if not site_error.is_empty(): + push_error("dive site %s cannot be built: %s" % [_selected_site_path, site_error]) + _show_main_menu() + return + _actor = world.get_node("%SubmarineActor") as SubmarineActor + _actor.global_transform = _dive_site.spawn_transform() + _bounds_rule = PlayableBoundsRule.new(definition.playable_bounds_m) + _bounds_status = _bounds_rule.evaluate(_actor.global_position) + _camera_controller = world.get_node("%CockpitCameraRig") as CockpitCameraController + _instrument_panel = world.get_node("%CockpitInterior") as CockpitInstrumentPanel +``` + +After `_hud.present(_latest_update, initial_telemetry)` in the same function add `_hud.present_bounds(_bounds_status)`. + +In `_physics_process`, after `_hud.present(_latest_update, telemetry)` add: + +```gdscript + _bounds_status = _bounds_rule.evaluate(_actor.global_position) + _hud.present_bounds(_bounds_status) +``` + +In `_show_main_menu`, reset `_dive_site = null`, `_bounds_rule = null`, `_bounds_status = null` next to `_actor = null`. + +Add accessors next to `sonar_display()`: + +```gdscript +func select_dive_site(definition_path: String) -> void: + _selected_site_path = definition_path + + +func dive_site() -> DiveSite: + return _dive_site + + +func latest_bounds_status() -> PlayableBoundsStatus: + return _bounds_status +``` + +- [ ] **Step 5: HUD panel** + +In `cockpit_hud.tscn`, after the `AlertLabel` node, add: + +```text +[node name="BoundsPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_left = 560.0 +offset_top = 88.0 +offset_right = -560.0 +offset_bottom = 136.0 +grow_horizontal = 2 +theme_override_styles/panel = SubResource("AlertStyle") + +[node name="BoundsLabel" type="Label" parent="BoundsPanel"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.82, 0.42, 1) +theme_override_font_sizes/font_size = 18 +text = "LIMITE DE ZONE" +horizontal_alignment = 1 +``` + +In `cockpit_hud.gd` add the preload `const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd")`, the `@onready var _bounds_panel: PanelContainer = %BoundsPanel` / `_bounds_label: Label = %BoundsLabel`, and: + +```gdscript +func present_bounds(status: PlayableBoundsStatus) -> void: + if status == null or not status.requires_warning(): + _bounds_panel.visible = false + return + _bounds_panel.visible = true + if status.state == PlayableBoundsStatus.State.OUTSIDE: + _bounds_label.text = tr("HORS ZONE · FAITES DEMI-TOUR") + else: + _bounds_label.text = "%s · %d m" % [tr("LIMITE DE ZONE"), int(status.distance_to_edge_m)] + + +func bounds_warning_visible() -> bool: + return _bounds_panel.visible +``` + +Presentation imports an application type (`PlayableBoundsStatus`) only; that is allowed. + +- [ ] **Step 6: Delete the old environment and world** + +```bash +git rm game/composition/vertical_slice/vehicle_test_world.tscn game/world/sites/vehicle_test_environment.gd game/world/sites/vehicle_test_environment.gd.uid game/world/sites/vehicle_test_environment.tscn +``` + +If `git rm` refuses because `vehicle_test_environment.tscn` has local modifications, that is the other session's work: inspect `git diff` on it, port what matters into `dive_site.tscn` (Task 8 Step 5), then `git rm -f`. + +`rg -n "vehicle_test_environment|vehicle_test_world|VehicleTestEnvironment" game tests docs` must return only historical mentions in `docs/` (acceptable) and none in `game/` or `tests/`. + +- [ ] **Step 7: Run, expect pass** + +```bash +"$GODOT_BIN" --headless --path . --script tests/integration/run_vertical_slice_foundation.gd +``` +Expected: `Vertical slice foundation integration: 96 passed, 0 failed.` The existing assertions `observed_count() > 1000` and `contacts.size() >= 1` keep passing because the calibration placeholder is a metal body in front of the spawn. + +Run also: `"$GODOT_BIN" --headless --path . --script tests/run_load_checks.gd` and `bash scripts/check_architecture.sh`. + +- [ ] **Step 8: Commit** + +```bash +git add -A game/composition game/presentation/cockpit/cockpit_hud.gd game/presentation/cockpit/cockpit_hud.tscn game/world/sites tests/integration/run_vertical_slice_foundation.gd +git commit -m "feat(composition): dive world assembled from a selected site definition + +Co-Authored-By: Claude Fable 5.1 " +``` + +Only stage the cockpit HUD files from `game/presentation/cockpit/`; the other cockpit files there belong to the other session. + +--- + +### Task 10: Zone 1 recipe « Banc du Vigilant » and content contract tests + +**Files:** +- Create: `tools/sites/zone1_vigilant_recipe.gd` +- Create (generated): `content/sites/zone1_vigilant_terrain.res`, `content/sites/zone1_vigilant.tres`, `docs/briefs/captures/zone1_vigilant_terrain_preview.png` +- Create: `tests/contract/run_site_content_tests.gd` +- Modify: `scripts/quality_gate.sh` + +**Interfaces:** +- Placement ids (StringName) used by tests: `&"prospection_vessel"` (WRECK_TARGET), `&"decoy_yacht"`, `&"decoy_coaster"` (WRECK_DECOY), `&"secondary_item"` (SECONDARY_ITEM), `&"debris_1"`..`&"debris_4"` (DEBRIS), `&"vessel_anchor"` (VESSEL_ANCHOR), landmarks `&"echine"`, `&"deux_freres"`, `&"rides"`, `&"saignee_entrance"`, `&"rupture"`. +- Route ids: `&"safe_ridge"` (maximum_depth 32), `&"risky_groove"` (maximum_depth 48). + +- [ ] **Step 1: Write the failing contract suite** + +`tests/contract/run_site_content_tests.gd`: + +```gdscript +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") +const DiveDifficultyProfile = preload("res://game/domain/rules/dive_difficulty_profile.gd") +const DiveUpgradeEffects = preload("res://game/domain/rules/dive_upgrade_effects.gd") +const DiveAccessibilityAdjustments = preload( + "res://game/domain/rules/dive_accessibility_adjustments.gd" +) +const DiveRulesResolver = preload("res://game/domain/rules/dive_rules_resolver.gd") + +const SITE_PATH := "res://content/sites/zone1_vigilant.tres" +const ROUTE_CLEARANCE_M := 3.0 +const MINIMUM_LANDMARK_SPACING_M := 60.0 +const TEST_COUNT := 30 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_zone1_content() + + if _failures.is_empty(): + print("Site content contract tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site content contract tests: %d failed." % _failures.size()) + quit(1) + + +func _test_zone1_content() -> void: + var definition := load(SITE_PATH) as DiveSiteDefinition + _expect(definition != null, "zone 1 definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "zone 1 definition is valid: %s" % definition.validation_error()) + var field := definition.height_field() + if field == null: + return + var rules = DiveRulesResolver.new().resolve( + DiveDifficultyProfile.standard(), + DiveUpgradeEffects.new(), + DiveAccessibilityAdjustments.new() + ) + var certified_m: float = rules.certified_depth_m + var critical_m: float = rules.critical_depth_m + + _expect(definition.playable_bounds_m.is_equal_approx(Rect2(-300.0, -300.0, 600.0, 600.0)), "playable zone is 600 x 600 m") + _expect(field.minimum_elevation_m() >= -88.5 and field.maximum_elevation_m() <= -19.5, "terrain stays between 20 m and 88 m deep") + _expect(field.cell_size_m() == 1.5 and field.columns() == 513 and field.rows() == 513, "terrain grid is 513 x 513 cells of 1.5 m") + + var targets := definition.placements_of_kind(SitePlacement.Kind.WRECK_TARGET) + var decoys := definition.placements_of_kind(SitePlacement.Kind.WRECK_DECOY) + var items := definition.placements_of_kind(SitePlacement.Kind.SECONDARY_ITEM) + var anchors := definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR) + var debris := definition.placements_of_kind(SitePlacement.Kind.DEBRIS) + var landmarks := definition.placements_of_kind(SitePlacement.Kind.LANDMARK) + _expect(targets.size() == 1, "exactly one target wreck") + _expect(decoys.size() == 2, "exactly two decoy wrecks") + _expect(items.size() == 1, "exactly one secondary item") + _expect(anchors.size() == 1, "exactly one vessel anchor") + _expect(debris.size() >= 3, "a debris trail of at least three pieces") + _expect(landmarks.size() >= 4, "at least four named landmarks") + + for wreck: SitePlacement in targets + decoys: + _expect(-wreck.position_m.y <= certified_m, "%s lies above the certified depth" % wreck.placement_id) + _expect(_rests_on_terrain(field, wreck), "%s rests on or in the seabed" % wreck.placement_id) + if items.size() == 1: + var item_depth := -items[0].position_m.y + _expect(item_depth > certified_m and item_depth <= critical_m, "secondary item is between 100 %% and 115 %% of the certified depth (%.1f m)" % item_depth) + _expect(_rests_on_terrain(field, items[0]), "secondary item rests on the seabed") + for piece: SitePlacement in debris: + if -piece.position_m.y < 45.0 or -piece.position_m.y > 66.0 or not _rests_on_terrain(field, piece): + _failures.append("debris %s is off the slope or floating" % piece.placement_id) + _expect(true, "debris pieces checked individually") + + if anchors.size() == 1: + var seabed_under_anchor := -field.elevation_at(anchors[0].position_m.x, anchors[0].position_m.z) + _expect(seabed_under_anchor >= 20.0 and seabed_under_anchor <= 30.0, "vessel anchors over 20-30 m of seabed") + var spawn := definition.spawn_position_m + _expect(spawn.y - field.elevation_at(spawn.x, spawn.z) >= 8.0, "spawn has at least 8 m of water under the keel") + _expect(is_equal_approx(-spawn.y, 10.0), "spawn is 10 m deep") + + var safe := definition.route(&"safe_ridge") + var risky := definition.route(&"risky_groove") + _expect(safe != null and safe.maximum_depth_m == 32.0, "safe route is declared with a 32 m ceiling") + _expect(risky != null and risky.maximum_depth_m == 48.0, "risky route is declared with a 48 m ceiling") + if safe != null and risky != null and targets.size() == 1: + _expect(_route_clears_terrain(field, safe), "safe route clears the terrain by %.0f m" % ROUTE_CLEARANCE_M) + _expect(_route_clears_terrain(field, risky), "risky route clears the terrain by %.0f m" % ROUTE_CLEARANCE_M) + var target := targets[0].position_m + _expect( + _xz_distance(safe.waypoints_m[-1], target) <= 25.0 and _xz_distance(risky.waypoints_m[-1], target) <= 25.0, + "both routes end within 25 m of the target" + ) + _expect(_route_length(risky) < _route_length(safe) * 0.9, "risky route is shorter than the safe route") + + var spaced := true + for i in landmarks.size(): + for j in range(i + 1, landmarks.size()): + if _xz_distance(landmarks[i].position_m, landmarks[j].position_m) < MINIMUM_LANDMARK_SPACING_M: + spaced = false + _expect(spaced, "landmarks are at least 60 m apart") + + +func _rests_on_terrain(field, placement: SitePlacement) -> bool: + var seabed := field.elevation_at(placement.position_m.x, placement.position_m.z) + var bottom := placement.position_m.y - placement.footprint_m.y * 0.5 + return bottom >= seabed - placement.footprint_m.y and bottom <= seabed + 1.0 + + +func _route_clears_terrain(field, route: SiteRoute) -> bool: + for i in range(route.waypoints_m.size() - 1): + var a := route.waypoints_m[i] + var b := route.waypoints_m[i + 1] + var steps := maxi(int(ceilf(a.distance_to(b) / 2.0)), 1) + for step in range(steps + 1): + var point := a.lerp(b, float(step) / float(steps)) + if point.y - field.elevation_at(point.x, point.z) < ROUTE_CLEARANCE_M: + return false + return true + + +func _route_length(route: SiteRoute) -> float: + var length := 0.0 + for i in range(route.waypoints_m.size() - 1): + length += route.waypoints_m[i].distance_to(route.waypoints_m[i + 1]) + return length + + +func _xz_distance(a: Vector3, b: Vector3) -> float: + return Vector2(a.x, a.z).distance_to(Vector2(b.x, b.z)) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +`TEST_COUNT` is the number of `_expect` calls reached when everything passes: 3 (load, valid, bounds) + 2 (depth range, grid) + 6 (kinds) + 6 (two per wreck, three wrecks) + 2 (item) + 1 (debris) + 1 (anchor) + 2 (spawn) + 2 (route declarations) + 4 (route checks) + 1 (landmarks) = 30. Per-debris failures append directly to `_failures` and do not change the count. + +- [ ] **Step 2: Run, expect failure** (missing `.tres`): + +```bash +"$GODOT_BIN" --headless --path . --script tests/contract/run_site_content_tests.gd +``` + +- [ ] **Step 3: Write the zone 1 recipe** + +`tools/sites/zone1_vigilant_recipe.gd`. Coordinates: `+X` east, `+Z` south; the vessel is north (`z = -250`), the shelf break is the line `z = 125`, the drop-off is south of it. + +```gdscript +class_name Zone1VigilantRecipe +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") + +# Groove axis (la Saignée): north entrance to the shelf break. +const GROOVE_START := Vector2(-60.0, -140.0) +const GROOVE_END := Vector2(-10.0, 120.0) +# Ridge axis (l'Échine). Starts south of the vessel so its crest never rises above 20 m. +const RIDGE_START := Vector2(70.0, -150.0) +const RIDGE_END := Vector2(80.0, 90.0) +const BREAK_Z := 125.0 + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 513 + result.rows = 513 + result.cell_size_m = 1.5 + result.origin_x_m = -384.0 + result.origin_z_m = -384.0 + result.floor_elevation_m = -88.0 + result.rock_slope_degrees = 20.0 + result.rock_reflectivity = 0.72 + result.sand_reflectivity = 0.45 + result.primitives = [ + # Plateau: 24 m deep at the vessel (z = -300), 6 m deeper over 400 m toward the south. + SiteTerrainBaker.Plane.new(24.0, 0.0, -0.015, 0.0, -300.0), + # L'Échine: long north-south rock ridge, 6 m high, flanks steeper than 20°. + SiteTerrainBaker.Ridge.new(RIDGE_START, RIDGE_END, 12.0, 6.0, 25.0), + # Les Deux Frères: twin pinnacles, 20 m apart, 6 m tall (crests stay below 20 m deep). + SiteTerrainBaker.Blob.new(Vector2(-160.0, -130.0), 8.4, 8.4, 6.0), + SiteTerrainBaker.Blob.new(Vector2(-142.0, -121.0), 8.4, 8.4, 6.0), + # La Saignée: 16 m wide groove, 9 m deep, steep walls, the risky route. + SiteTerrainBaker.Groove.new(GROOVE_START, GROOVE_END, 8.0, 9.0, 4.0), + # Basin and patch reef for the steel coaster decoy, east of the ridge. + SiteTerrainBaker.Blob.new(Vector2(140.0, 40.0), 30.0, 30.0, -8.0), + SiteTerrainBaker.Blob.new(Vector2(125.0, 30.0), 7.7, 7.7, 2.5), + SiteTerrainBaker.Blob.new(Vector2(155.0, 55.0), 7.7, 7.7, 2.5), + SiteTerrainBaker.Blob.new(Vector2(135.0, 60.0), 7.7, 7.7, 2.5), + # Shelf break (la rupture) then the drop-off (le tombant), clamped at -88 m. + SiteTerrainBaker.Step.new(Vector2(0.0, BREAK_Z), Vector2(0.0, 1.0), 16.0, 10.0, 0.7), + # Scree cones at the foot of the drop-off. + SiteTerrainBaker.Blob.new(Vector2(-40.0, 200.0), 6.3, 6.3, 2.0), + SiteTerrainBaker.Blob.new(Vector2(-5.0, 205.0), 6.3, 6.3, 2.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, 198.0), 6.3, 6.3, 2.0), + # Les Rides: sand ripples near the vessel only. + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.35), 9.0, 0.45, Vector2(0.0, -220.0), 90.0), + # Deterministic roughness everywhere. + SiteTerrainBaker.Roughness.new(1933, 0.02, 0.6), + ] + result.material_patches = [ + SiteTerrainBaker.MaterialPatch.new(Vector2(140.0, 40.0), 28.0, SonarMaterial.Value.ROCK), + ] + return result + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var field := TerrainHeightField.from_data(terrain) + var result := DiveSiteDefinition.new() + result.site_id = &"zone1_vigilant" + result.display_name = "Banc du Vigilant" + result.terrain = terrain + result.playable_bounds_m = Rect2(-300.0, -300.0, 600.0, 600.0) + result.spawn_position_m = Vector3(0.0, -10.0, -250.0) + result.spawn_yaw_radians = 0.0 + result.placements = [ + _on_seabed(field, &"prospection_vessel", SitePlacement.Kind.WRECK_TARGET, + Vector2(-12.0, 108.0), 0.35, Vector3(28.0, 8.0, 9.0), 2.0), + _on_seabed(field, &"decoy_yacht", SitePlacement.Kind.WRECK_DECOY, + Vector2(-40.0, -200.0), 1.1, Vector3(12.0, 3.0, 4.0), 0.5), + _on_seabed(field, &"decoy_coaster", SitePlacement.Kind.WRECK_DECOY, + Vector2(140.0, 40.0), -0.6, Vector3(26.0, 6.0, 8.0), 1.2), + _on_seabed(field, &"secondary_item", SitePlacement.Kind.SECONDARY_ITEM, + Vector2(-6.0, 157.0), 0.0, Vector3(1.0, 1.0, 1.0), 0.0), + _on_seabed(field, &"debris_1", SitePlacement.Kind.DEBRIS, + Vector2(-12.0, 134.0), 0.4, Vector3(3.0, 2.0, 2.0), 0.3), + _on_seabed(field, &"debris_2", SitePlacement.Kind.DEBRIS, + Vector2(-10.0, 139.0), 1.2, Vector3(6.0, 2.5, 2.5), 0.4), + _on_seabed(field, &"debris_3", SitePlacement.Kind.DEBRIS, + Vector2(-9.0, 146.0), 0.0, Vector3(2.0, 1.5, 2.0), 0.2), + _on_seabed(field, &"debris_4", SitePlacement.Kind.DEBRIS, + Vector2(-7.0, 152.0), 2.0, Vector3(4.0, 1.5, 1.5), 0.3), + _marker(&"vessel_anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(0.0, -14.0, -250.0)), + _marker(&"echine", SitePlacement.Kind.LANDMARK, Vector3(75.0, -20.0, -50.0)), + _marker(&"deux_freres", SitePlacement.Kind.LANDMARK, Vector3(-151.0, -18.0, -125.0)), + _marker(&"rides", SitePlacement.Kind.LANDMARK, Vector3(0.0, -25.0, -230.0)), + _marker(&"saignee_entrance", SitePlacement.Kind.LANDMARK, Vector3(-60.0, -34.0, -140.0)), + _marker(&"rupture", SitePlacement.Kind.LANDMARK, Vector3(40.0, -40.0, 125.0)), + ] + result.routes = [ + _route(&"safe_ridge", 32.0, [ + Vector3(0.0, -14.0, -250.0), Vector3(70.0, -16.0, -200.0), Vector3(76.0, -16.0, -100.0), + Vector3(78.0, -16.0, 0.0), Vector3(76.0, -18.0, 80.0), Vector3(40.0, -24.0, 105.0), + Vector3(0.0, -26.0, 100.0), Vector3(-14.0, -30.0, 98.0), + ]), + _route(&"risky_groove", 48.0, [ + Vector3(0.0, -14.0, -250.0), Vector3(-60.0, -20.0, -150.0), Vector3(-50.0, -31.0, -100.0), + Vector3(-35.0, -33.0, -20.0), Vector3(-22.0, -34.0, 60.0), Vector3(-14.0, -33.0, 98.0), + ]), + ] + return result + + +static func _on_seabed( + field: TerrainHeightField, + id: StringName, + kind: SitePlacement.Kind, + xz: Vector2, + yaw_radians: float, + footprint_m: Vector3, + burial_m: float +) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = id + placement.kind = kind + placement.footprint_m = footprint_m + placement.yaw_radians = yaw_radians + var seabed := field.elevation_at(xz.x, xz.y) + placement.position_m = Vector3(xz.x, seabed + footprint_m.y * 0.5 - burial_m, xz.y) + return placement + + +static func _marker(id: StringName, kind: SitePlacement.Kind, position_m: Vector3) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = id + placement.kind = kind + placement.position_m = position_m + return placement + + +static func _route(id: StringName, maximum_depth_m: float, waypoints: Array) -> SiteRoute: + var route := SiteRoute.new() + route.route_id = id + route.maximum_depth_m = maximum_depth_m + route.waypoints_m = PackedVector3Array(waypoints) + return route +``` + +Expected values from this recipe (check against the bake log and the contract test): plateau ≈ 24.75 m at the vessel; ridge and pinnacle crests ≈ 20.5–22.5 m (never shallower than 20 m, roughness ± 0.6 m included); yacht ≈ 25.5 m; coaster basin ≈ 36.8 m (box 6 m tall, top ≈ 32 m); groove floor at the target ≈ 39 m (hull 8 m tall, 2 m buried, top ≈ 33 m); secondary item ≈ 62 m; debris ≈ 45–59 m; drop-off reaches 88 m at `z ≈ 195`. Route lengths ≈ 430 m (safe) and ≈ 370 m (risky): shorter, darker and deeper, not half the length as the brief first estimated; Task 11 corrects the brief. + +- [ ] **Step 4: Bake zone 1** + +```bash +"$GODOT_BIN" --headless --path . --script tools/bake_site_terrain.gd -- zone1_vigilant +``` +Expected: `Baked zone1_vigilant: 513 x 513 cells, elevation -88.0 .. -2x.x m, rock xx %` plus `content/sites/zone1_vigilant_terrain.res`, `content/sites/zone1_vigilant.tres`, `docs/briefs/captures/zone1_vigilant_terrain_preview.png`. Open the preview: ripples top-centre, a bright ridge running down the right-centre, two bright dots left, a dark diagonal groove, a dark band across the lower third (the drop-off), a reddish patch right-centre (reef). If the preview is mirrored top/bottom, the baker's row/`z` mapping is wrong; fix the baker, not the recipe. + +- [ ] **Step 5: Run the contract suite, expect pass** → `Site content contract tests: 32 passed, 0 failed.` + +If a route clearance or burial check fails, adjust the failing waypoint `y` (keep safe ≤ 32 m, risky ≤ 48 m) or the `burial_m`, re-bake, re-run. The recipe is the authored document; never edit the `.tres` by hand. + +- [ ] **Step 6: Add to the gate**, after the site baker block: + +```bash +printf 'Quality gate: site content contract tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-content-contract.log" \ + --path . \ + --script tests/contract/run_site_content_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-content-contract-console.log" +check_godot_log "site content contract tests" "$QUALITY_TEMP_DIRECTORY/site-content-contract-console.log" +``` + +- [ ] **Step 7: Commit** + +```bash +git add tools/sites/zone1_vigilant_recipe.gd tools/sites/*.uid content/sites tests/contract scripts/quality_gate.sh docs/briefs/captures +git commit -m "feat(content): bake the Banc du Vigilant site and its contract tests + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 11: Zone 1 becomes the default dive, documentation, full gate + +**Files:** +- Modify: `game/composition/vertical_slice/vertical_slice_root.gd` (`DEFAULT_SITE_PATH`) +- Modify: `tests/README.md`, `content/README.md`, `docs/VERTICAL_SLICE_TECHNICAL_PLAN.md`, `docs/briefs/terrain-site-zone1-gameplay.md` + +- [ ] **Step 1: Flip the default** + +```gdscript +const DEFAULT_SITE_PATH := "res://content/sites/zone1_vigilant.tres" +``` + +The foundation test keeps selecting the calibration site explicitly, so it is unaffected. + +- [ ] **Step 2: Run the whole gate** + +```bash +GODOT_BIN="$GODOT_BIN" bash scripts/quality_gate.sh +``` +Expected: ends with `Quality gate: passed.` and no `ERROR`, `SCRIPT ERROR` or `WARNING` lines. Fix causes, never checks. + +- [ ] **Step 3: Play it once** + +```bash +"$GODOT_BIN" --path . +``` +`Nouvelle partie`: the submarine hangs 10 m deep under the beacon, seabed ≈ 15 m below, sand ripples visible in the lights. `F` scans: ripples read as regular waves, no metal contact at the start. Heading south (`W`), the ridge appears to the left-front as a long bright crest on the sonar; the groove entrance is a dark trench to the right-front. Note the frame time in the debugger: 60 FPS at 1280 × 720 on the development machine is the budget; if tiles are too costly, lower `VISIBILITY_RANGE_END_M` before anything else. Take one screenshot of the sonar over the groove and save it as `docs/briefs/captures/zone1_vigilant_sonar_groove.png`. + +- [ ] **Step 4: Documentation** + +`tests/README.md`: add the four suites to "Suites de production actuelles": + +```markdown +- `unit/run_site_domain_tests.gd` : données de terrain, champ de hauteur, + définition de site et ses placements/routes ; +- `unit/run_site_application_tests.gd` : règle de limites jouables ; +- `unit/run_site_baker_tests.gd` : primitives et déterminisme de la cuisson ; +- `contract/run_site_content_tests.gd` : règles de contenu du Banc du Vigilant + (profondeurs, routes, amers) ; +- `integration/run_site_terrain_integration.gd` : collision égale au champ, + matériaux sonar par cellule, scène de site et budget de chargement. +``` + +`content/README.md`: append + +```markdown +## Sites + +`sites/.tres` est une `DiveSiteDefinition` ; `sites/_terrain.res` +est son `SiteTerrainData`. Les deux sont produits par +`tools/bake_site_terrain.gd -- ` depuis `tools/sites/_recipe.gd`, +la recette étant le document d'auteur (ADR 0012). Ne pas éditer les fichiers +produits à la main. +``` + +`docs/VERTICAL_SLICE_TECHNICAL_PLAN.md`: in "Tranche 3 — site et découverte", add before the bullet list: + +```markdown +État au 2026-10-06 : terrain du site implémenté. Le Banc du Vigilant +(`content/sites/zone1_vigilant.tres`) est chargé par `Nouvelle partie` ; ses +placements d'épaves sont encore des volumes graybox métalliques. Voir +`docs/briefs/terrain-site-zone1-gameplay.md` et l'ADR 0012. +``` + +and in section 16 add item 8: `8. le terrain de site en champ de hauteur authoré, formalisé dans docs/adr/0012-authored-heightfield-dive-sites.md.` + +`docs/briefs/terrain-site-zone1-gameplay.md`: in §8 replace the heightmap/PNG wording in the table row and the paragraph after it with: terrain stored as `SiteTerrainData` `.res` (float32 elevations + byte materials) produced by the recipe; 16-bit PNG rejected (ADR 0012); change `content/sites/zone1_vigilant_height.png` + `_mask.png` to `zone1_vigilant_terrain.res`, and the `DiveSiteDefinition` field list to the Task 4 fields. In §4.4 replace "≈ 420 m" / "≈ 260 m" / "Récompense : moitié moins de distance" with the measured lengths (≈ 430 m and ≈ 370 m) and "Récompense : trajet plus court et direct sur la proue". In §4.5 and §4.2 set the pinnacles and the ridge to 6 m high (the 20 m minimum depth of the site caps them). + +- [ ] **Step 5: Commit** + +```bash +git add game/composition/vertical_slice/vertical_slice_root.gd tests/README.md content/README.md docs/VERTICAL_SLICE_TECHNICAL_PLAN.md docs/briefs/terrain-site-zone1-gameplay.md docs/briefs/captures/zone1_vigilant_sonar_groove.png +git commit -m "feat(sites): dive on the Banc du Vigilant by default + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +## Out of scope (next plans) + +- Real wreck meshes, decoy fact points, surface-sonar echo zones (rest of tranche 3). +- Textured/sculpted terrain pass and props (tranche 6). +- Currents in the groove (V0.5). +- The frontal-beam acquisition comparison and its ADR (tranche 2 close-out).