OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / bots / pathwalker.gd

276 lines · gd ref:
extends RefCounted
## A bot that walks a route the terrain gave it.
##
##   BotRegistry.register("walker1", ["map"], "position",
##       {"kind": "script_file", "path": "res://bots/pathwalker.gd",
##        "params": {"goal": Vector3(40, 0, 40)}})
##
## body = position: a visible marker that MOVES, so "is the path any good" is a
## question you answer by watching rather than by reading a waypoint list.
##
## ⛔ IT ASKS THE TERRAIN, IT DOES NOT SEARCH. dc-terrain owns the portal graph
## (docs/plans/2026-09-15-chunk-portal-nav-design.md) and answers nav_find_path.
## A brain that did its own search would be searching a copy, and the copy would
## be stale the moment a player carved anything.
##
## ⚠ IT RE-PATHS ON A TIMER, WHICH IS A PLACEHOLDER. Terrain edits do not announce
## themselves to a brain, and hyper-games #78 is explicit that D* Lite or ARA*
## should not be built until someone has MEASURED how often an edit actually
## invalidates a path in flight. Until then a periodic re-path is the honest
## placeholder, and its cost is one graph search per interval per bot.

var bot_id: String = ""

## The knobs a placed bot shows (BrainCatalogue.params_of). A placed bot wanders by
## default; a registered one keeps the var defaults below.
const PARAMS := [
	{"id": "wander", "label": "Wander", "kind": "bool", "default": true},
	{"id": "goal", "label": "Walk to", "kind": "vec3", "default": [0.0, 0.0, 0.0], "min": -100000.0, "max": 100000.0, "unit": "m"},
	{"id": "speed", "label": "Speed", "kind": "float", "default": 4.0, "min": 0.5, "max": 20.0, "step": 0.5, "unit": "m/s"},
	{"id": "wander_pause", "label": "Pause", "kind": "float", "default": 1.0, "min": 0.0, "max": 30.0, "step": 0.5, "unit": "s"},
]

## Where to walk to. World space. Ignored when wander is true.
var goal := Vector3.ZERO

## Pick a NEW random destination on arrival, for ever.
##
## This is what makes the search visible as a search: a fixed goal is one A* run
## you have to take on trust, while a bot that keeps choosing new destinations
## re-runs it in front of you, over the graph as it stands at that moment. If
## somebody carves a wall or the builder stacks a block between two legs, the next
## leg routes around it.
var wander: bool = false
## How long to pause on arrival before choosing again — long enough to see that it
## arrived rather than merely turned.
var wander_pause: float = 1.0
var speed: float = 4.0
## How close counts as reaching a waypoint. Below a nav cell (0.5 m at LOD0
## defaults) a bot can orbit a point it never quite touches.
var arrive_radius: float = 0.8
var repath_seconds: float = 4.0

var _pos := Vector3.ZERO
var _have_pos := false
var _waypoints: PackedVector3Array = PackedVector3Array()
var _index: int = 0
var _since_path: float = 999.0
var _last_error: String = ""
var _pause_left: float = 0.0
var _legs: int = 0


func _bot_init() -> void:
	# Say something IMMEDIATELY, so "did the brain start at all" is answerable
	# before anything else can go wrong.
	_publish("state", "init")
	var reg := _registry()
	# start_position: a placed bot's placement, else the id's lattice cell (as before).
	if reg != null and reg.has_method("start_position") and bot_id != "":
		_pos = reg.start_position(bot_id)
		_have_pos = true
	print("[pathwalker] '%s' from %s to %s" % [bot_id, str(_pos), str(goal)])


