OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_nav_end_to_end.gd

192 lines · gd ref:
extends SceneTree
## END TO END on map_flat: real map -> collision -> nav graph -> path -> bot walks.
##
## Every link in this chain has its own gate. THE CHAIN did not, and that is the
## gap this closes — each piece was proven in isolation while nothing had ever run
## the whole thing in one process.
##
## map_flat on purpose. It has NO DCTerrain and no density field anywhere, so if
## navigation worked only there it would work nowhere the bots actually are. That
## the same libdc code serves a BoxShape3D ground slab is what taking a distance
## function rather than a voxel grid bought.

var ok := true
var _ran := false


## ⛔ current_map MUST BE A DECLARED PROPERTY, and the map must hang off a node
## named "Game". That is the path bots use to find anything — Engine main loop ->
## /root/Game -> current_map -> a child answering nav_find_path. The first version
## of this gate added the map straight to root, so the wandering bot's lookup found
## nothing and it chose zero destinations. The gate was testing a bot that could not
## see the world.
class StubGame extends Node3D:
	var current_map: Node = null


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_end_to_end: %s" % ("PASS" if ok else "FAIL"))
	if not ok:
		quit(1)
	return true


## Every BoxShape3D collider under `n`, as (world centre, world half-extents).
func _collect_boxes(n: Node, centres: PackedVector3Array, halves: PackedVector3Array) -> void:
	if n is CollisionShape3D:
		var cs := n as CollisionShape3D
		if cs.shape is BoxShape3D:
			var b := cs.shape as BoxShape3D
			var t := cs.global_transform
			centres.push_back(t.origin)
			# Axis-aligned only. A rotated collider would need its OBB, and map_flat
			# has none — asserted below rather than assumed.
			halves.push_back(b.size * 0.5)
	for c in n.get_children():
		_collect_boxes(c, centres, halves)


