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