Files
Ransom-s-wake/docs/superpowers/plans/2026-10-06-terrain-site-zone1.md
T
2026-10-06 19:30:36 +02:00

2966 lines
122 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `"<Suite>: %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 <noreply@anthropic.com>`.
## 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: `-- <recipe_name>` |
| `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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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/<name>_terrain.res` with `ResourceSaver.save`, writes `res://docs/briefs/captures/<name>_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 <noreply@anthropic.com>"
```
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 <noreply@anthropic.com>"
```
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/<name>_terrain.res` (with `ResourceSaver.FLAG_CHANGE_PATH`) then `res://content/sites/<name>.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/<name>.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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
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 <noreply@anthropic.com>"
```
---
### 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/<nom>.tres` est une `DiveSiteDefinition` ; `sites/<nom>_terrain.res`
est son `SiteTerrainData`. Les deux sont produits par
`tools/bake_site_terrain.gd -- <nom>` depuis `tools/sites/<nom>_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 <noreply@anthropic.com>"
```
---
## 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).