fix(tools): canyons impose a floor material through rock outcrops; tighten showcase contracts

Review findings: the mesa forced rock onto the canyon bed, two runners
declared the wrong TEST_COUNT, and the escarpment check could pass on the
slope alone. Canyon gains floor_material, the showcase is re-baked with sand
beds, the contract measures the step across the crest band, counts distinct
ledges, and probes the crevasse against the open plateau. Degenerate recipe
parameters now fail an assert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 committed 2026-10-06 22:00:24 +02:00
1 parent abffa737ec
commit d91e6bd02f
8 files changed
+73 -36

No files matched your search

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

+9 -7
View File
@@ -82,7 +82,7 @@ ou `smoothstep`) et **mono-segment**. Il manque :
| Primitive | Paramètres | Forme |
|---|---|---|
| `Canyon` | `path: PackedVector2Array`, `half_width_m`, `depth_m`, `wall_softness_m`, `edge_seed`, `edge_amplitude_m` | tranchée à fond plat le long d'une polyligne ; parois `smoothstep` sur `wall_softness_m` ; distance au tracé perturbée par un bruit de contour ; se referme sur une demi-largeur à chaque bout |
| `Canyon` | `path: PackedVector2Array`, `half_width_m`, `depth_m`, `wall_softness_m`, `edge_seed`, `edge_amplitude_m`, `floor_material` | tranchée à fond plat le long d'une polyligne ; parois `smoothstep` sur `wall_softness_m` ; distance au tracé perturbée par un bruit de contour ; se referme sur une demi-largeur à chaque bout ; impose `floor_material` (sable en général) sur le fond plat, même à travers une butte rocheuse |
| `Outcrop` | `centre`, `radius_x_m`, `radius_z_m`, `height_m`, `wall_width_m`, `edge_seed`, `edge_amplitude_m`, `rock_top` | butte elliptique à sommet plat, flanc de largeur `wall_width_m`, contour perturbé ; matériau roche partout où elle dépasse 30 % de sa hauteur si `rock_top` |
| `BoulderField` | `centre`, `radius_m`, `seed`, `count`, `boulder_radius_min_m`, `boulder_radius_max_m`, `height_ratio` | `count` demi-ellipsoïdes tirés par `RandomNumberGenerator` seedé dans le disque, combinés par **maximum** ; roche sur toute leur emprise |
| `Escarpment` | `path: PackedVector2Array`, `drop_m`, `softness_m`, `slope_beyond`, `edge_seed`, `edge_amplitude_m` | `Step` le long d'une polyligne : le terrain descend à **droite** du sens de parcours ; ligne de crête perturbée par un bruit de contour |
@@ -129,7 +129,7 @@ Plan de masse (nord en haut) :
| 55°, gradins 4 m, fond ridé entre buttes |
| [départ] |
| SW : chaos de blocs SE : éperons |
| 80 blocs r 2–6 m sur deux buttes |
| 90 blocs r 2–6 m sur deux buttes |
| sable ridé alvéolées, |
| chenal 20 m |
| ====== falaise sinueuse (Escarpment) ===== |
@@ -148,13 +148,15 @@ Plan de masse (nord en haut) :
gradins : au moins deux niveaux plats ; un fond plat ne bouge pas) ;
cuisson du Banc du Vigilant identique à l'artefact versionné.
- **Contrat (`run_site_content_tests.gd`, section showcase)** : canyon
(profondeur ≥ 20 m sous la mesa, fond plat à ± 1 m sur 10 m, paroi ≥ 60°
quelque part sur une transversale, ≥ 2 replats de gradin) ; crevasse
(fond ≥ 16 m sous les bords, largeur au fond ≤ 8 m) ; chaos (roche sur un
(profondeur ≥ 18 m sous le sommet bruité de la mesa, fond de sable plat à
± 1 m sur 10 m, paroi ≥ 60° quelque part sur une transversale, ≥ 2 replats
distincts) ; crevasse (fond de sable ≥ 16 m sous le plateau ouvert, 3 à 8 m
de large à 10 m sous le plateau) ; chaos (roche sur un
bloc, sable entre deux blocs, ≥ 40 cellules de roche distinctes… mesuré par
fraction de roche dans le disque) ; éperons (chenal plus bas que les deux
buttes, roche sur chaque butte) ; falaise (drop ≥ 20 m entre deux points de
part et d'autre) ; bornes et profondeurs globales.
buttes, roche sur chaque butte) ; falaise (marche ≥ 18 m mesurée dans la
seule bande de crête, pour que la pente en contrebas ne masque pas une
marche absente) ; bornes et profondeurs globales.
- **Intégration (`run_site_terrain_integration.gd`)** : chargement du showcase
sous 3 s, rayon dans le canyon plus bas que sur la mesa, scan de référence
lisant roche et sable.
+29 -17
View File
@@ -15,20 +15,23 @@ const DiveRulesResolver = preload("res://game/domain/rules/dive_rules_resolver.g
const SITE_PATH := "res://content/sites/zone1_vigilant.tres"
const SHOWCASE_PATH := "res://content/sites/terrain_showcase.tres"
# Showcase probe positions (docs/briefs/terrain-varied-landforms.md §5). The canyon
# transect crosses its first leg at right angles; the crevasse transect is the
# z = -120 row between the two buttes.
# transect crosses its second leg at right angles; the crevasse transect is the
# z = -120 row between the two buttes, compared with the open plateau on the same row;
# the escarpment is probed across its step band at x = 0, where the crest is at z = 217.
const SHOWCASE_MESA_TOP := Vector2(-80.0, -90.0)
const SHOWCASE_CANYON_MID := Vector2(-130.0, -135.0)
const SHOWCASE_CANYON_AXIS := Vector2(40.0, -30.0)
const SHOWCASE_CREVASSE_ROW_Z := -120.0
const SHOWCASE_CREVASSE_SHELF_X := 118.0
const SHOWCASE_CREVASSE_PLAIN_X := 0.0
const SHOWCASE_CREVASSE_FLOOR_X := 124.0
const SHOWCASE_BOULDER_FIELD_CENTRE := Vector2(-120.0, 110.0)
const SHOWCASE_BOULDER_FIELD_RADIUS_M := 75.0
const SHOWCASE_WEST_SPUR := Vector2(80.0, 120.0)
const SHOWCASE_CHANNEL := Vector2(125.0, 120.0)
const SHOWCASE_EAST_SPUR := Vector2(170.0, 125.0)
const STEEP_WALL_RISE_PER_M := 1.7 # tan(60 degrees)
const SHOWCASE_ESCARPMENT_CREST_Z := 217.0
const SHOWCASE_ESCARPMENT_BAND_M := 14.0 # softness 6 m plus 8 m of outline noise
const STEEP_WALL_RISE_PER_M := 1.73 # tan(60 degrees)
const LEDGE_RISE_PER_M := 0.35
const ROUTE_CLEARANCE_M := 3.0
const MINIMUM_LANDMARK_SPACING_M := 60.0
@@ -36,7 +39,7 @@ const MAXIMUM_ANCHOR_TO_WRECK_M := 320.0
const MAXIMUM_WRECK_SPACING_M := 260.0
const HULL_HALF_LENGTH_M := 14.0
const HULL_SEABED_TOLERANCE_M := 2.0
const TEST_COUNT := 52
const TEST_COUNT := 54
var _failures: Array[String] = []
@@ -161,7 +164,7 @@ func _test_showcase_content() -> void:
return
_expect(field.cell_size_m() == 1.0 and field.columns() == 513 and field.rows() == 513, "showcase grid is 513 x 513 cells of 1 m")
_expect(definition.playable_bounds_m.is_equal_approx(Rect2(-220.0, -220.0, 440.0, 440.0)), "showcase playable zone is 440 x 440 m")
_expect(field.minimum_elevation_m() >= -80.5 and field.maximum_elevation_m() <= -18.5, "showcase terrain stays between 19 m and 80 m deep")
_expect(field.minimum_elevation_m() >= -80.5 and field.maximum_elevation_m() <= -19.0, "showcase terrain stays between 19 m and 80 m deep")
var anchors := definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR)
_expect(anchors.size() == 1 and definition.placements.size() == 1, "showcase has a single placement, the mooring beacon")
var spawn := definition.spawn_position_m
@@ -171,6 +174,7 @@ func _test_showcase_content() -> void:
var mesa_top := field.elevation_at(SHOWCASE_MESA_TOP.x, SHOWCASE_MESA_TOP.y)
var canyon_floor := field.elevation_at(SHOWCASE_CANYON_MID.x, SHOWCASE_CANYON_MID.y)
_expect(mesa_top - canyon_floor >= 18.0, "canyon floor is at least 18 m below the mesa top (%.1f m)" % (mesa_top - canyon_floor))
_expect(field.material_at(SHOWCASE_CANYON_MID.x, SHOWCASE_CANYON_MID.y) == SonarMaterial.Value.SAND, "canyon floor is sand through the rock mesa")
var axis := SHOWCASE_CANYON_AXIS.normalized()
var lowest := INF
var highest := -INF
@@ -186,23 +190,30 @@ func _test_showcase_content() -> void:
var point := SHOWCASE_CANYON_MID + normal * float(offset)
transect.append(field.elevation_at(point.x, point.y))
var steepest := 0.0
var ledge_pairs := 0
var ledges := 0
var on_ledge := false
for index in range(transect.size() - 1):
var rise := absf(transect[index + 1] - transect[index])
steepest = maxf(steepest, rise)
var in_wall := transect[index] > canyon_floor + 1.0 and transect[index] < mesa_top - 1.0
# A ledge is a run of flat metres inside the wall, separated from the next by a riser.
if in_wall and rise < LEDGE_RISE_PER_M:
ledge_pairs += 1
_expect(steepest >= STEEP_WALL_RISE_PER_M, "canyon wall is steeper than 60 degrees somewhere (%.1f m per m)" % steepest)
_expect(ledge_pairs >= 2, "canyon wall carries at least two ledges (%d flat metres)" % ledge_pairs)
if not on_ledge:
ledges += 1
on_ledge = true
else:
on_ledge = false
_expect(steepest >= STEEP_WALL_RISE_PER_M, "canyon wall is steeper than 60 degrees somewhere (%.2f m per m)" % steepest)
_expect(ledges >= 2, "canyon walls carry at least two distinct ledges (%d)" % ledges)
# NE: crevasse between the buttes.
var shelf := field.elevation_at(SHOWCASE_CREVASSE_SHELF_X, SHOWCASE_CREVASSE_ROW_Z)
var plain := field.elevation_at(SHOWCASE_CREVASSE_PLAIN_X, SHOWCASE_CREVASSE_ROW_Z)
var crevasse_floor := field.elevation_at(SHOWCASE_CREVASSE_FLOOR_X, SHOWCASE_CREVASSE_ROW_Z)
_expect(shelf - crevasse_floor >= 16.0, "crevasse floor is at least 16 m below the rim (%.1f m)" % (shelf - crevasse_floor))
_expect(plain - crevasse_floor >= 16.0, "crevasse floor is at least 16 m below the open plateau (%.1f m)" % (plain - crevasse_floor))
_expect(field.material_at(SHOWCASE_CREVASSE_FLOOR_X, SHOWCASE_CREVASSE_ROW_Z) == SonarMaterial.Value.SAND, "crevasse floor is sand")
var deep_cells := 0
for x in range(100, 146):
if field.elevation_at(float(x), SHOWCASE_CREVASSE_ROW_Z) < shelf - 10.0:
if field.elevation_at(float(x), SHOWCASE_CREVASSE_ROW_Z) < plain - 10.0:
deep_cells += 1
_expect(deep_cells >= 3 and deep_cells <= 8, "crevasse is 3 to 8 m wide at 10 m down (%d m)" % deep_cells)
@@ -234,10 +245,11 @@ func _test_showcase_content() -> void:
)
_expect(field.material_at(SHOWCASE_CHANNEL.x, SHOWCASE_CHANNEL.y + 30.0) == SonarMaterial.Value.SAND, "the channel floor is sand")
# S: escarpment.
var above := field.elevation_at(0.0, 190.0)
var below := field.elevation_at(0.0, 250.0)
_expect(above - below >= 20.0, "the escarpment drops at least 20 m (%.1f m)" % (above - below))
# S: escarpment, measured across its step band only so the slope below cannot
# stand in for a missing step (the slope alone would give about 7 m here).
var above := field.elevation_at(0.0, SHOWCASE_ESCARPMENT_CREST_Z - SHOWCASE_ESCARPMENT_BAND_M)
var below := field.elevation_at(0.0, SHOWCASE_ESCARPMENT_CREST_Z + SHOWCASE_ESCARPMENT_BAND_M)
_expect(above - below >= 18.0, "the escarpment steps down at least 18 m across its crest band (%.1f m)" % (above - below))
func _rests_on_terrain(field: TerrainHeightField, placement: SitePlacement) -> bool:
@@ -240,7 +240,7 @@ func _test_showcase_site() -> void:
var mesa_top := _terrain_height(space, terrain, -80.0, -90.0, [])
_expect(mesa_top - canyon_floor >= 18.0, "collision rays find the canyon under the mesa (%.1f m vs %.1f m)" % [canyon_floor, mesa_top])
# Sensor above the canyon looking along its first leg (the acquisition probes along
# Sensor above the canyon looking along its second leg (the acquisition probes along
# local +Z): rock walls and sand floor.
var sensor := Transform3D(Basis.IDENTITY, Vector3(-130.0, -15.0, -135.0))
sensor.basis = Basis.looking_at(-Vector3(40.0, 0.0, -30.0).normalized(), Vector3.UP)
+5 -3
View File
@@ -8,7 +8,7 @@ const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
const ZONE1_TERRAIN_PATH := "res://content/sites/zone1_vigilant_terrain.res"
const TEST_COUNT := 40
const TEST_COUNT := 44
var _failures: Array[String] = []
@@ -121,16 +121,18 @@ func _test_modifiers_and_primitive_materials() -> void:
func _test_canyon() -> void:
var canyon := SiteTerrainBaker.Canyon.new(
PackedVector2Array([Vector2(-20.0, 0.0), Vector2(0.0, 0.0), Vector2(0.0, 20.0)]),
4.0, 10.0, 1.0, 0, 0.0
4.0, 10.0, 1.0, 0, 0.0, SonarMaterial.Value.SAND
)
_expect(is_equal_approx(canyon.elevation_delta(-10.0, 0.0), -10.0), "canyon floor sits at depth on the first leg")
_expect(canyon.material_at(-10.0, 0.0) == SonarMaterial.Value.SAND, "canyon imposes its floor material on the flat floor")
_expect(canyon.material_at(-10.0, 4.0) == SiteTerrainBaker.NO_MATERIAL, "canyon leaves its walls to the slope rule")
_expect(is_equal_approx(canyon.elevation_delta(1.5, 10.0), -10.0), "canyon floor is flat across its width on the second leg")
_expect(is_equal_approx(canyon.elevation_delta(-10.0, 6.0), 0.0), "canyon stops past half width plus softness")
_expect(is_equal_approx(canyon.elevation_delta(15.0, -15.0), 0.0), "canyon is zero far from the path")
_expect(is_equal_approx(canyon.elevation_delta(0.0, 20.0), 0.0), "canyon closes at its last vertex")
_expect(canyon.elevation_delta(-10.0, 4.0) < -4.0 and canyon.elevation_delta(-10.0, 4.0) > -6.0, "canyon wall is half way down at half width")
var rough := SiteTerrainBaker.Canyon.new(
PackedVector2Array([Vector2(-200.0, 0.0), Vector2(200.0, 0.0)]), 8.0, 10.0, 2.0, 7, 3.0
PackedVector2Array([Vector2(-200.0, 0.0), Vector2(200.0, 0.0)]), 8.0, 10.0, 2.0, 7, 3.0, SiteTerrainBaker.NO_MATERIAL
)
var wall_values: Dictionary = {}
for x in range(-100, 101, 10):
+25 -5
View File
@@ -170,13 +170,15 @@ class Groove extends Primitive:
## Flat-floored trench along a polyline. The floor sits depth_m below the surroundings
## wherever the (noise-perturbed) distance to the path is under half_width_m minus
## wall_softness_m; the walls climb over 2 * wall_softness_m; the trench closes over one
## half-width at each end.
## half-width at each end. floor_material (a SonarMaterial value, or NO_MATERIAL) is
## imposed on the flat floor, so a canyon cut through a rock outcrop keeps a sand bed.
class Canyon extends Primitive:
var path: PackedVector2Array
var half_width_m: float
var depth_m: float
var wall_softness_m: float
var edge_amplitude_m: float
var floor_material: int
var _noise: FastNoiseLite
var _length_m: float
@@ -186,17 +188,31 @@ class Canyon extends Primitive:
p_depth_m: float,
p_wall_softness_m: float,
p_edge_seed: int,
p_edge_amplitude_m: float
p_edge_amplitude_m: float,
p_floor_material: int
) -> void:
assert(p_path.size() >= 2, "a canyon path needs at least two vertices")
assert(p_half_width_m > 0.0, "a canyon needs a positive half width")
path = p_path
half_width_m = p_half_width_m
depth_m = p_depth_m
wall_softness_m = p_wall_softness_m
edge_amplitude_m = p_edge_amplitude_m
floor_material = p_floor_material
_noise = edge_noise(p_edge_seed, p_edge_amplitude_m)
_length_m = path_length(p_path)
func elevation_delta(x_m: float, z_m: float) -> float:
return -depth_m * _presence(x_m, z_m)
func material_at(x_m: float, z_m: float) -> int:
# Full presence means the flat floor: inside the walls and clear of the end tapers.
if floor_material != NO_MATERIAL and _presence(x_m, z_m) >= 0.999:
return floor_material
return NO_MATERIAL
## 1 on the flat floor, 0 outside the trench, in between on the walls and end tapers.
func _presence(x_m: float, z_m: float) -> float:
var sample := path_sample(Vector2(x_m, z_m), path)
var distance := sample.x + edge_offset(_noise, edge_amplitude_m, x_m, z_m)
var inside := 1.0 - smoothstep(
@@ -205,13 +221,14 @@ class Canyon extends Primitive:
distance
)
var taper := smoothstep(0.0, half_width_m, sample.y) * smoothstep(0.0, half_width_m, _length_m - sample.y)
return -depth_m * inside * taper
return inside * taper
## Step along a polyline: the terrain drops by drop_m on the right of the travel direction
## (south of an eastbound path), over 2 * softness_m, then keeps falling at slope_beyond.
## The crest line is roughened by outline noise. Extend the path past the field edges:
## beyond its ends the contour wraps around the end vertex.
## The crest line is roughened by outline noise. The path must extend past the field
## edges: beyond an end vertex the drop is decided by the half-plane of the last segment,
## which leaves a seam along the path's extension.
class Escarpment extends Primitive:
var path: PackedVector2Array
var drop_m: float
@@ -228,6 +245,7 @@ class Escarpment extends Primitive:
p_edge_seed: int,
p_edge_amplitude_m: float
) -> void:
assert(p_path.size() >= 2, "an escarpment path needs at least two vertices")
path = p_path
drop_m = p_drop_m
softness_m = p_softness_m
@@ -267,6 +285,7 @@ class Outcrop extends Primitive:
p_edge_amplitude_m: float,
p_rock_top: bool
) -> void:
assert(p_radius_x_m > 0.0 and p_radius_z_m > 0.0, "an outcrop needs positive radii")
centre = p_centre
radius_x_m = p_radius_x_m
radius_z_m = p_radius_z_m
@@ -435,6 +454,7 @@ class Strata extends Modifier:
p_centre: Vector2,
p_radius_m: float
) -> void:
assert(p_layer_height_m > 0.0, "strata need a positive layer height")
layer_height_m = p_layer_height_m
sharpness = clampf(p_sharpness, 0.0, 0.999)
centre = p_centre
+4 -3
View File
@@ -5,6 +5,7 @@ extends RefCounted
## mooring beacon at the centre. Field 512 x 512 m at 1 m, +X east, +Z south.
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 DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
const SitePlacement = preload("res://game/domain/sites/site_placement.gd")
@@ -59,15 +60,15 @@ static func recipe() -> SiteTerrainBaker.Recipe:
# modifier (one ledge every 2.8 m across, resolvable at 1 m per cell), rippled
# sand on the floor.
SiteTerrainBaker.Outcrop.new(MESA_CENTRE, 95.0, 85.0, 6.0, 10.0, 101, 6.0, true),
SiteTerrainBaker.Canyon.new(PackedVector2Array(CANYON_PATH), CANYON_HALF_WIDTH_M, CANYON_DEPTH_M, 7.0, 102, 2.0),
SiteTerrainBaker.Canyon.new(PackedVector2Array(CANYON_PATH), CANYON_HALF_WIDTH_M, CANYON_DEPTH_M, 7.0, 102, 2.0, SonarMaterial.Value.SAND),
SiteTerrainBaker.Ripples.new(Vector2(0.8, -0.6), 4.0, 0.25, Vector2(-130.0, -140.0), 70.0),
# NE — la Fente: two buttes 8-9 m high with a 6 m wide, 20 m deep slot between them.
SiteTerrainBaker.Outcrop.new(WEST_BUTTE_CENTRE, 38.0, 70.0, 8.0, 8.0, 201, 5.0, true),
SiteTerrainBaker.Outcrop.new(EAST_BUTTE_CENTRE, 38.0, 75.0, 9.0, 8.0, 202, 5.0, true),
SiteTerrainBaker.Canyon.new(PackedVector2Array(CREVASSE_PATH), CREVASSE_HALF_WIDTH_M, CREVASSE_DEPTH_M, 1.0, 203, 1.0),
SiteTerrainBaker.Canyon.new(PackedVector2Array(CREVASSE_PATH), CREVASSE_HALF_WIDTH_M, CREVASSE_DEPTH_M, 1.0, 203, 1.0, SonarMaterial.Value.SAND),
# SW — le Chaos: ninety boulders 2-6 m across plus six coral heads, on rippled sand.
# SW — le Chaos: ninety boulders of 2-6 m radius plus six coral heads, on rippled sand.
SiteTerrainBaker.BoulderField.new(BOULDER_FIELD_CENTRE, BOULDER_FIELD_RADIUS_M, 301, 90, 2.0, 6.0, 0.7),
SiteTerrainBaker.BoulderField.new(Vector2(-100.0, 130.0), 40.0, 302, 6, 6.0, 9.0, 0.6),
SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.3), 3.5, 0.3, BOULDER_FIELD_CENTRE, 110.0),