func _bot_tick(delta: float) -> void:
	_since_path += delta
	if not _have_pos:
		return

	if _pause_left > 0.0:
		_pause_left -= delta
		return

	if _index >= _waypoints.size():
		if _since_path >= repath_seconds:
			_since_path = 0.0
			if wander:
				_choose_new_goal()
			_request_path()
		if _index >= _waypoints.size():
			return

	var target: Vector3 = _waypoints[_index]
	var to_target := target - _pos
	# Horizontal only: the body is placed on the surface by the route, and adding
	# vertical steering here would fight it.
	to_target.y = 0.0
	var dist := to_target.length()
	if dist <= arrive_radius:
		_index += 1
		if _index >= _waypoints.size():
			_legs += 1
			print("[pathwalker] '%s' arrived at %s (leg %d)" % [bot_id, str(_pos), _legs])
			if wander:
				# Choose again NEXT tick, after a visible pause. Choosing here would
				# make arrival and departure the same frame and the bot would look
				# like it never stopped.
				_pause_left = wander_pause
				_since_path = repath_seconds
			_publish("legs", _legs)
			_publish("at", "%.1f,%.1f,%.1f" % [_pos.x, _pos.y, _pos.z])
		return

	_pos += to_target.normalized() * min(speed * delta, dist)
	var reg := _registry()
	if reg != null and reg.has_method("move"):
		reg.move(bot_id, _pos)


func _bot_exit() -> void:
	print("[pathwalker] '%s' stopped after %d/%d waypoints"
			% [bot_id, _index, _waypoints.size()])


## Ask the nav volume for somewhere an agent can actually stand.
##
## ⛔ NOT A RANDOM POINT IN SPACE. Most of a 64 m cube is air, so guessing a
## position and hoping it is navigable wastes nearly every attempt and biases the
## bot toward whatever happens to be open. The graph already knows every standable
## cell; asking it is one call and always yields a reachable-looking goal.
func _choose_new_goal() -> void:
	# ⛔ ACROSS ALL PROVIDERS, not just the first. _terrain() returns whichever
	# child comes first in scene order, and in map_flat that is the DCTerrain --
	# which does not implement random_standable_point at all (only DCNavVolume
	# does). So this returned early every time, goal stayed Vector3.ZERO, and the
	# bot re-walked to the origin forever: 3987 completed legs, 0 destinations.
	# Shuffled rather than first-capable, so a map whose providers each cover
	# different ground gets wandered over instead of only its first one.
	var navs: Array[Node] = []
	for prov in _nav_providers():
		if prov.has_method("random_standable_point"):
			navs.append(prov)
	if navs.is_empty():
		return
	navs.shuffle()
	var p: Vector3 = Vector3.ZERO
	for nav in navs:
		p = nav.random_standable_point()
		if p != Vector3.ZERO:
			break
	if p == Vector3.ZERO:
		return
	goal = p
	print("[pathwalker] '%s' new destination %s" % [bot_id, str(goal)])


func _request_path() -> void:
	var providers := _nav_providers()
	if providers.is_empty():
		# ⛔ NOT A SILENT RETURN. This was one, and it made the bot inert and
		# unexplained: registered, ticking, publishing nothing, with no way from
		# outside the game to tell "there is no nav source here" from "the brain
		# never started". Every path out of this function reports now.
		if _last_error != "no nav source":
			_last_error = "no nav source"
			print("[pathwalker] '%s' found no nav source in this map" % bot_id)
			_publish("error", "no nav source in this map")
		return
	# Ask each provider until one can route from where the bot actually stands.
	# Reporting the LAST failure rather than the first: the first is usually the
	# provider the bot is not on, whose "start is not on the navigable set" says
	# nothing about why the real one refused.
	var r: Dictionary = {}
	var bench_t0 := Time.get_ticks_usec()          # benchmark (#114 §7): route queries
	var bench_q := 0
	for prov in providers:
		r = prov.nav_find_path(_pos, goal)
		bench_q += 1
		if bool(r.get("found", false)):
			break
	SimStats.add("bot.nav", Time.get_ticks_usec() - bench_t0, bench_q)
	if not bool(r.get("found", false)):
		# Reported ONCE per distinct reason. A bot that cannot reach its goal will
		# say so every interval otherwise, and the log stops being readable exactly
		# when it matters.
		var err := String(r.get("error", "unknown"))
		if err != _last_error:
			_last_error = err
			print("[pathwalker] '%s' has no route: %s" % [bot_id, err])
			_publish("error", err)
		_waypoints = PackedVector3Array()
		_index = 0
		return
	_last_error = ""
	_publish("route_nodes", int(r.get("nodes", 0)))
	_publish("goal", "%.1f,%.1f,%.1f" % [goal.x, goal.y, goal.z])
	_waypoints = r.get("waypoints", PackedVector3Array())
	# The route gives crossings, not the destination — append it, or the bot walks
	# to the last doorway and stops in it.
	_waypoints.append(goal)
	_index = 0
	print("[pathwalker] '%s' route: %d waypoints over %d nodes"
			% [bot_id, _waypoints.size(), int(r.get("nodes", 0))])