func _run() -> void:
	_p(ClassDB.class_exists("DCNavVolume"),
		"DCNavVolume exists — if not, the GDExtension is stale, rebuild all three targets")
	if not ClassDB.class_exists("DCNavVolume"):
		return

	# ---- 1. the REAL map ----
	var ps: PackedScene = load("res://scenes/maps/map_flat.tscn")
	_p(ps != null, "map_flat.tscn loads")
	if ps == null:
		return
	var game := StubGame.new()
	game.name = "Game"
	root.add_child(game)
	var map: Node = ps.instantiate()
	game.add_child(map)
	game.current_map = map

	var centres := PackedVector3Array()
	var halves := PackedVector3Array()
	_collect_boxes(map, centres, halves)
	print("     collected %d box colliders from the real map" % centres.size())
	_p(centres.size() > 0, "the map has box collision to navigate over")
	if centres.size() == 0:
		return

	# ---- 2. add what the builder bot stacks, so composition is exercised ----
	# A 10 x 10 x 20 block sitting on the ground, as bots/builder.gd places.
	var block_centre := Vector3(14.0, 5.0, 0.0)
	centres.push_back(block_centre)
	halves.push_back(Vector3(5.0, 5.0, 10.0))

	# ---- 3. build the graph ----
	var vol: Node = ClassDB.instantiate("DCNavVolume")
	root.add_child(vol)
	vol.set_boxes(centres, halves)
	var region := AABB(Vector3(-32, -8, -32), Vector3(64, 64, 64))
	var populated: int = vol.rebuild(region, 4, 16, 0.4, 1.8)
	var st: Dictionary = vol.stats()
	print("     graph: %d chunks populated of 64, %d links, %d regions, %d standable, cell %.2f m"
			% [populated, int(st["links"]), int(st["regions"]),
			   int(st["standable"]), float(st["cell_size"])])
	_p(populated > 0, "chunks with ground in them produced navigation")
	_p(int(st["links"]) > 0, "and neighbouring chunks LINKED — the graph is connected")
	_p(int(st["standable"]) > 0, "with walkable cells")

	# ---- 4. a route across the map ----
	var from := Vector3(-20.0, 0.5, -20.0)
	var to := Vector3(20.0, 0.5, 20.0)
	var r: Dictionary = vol.find_path(from, to)
	print("     path %s -> %s : found=%s nodes=%d waypoints=%d %s"
			% [str(from), str(to), str(r.get("found")), int(r.get("nodes", 0)),
			   (r.get("waypoints", PackedVector3Array()) as PackedVector3Array).size(),
			   str(r.get("error", ""))])
	_p(bool(r.get("found", false)), "a route across the map is found")
	if not bool(r.get("found", false)):
		return
	_p(int(r.get("nodes", 0)) > 1, "and it crosses more than one chunk")

	# ---- 5. THE BOT WALKS IT ----
	var brain_script: GDScript = load("res://bots/pathwalker.gd")
	_p(brain_script != null, "pathwalker.gd loads")
	if brain_script == null:
		return
	var b: Object = brain_script.new()
	b.set("bot_id", "e2e")
	b.set("goal", to)
	b.set("speed", 8.0)
	b.set("_pos", from)
	b.set("_have_pos", true)

	# Hand it the route this graph actually produced — not an invented one.
	var wp: PackedVector3Array = r.get("waypoints", PackedVector3Array())
	wp.append(to)
	b.set("_waypoints", wp)
	b.set("_index", 0)

	var start_pos: Vector3 = b.get("_pos")
	var ticks := 0
	while ticks < 2000 and int(b.get("_index")) < wp.size():
		b.call("_bot_tick", 0.05)
		ticks += 1
	var end_pos: Vector3 = b.get("_pos")
	print("     bot walked %.1f m in %d ticks: %s -> %s"
			% [start_pos.distance_to(end_pos), ticks, str(start_pos), str(end_pos)])
	_p(end_pos.distance_to(start_pos) > 1.0, "the bot MOVED")
	_p(end_pos.distance_to(to) < 1.5,
		"and arrived at the goal the graph routed it to (%.2f m away)" % end_pos.distance_to(to))

	# ---- 6. the map wires its OWN nav volume, and WANDER re-runs the search ----
	_p(map.has_method("nav_rebuild"), "map_flat builds its own nav volume")
	if map.has_method("nav_rebuild"):
		var populated2: int = map.nav_rebuild()
		var own: Node = map.get_nav_volume()
		_p(own != null and populated2 > 0,
			"and it populates from the map's OWN colliders (%d chunks)" % populated2)
		if own != null:
			_p(own.has_method("nav_find_path"),
				"it answers nav_find_path, so a bot need not know which map it is in")
			var rp: Vector3 = own.random_standable_point()
			_p(rp != Vector3.ZERO, "it can offer a random standable destination (%s)" % str(rp))

			# The wandering bot must choose a NEW goal and route to it — the thing
			# that makes the search visible as a search rather than one trusted run.
			var w: Object = brain_script.new()
			w.set("bot_id", "wander")
			w.set("wander", true)
			w.set("wander_pause", 0.0)
			w.set("speed", 40.0)
			w.set("_pos", rp)
			w.set("_have_pos", true)
			# Drive it long enough to finish several legs.
			var goals: Array[Vector3] = []
			for _i in range(4000):
				w.call("_bot_tick", 0.05)
				var gnow: Vector3 = w.get("goal")
				if gnow != Vector3.ZERO and (goals.is_empty() or goals[-1] != gnow):
					goals.append(gnow)
				if goals.size() >= 3:
					break
			print("     wander chose %d distinct destinations, legs=%d"
					% [goals.size(), int(w.get("_legs"))])
			_p(goals.size() >= 2,
				"the wandering bot chose more than one destination — A* re-ran live")
			_p(int(w.get("_legs")) >= 1, "and completed at least one leg to get there")

	# SELF-CHECK: a goal in mid-air must be refused, or "found" above means nothing.
	var air: Dictionary = vol.find_path(from, Vector3(0.0, 40.0, 0.0))
	_p(not bool(air.get("found", true)),
		"self-check: a goal floating in the air is refused (%s)" % str(air.get("error", "")))

	vol.queue_free()
	game.queue_free()