OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_nav_debug.gd

142 lines · gd ref:
extends SceneTree
## Gate: DCTerrain.debug_nav_chunk answers, and an EDIT changes the answer.
##
## The instrument from design §8 — the portal graph is made visible before
## anything paths over it, because a pathfinder on a wrong graph produces bad
## routes that look like a bad pathfinder.
##
## ⛔ THE EDIT CHECK IS THE POINT. A nav view built from the generator alone would
## return identical data before and after a player carves a wall, and would look
## perfectly healthy while being blind to every edit in the world. DCCollisionSDF
## has exactly that bug today. So this does not merely assert that the call
## returns something — it asserts that carving CHANGES it.

var ok := true
var _ran := false


func _p(c: bool, m: String) -> void:
	print(("ok   " if c else "FAIL: ") + m)
	if not c:
		ok = false


func _process(_delta: float) -> bool:
	if _ran:
		return true
	_ran = true
	_run()
	print("GATE nav_debug: %s" % ("PASS" if ok else "FAIL"))
	if not ok:
		quit(1)
	return true


func _run() -> void:
	_p(ClassDB.class_exists("DCTerrain"), "the dc-terrain GDExtension is loaded")
	if not ClassDB.class_exists("DCTerrain"):
		return
	_p(ClassDB.class_has_method("DCTerrain", "debug_nav_chunk"),
		"DCTerrain exposes debug_nav_chunk — if this fails the .so is STALE, rebuild all three targets")
	if not ClassDB.class_has_method("DCTerrain", "debug_nav_chunk"):
		return

	var t: Node = ClassDB.instantiate("DCTerrain")
	_p(t != null, "a DCTerrain instantiates")
	if t == null:
		return
	root.add_child(t)

	# A cube well above the noise surface would be empty; sit it across y=0 where
	# the default generator puts ground.
	var origin := Vector3(0.0, -4.0, 0.0)
	var d: Dictionary = t.debug_nav_chunk(origin, 8.0, 16, 0.4, 1.8)
	_p(not d.is_empty(), "debug_nav_chunk returns data")
	if d.is_empty():
		return

	_p(d.has("standable") and d.has("regions") and d.has("portals") and d.has("cell_size"),
		"and it carries standable cells, regions, portals and a cell size")
	var cells: PackedVector3Array = d["standable"]
	var regions: PackedInt32Array = d["regions"]
	var portals: Array = d["portals"]
	print("     before edit: %d standable, %d region(s), %d portal(s), cell=%.3f m"
			% [cells.size(), int(d["region_count"]), portals.size(), float(d["cell_size"])])
	_p(cells.size() == regions.size(), "one region label per standable cell")
	_p(float(d["cell_size"]) > 0.0, "cell size is positive (8m / 16 = 0.5)")
	_p(abs(float(d["cell_size"]) - 0.5) < 0.001, "and is exactly size/cells")

	for c: Vector3 in cells:
		_p(c.x >= origin.x and c.x <= origin.x + 8.0
				and c.y >= origin.y and c.y <= origin.y + 8.0
				and c.z >= origin.z and c.z <= origin.z + 8.0,
			"every standable cell lies inside the cube asked for")
		break   # one is enough; the rest share the derivation

	for p: Dictionary in portals:
		_p(p.has("face") and p.has("region") and p.has("cells"), "each portal names face, region and cells")
		_p((p["cells"] as PackedVector3Array).size() > 0, "and covers at least one cell")
		break

	# ---- THE EDIT CHECK ----
	# ⛔ NOT SKIPPED. The first run of this gate guessed "add_sphere_edit", found no
	# such method, printed "edit check skipped" and reported PASS — the one
	# assertion that matters not running, while the gate looked healthy. The real
	# binding is edit_terrain(position, radius, strength, ...).
	_p(t.has_method("edit_terrain"), "DCTerrain exposes edit_terrain")
	if t.has_method("edit_terrain"):
		var before: int = cells.size()
		# Carve a large sphere through the middle of the cube. Negative strength
		# subtracts material.
		t.call("edit_terrain", origin + Vector3(4.0, 4.0, 4.0), 3.0, -1.0)
		var d2: Dictionary = t.debug_nav_chunk(origin, 8.0, 16, 0.4, 1.8)
		var after: int = (d2["standable"] as PackedVector3Array).size()
		print("     after carve: %d standable (was %d), %d region(s)"
				% [after, before, int(d2["region_count"])])
		_p(after != before,
			"carving CHANGES the nav answer — the view is not generator-only and edit-blind")

	# ---- the graph populates from REAL meshing, not from the debug call ----
	# debug_nav_chunk builds nav on demand and touches no graph. This asserts the
	# other path: nav riding _process_cell, portals travelling back on CellResult,
	# and the main thread matching them across chunks.
	_p(t.has_method("set_nav_enabled") and t.has_method("nav_graph_stats"),
		"DCTerrain exposes the nav graph")
	if t.has_method("set_nav_enabled"):
		var off: Dictionary = t.nav_graph_stats()
		_p(not bool(off.get("enabled", true)), "navigation is OFF by default — it costs per chunk")
		_p(int(off.get("chunks", -1)) == 0, "and the graph is empty while it is off")
		t.set_nav_enabled(true)
		_p(bool((t.nav_graph_stats() as Dictionary).get("enabled", false)), "it can be switched on")

		# SELF-CHECK: switching off must CLEAR the graph, not leave stale chunks
		# that would answer route queries from terrain remeshed without nav.
		t.set_nav_enabled(false)
		_p(int((t.nav_graph_stats() as Dictionary).get("chunks", -1)) == 0,
			"self-check: switching nav off drops the graph rather than stranding it")

	# ---- something PATHS over it ----
	_p(t.has_method("nav_find_path"), "DCTerrain exposes nav_find_path")
	if t.has_method("nav_find_path"):
		# With nav off, the answer must be a refusal that says why — not a silent
		# "no route", which a caller would read as a wall.
		t.set_nav_enabled(false)
		var off: Dictionary = t.nav_find_path(Vector3.ZERO, Vector3(10, 0, 0))
		_p(not bool(off.get("found", true)), "with nav off there is no path")
		_p(String(off.get("error", "")).contains("off"),
			"and it says navigation is OFF rather than implying a wall (%s)"
				% str(off.get("error", "")))

		t.set_nav_enabled(true)
		# Nothing has meshed in this bare instance, so the graph is empty and a
		# query must fail on the START, not pretend to route. Distinguishing "I
		# cannot see where you are" from "there is no way" is the point.
		var empty: Dictionary = t.nav_find_path(Vector3.ZERO, Vector3(10, 0, 0))
		_p(not bool(empty.get("found", true)), "an empty graph routes nothing")
		_p(String(empty.get("error", "")).contains("start"),
			"and blames the START being off the navigable set, not a missing route (%s)"
				% str(empty.get("error", "")))
		t.set_nav_enabled(false)

	t.queue_free()