gmp(M02): extract player terrain query

Work-Package: M02-GMP-TERRAIN-QUERY-001
Agent: sindo-main-codex
Tests: terrain query, player input and movement regressions; asset-free scene; renderer dry-run; coordinate, streaming, documentation and coordination gates
Fidelity: preserves existing ADT outer-grid interpolation and player ground snap; build-12340 collision parity remains unverified
This commit is contained in:
2026-07-14 23:10:55 +04:00
parent 5206c42cf0
commit a45d521567
14 changed files with 812 additions and 113 deletions
+198
View File
@@ -0,0 +1,198 @@
extends SceneTree
## Headless M02 contract, interpolation, cache and player-injection regression.
const TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/terrain_query.gd"
const ADT_TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/adt_terrain_query.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
var _synthetic_adt_tile_data: Dictionary = {}
var _synthetic_loader_call_count := 0
class FlatTerrainQuery extends TerrainQuery:
var _height_units: float
func _init(height_units: float) -> void:
_height_units = height_units
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
return TerrainGroundSample.available(_height_units)
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
_verify_typed_result_contract(failures)
_verify_synthetic_adt_interpolation_and_cache(failures)
_verify_unavailable_tile(failures)
_verify_player_injection(failures)
_verify_source_boundaries(failures)
if not failures.is_empty():
for failure in failures:
push_error("TERRAIN_QUERY: %s" % failure)
quit(1)
return
print("TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1 failures=2 player_injection=1")
quit(0)
func _verify_typed_result_contract(failures: Array[String]) -> void:
var available_sample := TerrainGroundSample.available(123.5)
_expect_true(available_sample.is_available, "available sample state", failures)
_expect_near(available_sample.height_units, 123.5, "available sample height", failures)
_expect_true(available_sample.failure_code.is_empty(), "available sample has no failure", failures)
var unavailable_sample := TerrainGroundSample.unavailable(&"synthetic_failure")
_expect_true(not unavailable_sample.is_available, "unavailable sample state", failures)
_expect_equal(unavailable_sample.failure_code, &"synthetic_failure", "unavailable failure code", failures)
var base_sample := TerrainQuery.new().sample_ground_height(GodotWorldPosition.new(0.0, 0.0, 0.0))
_expect_equal(base_sample.failure_code, &"terrain_query_not_implemented", "base query failure", failures)
var non_finite_sample := TerrainGroundSample.available(NAN)
_expect_true(not non_finite_sample.is_available, "non-finite sample unavailable", failures)
_expect_equal(non_finite_sample.failure_code, &"terrain_height_not_finite", "non-finite failure code", failures)
func _verify_synthetic_adt_interpolation_and_cache(failures: Array[String]) -> void:
var query_position := GodotWorldPosition.new(17000.0, 999.0, 26525.0)
_synthetic_adt_tile_data = _build_synthetic_adt_tile(query_position)
_synthetic_loader_call_count = 0
var terrain_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", _load_synthetic_adt_tile)
var first_sample := terrain_query.sample_ground_height(query_position)
_expect_true(first_sample.is_available, "synthetic ADT sample available", failures)
_expect_near_with_tolerance(first_sample.height_units, 137.0, 0.002, "synthetic bilinear height", failures)
var second_sample := terrain_query.sample_ground_height(query_position)
_expect_near_with_tolerance(second_sample.height_units, 137.0, 0.002, "cached synthetic height", failures)
_expect_equal(_synthetic_loader_call_count, 1, "ADT tile loaded once", failures)
func _verify_unavailable_tile(failures: Array[String]) -> void:
var query_position := GodotWorldPosition.new(17000.0, 0.0, 26525.0)
var empty_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", func(_path: String) -> Dictionary: return {})
var empty_sample := empty_query.sample_ground_height(query_position)
_expect_true(not empty_sample.is_available, "empty ADT unavailable", failures)
_expect_equal(empty_sample.failure_code, &"adt_tile_unavailable", "empty ADT failure", failures)
var invalid_heights_data := _build_synthetic_adt_tile(query_position)
var invalid_chunks: Array = invalid_heights_data.get("chunks", [])
(invalid_chunks[invalid_chunks.size() - 1] as Dictionary)["heights"] = PackedFloat32Array()
var invalid_query := AdtTerrainQuery.new(
"res://synthetic",
"Azeroth",
func(_path: String) -> Dictionary: return invalid_heights_data
)
var invalid_sample := invalid_query.sample_ground_height(query_position)
_expect_equal(invalid_sample.failure_code, &"adt_chunk_heights_invalid", "invalid heights failure", failures)
func _verify_player_injection(failures: Array[String]) -> void:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
_expect_true(packed_scene != null, "regression scene loads", failures)
if packed_scene == null:
return
var player := packed_scene.instantiate() as CharacterBody3D
player.set_terrain_query(FlatTerrainQuery.new(20.0))
root.add_child(player)
player.set_physics_process(false)
_expect_near(player.position.y, 20.05, "injected spawn ground height", failures)
player.position.y = 30.0
player.call("_physics_process", 1.0)
_expect_near(player.position.y, 20.05, "injected physics ground snap", failures)
player.free()
func _verify_source_boundaries(failures: Array[String]) -> void:
var terrain_contract_source := _read_text(TERRAIN_QUERY_PATH, failures)
_expect_true(not terrain_contract_source.contains("extends Node"), "TerrainQuery omits Node", failures)
_expect_true(not terrain_contract_source.contains("Vector3"), "TerrainQuery omits untyped Vector3 position", failures)
var adt_query_source := _read_text(ADT_TERRAIN_QUERY_PATH, failures)
_expect_true(adt_query_source.contains("ADTLoader"), "ADT adapter owns native loader", failures)
_expect_true(adt_query_source.contains("_adt_tile_cache"), "ADT adapter owns tile cache", failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
for forbidden_text in ["ADTLoader", "load_adt", "_adt_cache", "_find_chunk", "_outer_height_index"]:
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
_expect_true(player_source.contains("set_terrain_query"), "player exposes terrain query injection", failures)
_expect_true(player_source.contains("_terrain_query.sample_ground_height"), "player delegates ground sampling", failures)
func _build_synthetic_adt_tile(query_position: GodotWorldPosition) -> Dictionary:
var tile_coordinate := CoordinateMapper.godot_to_adt_tile(query_position)
var tile_local_position := CoordinateMapper.godot_to_adt_tile_local(query_position)
var chunk_coordinate := CoordinateMapper.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
var chunk_size := CoordinateMapper.ADT_CHUNK_SIZE_YARDS
var outer_grid_unit_size := chunk_size / 8.0
var target_chunk_origin := Vector3(
query_position.x_units - outer_grid_unit_size * 4.5,
100.0,
query_position.z_units - outer_grid_unit_size * 3.25
)
var tile_origin := target_chunk_origin - Vector3(
chunk_coordinate.chunk_x * chunk_size,
0.0,
chunk_coordinate.chunk_y * chunk_size
)
var heights := PackedFloat32Array()
heights.resize(145)
for row in range(9):
for column in range(9):
heights[row * 17 + column] = float(row * 10 + column)
var target_chunk := {"origin": target_chunk_origin, "heights": heights}
if chunk_coordinate.chunk_x == 0 and chunk_coordinate.chunk_y == 0:
return {"chunks": [target_chunk]}
return {
"chunks": [
{"origin": tile_origin, "heights": PackedFloat32Array()},
target_chunk,
]
}
func _load_synthetic_adt_tile(_absolute_path: String) -> Dictionary:
_synthetic_loader_call_count += 1
return _synthetic_adt_tile_data
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
func _expect_near_with_tolerance(
actual_value: float,
expected_value: float,
tolerance: float,
label: String,
failures: Array[String]
) -> void:
if absf(actual_value - expected_value) > tolerance:
failures.append("%s expected %.6f ± %.6f, got %.6f" % [label, expected_value, tolerance, actual_value])
func _expect_equal(actual_value: Variant, expected_value: Variant, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)