## Progress on the DURABLE plane.
##
## ⛔ WITHOUT THIS THE BOT IS UNVERIFIABLE FROM OUTSIDE THE GAME. Its position rides
## world:bots:pos:, which is a REALTIME route — sent over the mesh, never stored —
## so a state query returns null whether it is striding across the map or standing
## still. Its prints go to stdout, which the dedicated peer's TUI swallows. "I
## registered it" is not "it works", and this is the difference. Same key space and
## same reasoning as bots/builder.gd.
func _publish(what: String, value: Variant) -> void:
	var tree := Engine.get_main_loop() as SceneTree
	if tree == null or bot_id == "":
		return
	var gs := tree.root.get_node_or_null(^"GameState")
	if gs != null and gs.has_method("set_state"):
		gs.set_state("state:bot:%s:%s" % [bot_id, what], value)


func _registry() -> Node:
	var tree := Engine.get_main_loop() as SceneTree
	return tree.root.get_node_or_null(^"BotRegistry") if tree != null else null


func _terrain() -> Node:
	var tree := Engine.get_main_loop() as SceneTree
	if tree == null:
		return null
	var game := tree.root.get_node_or_null(^"Game")
	if game == null:
		return null
	var map: Variant = game.get("current_map")
	if map == null or not is_instance_valid(map):
		return null
	# Anything that answers nav_find_path. DCTerrain owns a graph built from its
	# density as it meshes; DCNavVolume owns one built from collision boxes. A bot
	# should not care which map it is standing in.
	for child in (map as Node).get_children():
		if child.has_method("nav_find_path"):
			return child
	return null


## EVERY nav provider in the current map, in scene order.
##
## ⛔ A map can have MORE THAN ONE, with DISJOINT graphs. map_flat gained a
## DCTerrain ("MountainTerrain") alongside its own DCNavVolume in 4ecb89e, and
## their navigable sets do not overlap -- 20 chunks/4096 standable for the
## mountain against 16/4084 for the slab. _terrain() returns the FIRST match, so
## a bot standing on the slab asked the mountain and got
## "start is not on the navigable set": inert, in a spot it was plainly standing on.
##
## There is no cheap containment query to pick by -- nav_find_path is the only
## method both providers expose (DCTerrain adds nav_graph_stats/_links,
## DCNavVolume adds random_standable_point, neither has "is this point on the
## set"). So selection is by ATTEMPT: ask each until one answers, which is also
## the only check that cannot disagree with the pathfinder it is predicting.
func _nav_providers() -> Array[Node]:
	var out: Array[Node] = []
	var tree := Engine.get_main_loop() as SceneTree
	if tree == null:
		return out
	var game := tree.root.get_node_or_null(^"Game")
	if game == null:
		return out
	var map: Variant = game.get("current_map")
	if map == null or not is_instance_valid(map):
		return out
	for child in (map as Node).get_children():
		if child.has_method("nav_find_path"):
			out.append(child)
	return out