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