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