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

122 KiB
Raw Permalink Blame History

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

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

	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
"$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:

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

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
"$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:

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:

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:

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

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:

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:

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:

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:

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

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:

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):
"$GODOT_BIN" --headless --path . --script tests/unit/run_site_application_tests.gd
  • Step 3: Implement

game/application/sites/playable_bounds_status.gd:

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:

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:

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

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):
"$GODOT_BIN" --headless --path . --script tests/unit/run_site_baker_tests.gd
  • Step 3: Implement tools/site_terrain_baker.gd
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).

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:

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

"$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:

"$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:

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

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):
"$GODOT_BIN" --headless --path . --script tests/integration/run_site_terrain_integration.gd
  • Step 3: Write the shader

game/world/sites/seabed_terrain.gdshader:

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:

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:

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

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:

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:

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

[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:

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:

	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:

"$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

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:
	_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):
"$GODOT_BIN" --headless --path . --script tests/integration/run_vertical_slice_foundation.gd
  • Step 3: Write dive_world.tscn
[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:

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:

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:

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:

	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:

	_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():

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:

[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:

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
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
"$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
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:

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):
"$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.

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
"$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:
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
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

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
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
"$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":

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

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

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