OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / bot_registry.gd

1269 lines · gd ref:
extends Node
## The bot registry — spec/bot-testbed.md (e2).
##
## ⚠ NO `class_name`: registered as the autoload singleton `BotRegistry`, and a
## global class of the same name makes Godot refuse to compile it. The same rule
## script_host.gd carries, for the same reason.
##
## WHAT THIS OWNS, AND WHY IT IS ONE OBJECT. A bot has three lifetimes — its
## statebase keys, its scene-tree node, and whoever runs its brain — and until now
## each was declared somewhere different, which is precisely how the creature leak
## happened: the data was map-scoped and the node was not, and nothing made them
## agree. So containment is ONE declaration here and the three are DERIVED. No bot
## code chooses its own parent, because that choice is the one creatures got wrong
## and it cannot be got right by remembering three things in three files.
##
## ⛔ `(scope, owner)` WAS THE WRONG SHAPE, twice over, and the corrections are the
## design:
##
##   `owner` is not a declaration. Owned bots are room-authoritative, so there is
##   nothing per-bot to declare. What a player holds over "their" bot is COMMAND
##   RIGHTS — an existing role-gated write — not authority. And "my bot dies when I
##   leave" is a lifetime, which is a binding.
##
##   `scope` is not a single value. A player-owned bot in a map must die if the map
##   changes OR if that player leaves. Two bindings, and the bot dies when the
##   FIRST one ends. Death is their UNION, not their intersection.
##
## EVENT-DRIVEN, NO TIMERS. Every binding has a real signal behind it. Nothing here
## polls for liveness, and nothing scans on a tick.

## A bot is now registered and its brain (if any) is running.
signal bot_registered(id: String)
## A bot is gone. `reason` is the binding that ended, or "explicit".
signal bot_despawned(id: String, reason: String)

# ---- bindings ---------------------------------------------------------------

const BINDING_MAP := "map"
const BINDING_ROOM := "room"
const BINDING_SESSION := "session"
## `peer:<id>` — dies when that peer leaves. The prefix, not a whole value.
const BINDING_PEER_PREFIX := "peer:"

## Leaking must not be the default. A bot that wants to outlive the map says so.
const DEFAULT_BINDINGS: Array[String] = [BINDING_MAP]

# ---- bodies -----------------------------------------------------------------

## No node at all: a brain that acts through state and the edit journal. This is
## the builder bot, and it needs no player body, no synchronizer and no authority
## over a character — which is why it lands first.
const BODY_NONE := "none"
## A cheap position-only entity, replicated like a creature (10 Hz pos array).
const BODY_POSITION := "position"
## A full player body with InputSync + PlayerSync. The only genuinely new netcode.
const BODY_PLAYER := "player"

## A body that is a SCENE OBJECT (the `bot` prefab, bot MVP 2026-10-04): placed,
## persisted, undone and deleted through the object journal, so it never goes
## through register() and has no registry row — the journal IS its declaration.
## It attach()es here so its brain runs on the authority and every other peer
## steers the body toward the replicated position. The node is the OBJECT
## system's; this registry never frees it.
const BODY_OBJECT := "object"

## CREATURE bodies (creatures → bots, #114 phase 3): the same GroundCrawler / Hopper /
## FlyingSerpent a placed bot object builds, for a REGISTERED bot — what a spawner
## object makes. Built on every peer from the registry row (id-hash seed, so every peer
## builds the same creature), ticked by the scheduler, steered by set_target.
const CREATURE_BODIES: Array[String] = ["crawler", "hopper", "serpent"]

const IMPLEMENTED_BODIES: Array[String] = [BODY_NONE, BODY_POSITION, "crawler", "hopper", "serpent"]


## True for a body this registry builds a node for (a marker or a creature).
static func _has_node_body(body: String) -> bool:
	return body == BODY_POSITION or CREATURE_BODIES.has(body)

## Durable "this bot exists"; realtime "it is here".
##
## ⛔ TWO NAMESPACES, CHOSEN BY THE BINDING. These were one pair of constants under
## world:bots:, which STATE_ROUTES scopes to the map unconditionally — so a room-
## or session-bound bot got a MAP-SCOPED row. Its node is parented to this
## autoload and survives a map change while the row announcing it is dropped with
## the old map's durable state: binding and namespace disagreeing for exactly the
## non-map bindings. Found by standing up the bot matrix, where a room-bound bot's
## row landed at map:map_flat:world:bots:registry:mx-pos-room.
##
## spec/bot-testbed.md (e2) already specified the rule under "The three
## derivations" — the key prefix is "the same narrowest binding, so eviction by
## prefix is mechanically possible". The node parent was derived that way and the
## key prefix was not derived at all.
##
## Two are enough, because eviction only has to distinguish "dropped with the map"
## from "not":
##   world:bots:  map-bound       map-scoped, evicted with the map
##   room:bots:   room / session  room-global, survives a map change
##
## The prefix also travels: a REMOTE peer reads which namespace a row arrived in
## and parents the body accordingly, so containment is honoured on the rendering
## side too rather than only where the bot was registered.
const KEY_MAP_REGISTRY := "world:bots:registry:"
const KEY_MAP_POS := "world:bots:pos:"
const KEY_ROOM_REGISTRY := "room:bots:registry:"
const KEY_ROOM_POS := "room:bots:pos:"

## Kept so existing readers and gates that name the map-bound pair still resolve.
const KEY_REGISTRY := KEY_MAP_REGISTRY
const KEY_POS := KEY_MAP_POS


## True when this bot's keys belong to the map's namespace.
static func _is_map_bound(bindings: Array) -> bool:
	return bindings.has(BINDING_MAP)


static func _key_registry(bindings: Array) -> String:
	return KEY_MAP_REGISTRY if _is_map_bound(bindings) else KEY_ROOM_REGISTRY


static func _key_pos(bindings: Array) -> String:
	return KEY_MAP_POS if _is_map_bound(bindings) else KEY_ROOM_POS

## Placeholder visual for a position body. Deliberately crude: the point of this
## body is that it REPLICATES, not that it looks like anything.
const MARKER_SIZE := Vector3(0.6, 1.2, 0.6)

# ---- brains -----------------------------------------------------------------

## {kind = "script", name = "<published .gd name>"} — via ScriptHost + AssetSync.
## The MESH path: the bytes reach every peer by pointer, so every peer can run the
## same brain. This is what a bot published to a room uses.
const BRAIN_SCRIPT := "script"
## {kind = "script_file", path = "res://bots/x.gd"} — a LOCAL brain, run from disk
## with no overlay involved.
##
## Why both exist: "script" needs the bytes published before anything can run, so
## with no overlay there is no way to start a brain at all — no dev loop, and
## nothing end-to-end testable. This is the authored path (edit a file, run it),
## and it does NOT propagate: only the peer holding the file runs it. Publish it to
## give it to the room.
const BRAIN_SCRIPT_FILE := "script_file"
## {kind = "none"} — a bot with no brain is legal: a placeholder, or a body driven
## entirely from outside by bot.drive / an external controller.
const BRAIN_NONE := "none"

const IMPLEMENTED_BRAINS: Array[String] = [BRAIN_SCRIPT, BRAIN_SCRIPT_FILE, BRAIN_NONE]

## id -> {bindings: Array[String], body: String, brain: Dictionary, node: Node}
var _bots: Dictionary = {}

## id -> Node3D for bots some OTHER peer registered, rebuilt from replicated state.
##
## ⛔ ONE CLASS OWNS BOTH, on purpose. Creatures split this into a spawner that
## writes and a listener that renders, and that split IS the leak: the two had
## different lifetimes, the visuals outlived the map, and the dictionary holding
## the only handle to them died with the listener, leaving nodes nothing could
## ever free. Local and remote bodies are built and destroyed by the same object
## here, so there is one lifetime and one handle.
var _remote: Dictionary = {}

## Where a map-bound body is parented. Normally resolved from Game.current_map;
## injectable so a gate can supply a stand-in map without booting the game.
var map_container: Node = null

## Injected for tests, the shape asset_sync.gd and script_host.gd already use.
var state: Object = null
var scripts: Object = null


func _ready() -> void:
	_register_admin()
	if state == null:
		state = get_node_or_null(^"/root/GameState")
	if scripts == null:
		scripts = get_node_or_null(^"/root/ScriptHost")
	_connect_bindings()
	set_physics_process(false)          # on while a body is scheduled
	# KeetService builds peer_identity in ITS _ready; autoload order is not ours to assume.
	_connect_names.call_deferred()


# ---- one scheduler for every bot body (creatures → bots, #114 phase 1) ------------

## SIMULATION TIERS + SLEEP for bot bodies (CreatureScheduler, #114). Creatures and bots
## are ONE concept (user 2026-10-13: "creatures being different than bots is technically
## an implementation bug"); the tiers were the spawner's alone, so a placed bot's body
## ticked at the full physics rate however far away it stood. Every peer schedules the
## bodies it builds — the gait is drawn locally everywhere — by its nearest observer.
const RETIER_INTERVAL := 0.5

var _sched := CreatureScheduler.new()
var _sched_frame := 0
var _retier_left := 0.0


## A body that ticks itself (sim_tick) is ticked from here at its tier's rate instead.
## Unschedule it BEFORE freeing it.
func schedule_body(b: Node3D) -> void:
	if b == null or not b.has_method("sim_tick"):
		return
	_sched.add(b, _observers())
	set_physics_process(true)


func unschedule_body(b: Node3D) -> void:
	if b != null:
		_sched.remove(b)


## For gates and benchmarks (tier_of, ticks, tick_count).
func scheduler() -> CreatureScheduler:
	return _sched


## A registered bot's body node, or null (body=none, or not registered here).
func body_of(id: String) -> Node3D:
	var n: Variant = (_bots.get(id, {}) as Dictionary).get("node")
	return n as Node3D if n is Node3D and is_instance_valid(n) else null


## True if this peer is the elected game authority (runs brains and spawners).
func is_authority() -> bool:
	return _is_authority()


# ---- authority hand-off (creatures → bots, #114 phase 5) -------------------------

## This peer became, or stopped being, the elected authority (spawner objects listen).
signal authority_changed(is_auth: bool)

var _auth_was := true                # no mesh = authority (_is_authority's fallback)


## THE ELECTION CHANGED HANDS (menu_main._elect_authority calls this). Owned bots are
## room-authoritative: exactly one peer runs their brains. The creature spawner used to
## handle this for creatures alone (released its nodes to the listener on losing
## authority, cleared orphans on taking it); bots had no hand-off at all, so after a
## re-election the OLD authority kept running every brain and the new one ran none.
##
## LOSING it: stop the brains we run and keep drawing the bodies from the replicated
## rows, exactly like any other peer — the rows stay; the new authority carries on.
## TAKING it: what we only drew we now run — bot objects re-attach as simulators, and
## every declared row is revived (its brain started) in place of its passive body.
func on_authority_changed() -> void:
	var now := _is_authority()
	if now == _auth_was:
		return
	_auth_was = now
	if not now:
		for id: String in _bots.keys():
			_release(id)
	else:
		var objs: Array = _attached_remote.values()
		_attached_remote.clear()
		for n: Variant in objs:
			if n is Node and is_instance_valid(n) and (n as Node).has_method("_restart"):
				(n as Node).call("_restart")       # attach() again, now as the simulator
		if state != null:
			for prefix: String in [KEY_MAP_REGISTRY, KEY_ROOM_REGISTRY]:
				for k: String in state.keys_with_prefix(prefix):
					var id := k.substr(prefix.length())
					if _bots.has(id):
						continue
					_drop_remote(id)            # its passive body; the revival builds the live one
					_on_state_changed(k, state.get_state(k), 0)
	print("[BotRegistry] authority %s" % ("TAKEN: running the room's bots" if now else "LOST: drawing bots only"))
	authority_changed.emit(now)


## Stop running `id` here without retiring it: brain unloaded, no rows touched, its body
## kept and steered from the position rows like any remote bot's.
func _release(id: String) -> void:
	var rec: Dictionary = _bots[id]
	var brain: Dictionary = rec.get("brain", {}) as Dictionary
	if scripts != null:
		match String(brain.get("kind", BRAIN_NONE)):
			BRAIN_SCRIPT:
				scripts.unload(String(brain.get("name", "")))
			BRAIN_SCRIPT_FILE:
				scripts.unload(id)
	var node: Variant = rec.get("node")
	_bots.erase(id)
	if not (node is Node3D) or not is_instance_valid(node):
		return
	if String(rec.get("body", "")) == BODY_OBJECT:
		_attached_remote[id] = node
	else:
		_remote[id] = node


## Who can see a body: every player in the mesh, and this peer's camera (the build-mode
## camera flies away from the player body, and it is what is looking).
func _observers() -> PackedVector3Array:
	var out := PackedVector3Array()
	if not is_inside_tree():
		return out
	for p: Node in get_tree().get_nodes_in_group("player"):
		if p is Node3D and is_instance_valid(p):
			out.append((p as Node3D).global_position)
	var vp := get_viewport()
	var cam := vp.get_camera_3d() if vp != null else null
	if cam != null:
		out.append(cam.global_position)
	return out


func _physics_process(delta: float) -> void:
	if _sched.size() == 0:
		set_physics_process(false)
		return
	_retier_left -= delta
	if _retier_left <= 0.0:
		_retier_left = RETIER_INTERVAL
		var obs := _observers()
		if not obs.is_empty():              # nobody looking yet: keep the tiers it has
			_sched.retier(obs)
	_sched.step(delta, _sched_frame)
	_sched_frame += 1


## A bot bound to a peer is named after that peer, so its label follows the
## peer's verified keet name (claims, roster, the peer leaving).
func _connect_names() -> void:
	var keet: Node = get_node_or_null(^"/root/KeetService")
	var pi: Variant = keet.get("peer_identity") if keet else null
	if pi is Node and not (pi as Node).names_changed.is_connected(_relabel_markers):
		(pi as Node).names_changed.connect(_relabel_markers)


func _relabel_markers() -> void:
	for id: String in _bots:
		var rec: Dictionary = _bots[id]
		var node: Variant = rec.get("node")
		if not (node is Node) or not is_instance_valid(node):
			continue
		var label := (node as Node).get_node_or_null("Label") as Label3D
		if label != null:
			label.text = _marker_text(id, rec.get("bindings", []) as Array, String(rec.get("body", BODY_POSITION)),
					String((rec.get("brain", {}) as Dictionary).get("kind", BRAIN_NONE)))


## "<name>.bot" — the verified keet name of the peer it is bound to (`peer:<id>`),
## else its own id (#89 §3.3: a bot must never pass for a person, and should say
## whose it is). The second line is the testbed's declaration in words.
func _marker_text(id: String, bindings: Array, body: String, brain_kind: String) -> String:
	return "%s.bot\n%s · %s · %s" % [_bot_display_name(id, bindings), _binding_word(bindings), body, brain_kind]


func _bot_display_name(id: String, bindings: Array) -> String:
	var keet: Node = get_node_or_null(^"/root/KeetService")
	var pi: Variant = keet.get("peer_identity") if keet else null
	for b: Variant in bindings:
		var s := String(b)
		if s.begins_with(BINDING_PEER_PREFIX) and pi is Node:
			return (pi as Node).name_for_peer(int(s.substr(BINDING_PEER_PREFIX.length())))
	return id


## Every binding has a signal. A binding with no event behind it would be a
## promise this cannot keep, which is why `room` needed GameState.room_changing
## added rather than being folded onto map_changing — reset_for_room_switch emits
## BOTH, so a room-scoped bot torn down by an ordinary map change would have been
## the same class of bug this registry exists to stop.
func _connect_bindings() -> void:
	if state != null:
		if state.has_signal("state_changed") and not state.state_changed.is_connected(_on_state_changed):
			state.state_changed.connect(_on_state_changed)
		if state.has_signal("map_changing") and not state.map_changing.is_connected(_on_map_changing):
			state.map_changing.connect(_on_map_changing)
		if state.has_signal("room_changing") and not state.room_changing.is_connected(_on_room_changing):
			state.room_changing.connect(_on_room_changing)
	# peer_disconnected is what makes `peer:<id>` real. multiplayer is null outside
	# the tree, so this runs from _ready and not from a constructor.
	if multiplayer != null and not multiplayer.peer_disconnected.is_connected(_on_peer_left):
		multiplayer.peer_disconnected.connect(_on_peer_left)


# ---- registration -----------------------------------------------------------

## Register a bot. Returns {ok: bool, error: String}.
##
## Validation REFUSES rather than degrades. A bot registered with a body this
## cannot build, or a binding with no event behind it, would look registered and
## quietly never work — and an unknown binding is the dangerous one, because it
## silently means "no teardown for that container", which is the leak again.
##
## `at`: where a body starts (a Vector3, in its container's space) — a spawner object
## puts its bots near players. Default: the id's lattice cell (_spawn_offset_for). It is
## stored in the row, so every peer and every revival builds the body in the same place.
func register(id: String, bindings: Array = [], body: String = BODY_NONE,
		brain: Dictionary = {}, at: Variant = null) -> Dictionary:
	if id == "":
		return {"ok": false, "error": "id must not be empty"}
	if _bots.has(id):
		return {"ok": false, "error": "already registered: " + id}

	var b: Array[String] = _normalise_bindings(bindings)
	var bad: String = _first_invalid_binding(b)
	if bad != "":
		return {"ok": false, "error": "unknown binding '%s'" % bad}

	if not IMPLEMENTED_BODIES.has(body):
		return {"ok": false, "error": "body '%s' not implemented yet (have %s)"
				% [body, str(IMPLEMENTED_BODIES)]}

	var kind: String = String(brain.get("kind", BRAIN_NONE))
	if not IMPLEMENTED_BRAINS.has(kind):
		return {"ok": false, "error": "unknown brain kind '%s'" % kind}

	var node: Node = null
	var spawn: Vector3 = at if at is Vector3 else _spawn_offset_for(id)
	if _has_node_body(body):
		var host: Node = _container_for(b)
		if host == null:
			return {"ok": false, "error":
				"body=%s bound to 'map' needs a map to live in, and there is none" % body}
		node = _make_body(id, b, body, kind)
		# NOT add_child YET. The durable row can be refused, and a node already in
		# the tree would then have to be rolled back -- with queue_free, which is
		# DEFERRED, so the orphan is still findable for the rest of the frame. Build
		# it, prove the write landed, and only then attach: a refused body never
		# enters the tree at all and there is nothing to undo.
		if state != null:
			# Durable identity first, then position: a peer that sees the position
			# before it knows the bot exists has nothing to attach it to.
			var row := {"body": body, "brain": _brain_row(brain),
				"ts": Time.get_unix_time_from_system()}
			if at is Vector3:
				row["pos"] = [spawn.x, spawn.y, spawn.z]
			# ⛔ RESERVE THE ID FIRST. GameState emits state_changed INSIDE set_state, so
			# our own row comes straight back to _on_state_changed — and with the id not
			# yet in _bots the authority REVIVED it, building a second body that this
			# function then orphaned: a twin. (Gates on a permissive stand-in state, which
			# emits nothing, never saw it; gate_ghost_creatures C2 on the real one does.)
			_bots[id] = {"bindings": b, "body": body, "brain": brain, "node": null}
			state.set_state(_key_registry(b) + id, row)
			# ⛔ VERIFY IT LANDED. set_state DROPS a durable write whose can_write
			# gate refuses -- it returns before touching the local mirror and tells
			# the caller nothing. world:bots:registry: is role "mod", so a plain
			# peer registering a position body got a node, a position row, a
			# roster entry and an "ok" for a bot NO OTHER PEER WOULD EVER SEE,
			# because the row that announces it never existed. LevelPersistence
			# learned this the same way and says so at record_at.
			if not state.has_state(_key_registry(b) + id):
				_bots.erase(id)      # the reservation
				node.free()          # never attached, so free() and not queue_free()
				return {"ok": false, "error":
					"durable registry row refused (world:bots:registry: needs role mod)"}
			state.set_state(_key_pos(b) + id, [spawn.x, spawn.y, spawn.z])
		host.add_child(node)
		_settle_body(id, node as Node3D, spawn)

	_bots[id] = {"bindings": b, "body": body, "brain": brain, "node": node}

	if kind == BRAIN_SCRIPT or kind == BRAIN_SCRIPT_FILE:
		var started: Dictionary = _start_script_brain(id, brain)
		if not started.get("ok", false):
			_bots.erase(id)
			return started

	bot_registered.emit(id)
	return {"ok": true, "id": id, "bindings": b, "body": body}


## Run a published .gd as this bot's brain.
##
## ⚠ PASSES session BINDINGS TO ScriptHost DELIBERATELY. ScriptHost honours its own
## map binding and would unload the script on a map change — correct when a script
## IS the bot, wrong here, because then two owners tear down the same thing and the
## registry's despawn (which also emits and cleans the roster) could run against a
## script already gone. One owner: the registry.
## Does this brain's script exist, asked the way the path can answer?
##
## ⛔ FileAccess CANNOT SEE A PACKED SCRIPT. GDScript exports as .gdc binary
## tokens, so in an exported build res://bots/beacon.gd does not exist under that
## name — beacon.gdc does — and FileAccess.file_exists() returns false for every
## shipped brain. _start_script_brain therefore refused each one with "no such
## script" BEFORE ScriptHost was reached, even after run_file learned to load()
## packed scripts. Same defect as hyper-games #77 item 3, in a second place, and
## the fix for run_file did not cover it.
##
## Found by registering the bot matrix against the DEPLOYED build. No gate saw
## it, because from the source tree res://bots/*.gd really are files and both
## checks answer true — which is why the gate for this is structural.
static func _brain_script_exists(path: String) -> bool:
	if path.begins_with("res://"):
		return ResourceLoader.exists(path)
	return FileAccess.file_exists(path)


func _start_script_brain(id: String, brain: Dictionary) -> Dictionary:
	if scripts == null:
		return {"ok": false, "error": "no ScriptHost — cannot run a script brain"}

	# The LOCAL path runs straight from disk. The script is named after the BOT, not
	# after the file, so despawn can unload it by bot id without having to remember
	# which file it came from.
	if String(brain.get("kind", "")) == BRAIN_SCRIPT_FILE:
		var path: String = String(brain.get("path", ""))
		if path == "":
			return {"ok": false, "error": "brain.kind=script_file needs a 'path'"}
		if not _brain_script_exists(path):
			return {"ok": false, "error": "no such script: " + path}
		# The brain is told which bot it IS, so it can move its own body, plus any
		# params the declaration carries.
		#
		# ⛔ bot_id IS APPLIED LAST AND WINS. params arrive from an admin:cmd, so a
		# caller could otherwise pass {"bot_id": "someone-else"} and have the brain
		# act as another bot — move() and despawn() are keyed on it.
		var ctx: Dictionary = {}
		var declared: Variant = brain.get("params", {})
		if declared is Dictionary:
			ctx = (declared as Dictionary).duplicate()
		ctx["bot_id"] = id
		if scripts.run_file(path, id, [BINDING_SESSION], ctx) == null:
			return {"ok": false, "error": "script failed to compile or instantiate: " + path}
		return {"ok": true}

	var name: String = String(brain.get("name", ""))
	if name == "":
		return {"ok": false, "error": "brain.kind=script needs a 'name'"}
	# Not an error if the bytes are not here yet: the fetch stays open and runs when
	# a peer with them joins. Same reconciliation AssetSync already provides.
	scripts.sync_and_run(name, [BINDING_SESSION])
	return {"ok": true}


## Replace a bot's bindings. THE EXPLICIT TRAVEL OPERATION: a bot that should cross
## maps calls this, and a bot that should not simply cannot, because nothing else
## moves it. Leaking is not "discouraged" here, it is unreachable.
func rebind(id: String, bindings: Array) -> Dictionary:
	if not _bots.has(id):
		return {"ok": false, "error": "not registered: " + id}
	var b: Array[String] = _normalise_bindings(bindings)
	var bad: String = _first_invalid_binding(b)
	if bad != "":
		return {"ok": false, "error": "unknown binding '%s'" % bad}
	(_bots[id] as Dictionary)["bindings"] = b
	_relabel_markers()  # a new peer:<id> binding renames it
	return {"ok": true, "id": id, "bindings": b}


## Remove a bot. Safe for an unknown id, so a teardown racing an explicit despawn
## is not an error.
func despawn(id: String, reason: String = "explicit") -> Dictionary:
	if not _bots.has(id):
		return {"ok": true, "id": id, "already_gone": true}
	var rec: Dictionary = _bots[id]
	var brain: Dictionary = rec.get("brain", {}) as Dictionary
	var bkind: String = String(brain.get("kind", BRAIN_NONE))
	if scripts != null:
		if bkind == BRAIN_SCRIPT:
			scripts.unload(String(brain.get("name", "")))
		elif bkind == BRAIN_SCRIPT_FILE:
			scripts.unload(id)   # registered under the bot id, see _start_script_brain
	var node: Variant = rec.get("node")
	# A bot OBJECT's node is the object system's (journal-owned): freeing it here would
	# delete a placed object behind the journal's back. Its brain stops; the object
	# stays until it is deleted the journalled way.
	var is_object := String(rec.get("body", BODY_NONE)) == BODY_OBJECT
	if not is_object:
		_free_body(node)
	if is_object and state != null:
		state.erase_state(KEY_MAP_POS + id)
	if _has_node_body(String(rec.get("body", BODY_NONE))) and state != null:
		var db: Array = rec.get("bindings", []) as Array
		# Erase the POSITION row whatever ended the bot: it is what other peers
		# render from, and leaving it would resurrect a visual on any peer that
		# reloads room state -- the data half of the creature leak.
		state.erase_state(_key_pos(db) + id)
		# ⛔ THE DECLARATION IS NOT PRESENCE. The registry row says "this bot
		# belongs in this map"; the position row says "it is alive right now".
		# Erasing the row on every despawn conflated them, so leaving a map DELETED
		# the declarations of the bots that belong to it and re-entering had nothing
		# to revive -- measured on stortron, where map_flat's load delivered 145
		# entries every poll and revived nothing.
		#
		# A containment teardown (map/room/peer ended) ends the INSTANCE. Only
		# someone actually retiring the bot ends the DECLARATION.
		if reason == "explicit":
			state.erase_state(_key_registry(db) + id)
	_bots.erase(id)
	bot_despawned.emit(id, reason)
	return {"ok": true, "id": id, "reason": reason}


# ---- teardown: any binding's end ends the bot --------------------------------

func _on_map_changing(_map_id: String) -> void:
	_despawn_bound_to(BINDING_MAP, "map")
	_clear_remote()
	_attached_remote.clear()      # those nodes are the old map's objects
	# HELD ROWS bound to the map we are LEAVING: they waited for that map to exist, and
	# _process would otherwise revive them into whatever map exists next — a bot from
	# map A standing in map B (ghost bots, demo sprint §5; gate_ghost_creatures B1). The
	# new map's own rows arrive with its room-state load.
	for id: String in _pending_rows.keys():
		var held: Dictionary = _pending_rows[id]
		if (held.get("bindings", []) as Array).has(BINDING_MAP):
			_pending_rows.erase(id)


func _on_room_changing(_room_id: String) -> void:
	# A room switch ends room-scoped AND map-scoped bots: leaving the world is
	# strictly bigger than changing the map within it. reset_for_room_switch emits
	# map_changing too, so the map half would fire anyway — doing it here as well is
	# harmless because despawn() is idempotent, and it keeps the rule readable:
	# a bigger container ending takes everything inside it.
	_despawn_bound_to(BINDING_ROOM, "room")
	_despawn_bound_to(BINDING_MAP, "room")


func _on_peer_left(peer_id: int) -> void:
	_despawn_bound_to(BINDING_PEER_PREFIX + str(peer_id), "peer_left")


func _despawn_bound_to(binding: String, reason: String) -> void:
	var doomed: Array[String] = []
	for id: String in _bots.keys():
		var b: Array = (_bots[id] as Dictionary).get("bindings", []) as Array
		if b.has(binding):
			doomed.append(id)
	for id: String in doomed:
		print("[BotRegistry] %s ended: despawning '%s'" % [reason, id])
		despawn(id, reason)


# ---- reads ------------------------------------------------------------------

func list() -> Array:
	var out: Array = []
	for id: String in _bots.keys():
		var r: Dictionary = _bots[id]
		out.append({
			"id": id,
			"bindings": r.get("bindings", []),
			"body": r.get("body", BODY_NONE),
			"brain": String((r.get("brain", {}) as Dictionary).get("kind", BRAIN_NONE)),
		})
	return out


func bindings_of(id: String) -> Array:
	return (_bots[id] as Dictionary).get("bindings", []) as Array if _bots.has(id) else []


func is_registered(id: String) -> bool:
	return _bots.has(id)


# ---- helpers ----------------------------------------------------------------

func _normalise_bindings(bindings: Array) -> Array[String]:
	var out: Array[String] = []
	for b: Variant in bindings:
		var s := String(b)
		if s != "" and not out.has(s):
			out.append(s)
	if out.is_empty():
		out.assign(DEFAULT_BINDINGS)
	return out


## "" if every binding is known. An unknown binding must REFUSE registration: it
## would silently mean "this container never tears me down".
func _first_invalid_binding(bindings: Array[String]) -> String:
	for b: String in bindings:
		if b == BINDING_MAP or b == BINDING_ROOM or b == BINDING_SESSION:
			continue
		if b.begins_with(BINDING_PEER_PREFIX) and b.length() > BINDING_PEER_PREFIX.length():
			continue
		return b
	return ""


## Reachable from the TUI and admin-cli, the same surface ScriptHost uses.
##
## Writes are "mod": spawning something the room then simulates is privileged
## world authoring, the judgement world:creatures:registry: already makes by
## carrying role mod. Reads are "peer".
##
## Scope is "local" for every method, which is the opposite of the level journal
## and correct for the opposite reason: a bot must exist on EVERY peer that will
## render or tear it down, so each peer registers its own record. There is no
## value to collide, unlike a value-returning call whose result would race on one
## admin:result:<seq> key — `list` is the exception and is local too, so a caller
## reads its own roster rather than an arbitrary peer's.
func _register_admin() -> void:
	add_to_group("admin_controllable")
	set_meta("admin_scope", "global")
	set_meta("admin_name", "bots")
	set_meta("admin_exposed_methods", [
		"register", "rebind", "despawn", "list", "bindings_of", "is_registered",
	])
	# ⛔ THE MUTATORS ARE "authority". They were all "local", so every peer that
	# saw the admin:cmd: ran them, and a body=none brain that drives the local
	# player therefore started walking EVERY peer's avatar and took every
	# player's keyboard -- the brain sets InputSync.driven = true on whatever
	# body it finds. hyper-games #77 item 6, worked around at the time by calling
	# /global/scripts.run_file instead, which defaults to "authority".
	#
	# It was wrong for both bodies, not just body=none. A body=position register()
	# writes the durable world:bots:registry: row and the realtime
	# world:bots:pos: row, and every other peer already builds the marker and
	# follows the position from those rows alone (_on_state_changed). Running it
	# per-peer means every peer writes the same durable row and starts a second
	# simulator for one bot. move() says it directly a few lines down: owned bots
	# are room-authoritative, the authority moves them, everyone else renders.
	#
	# ⚠ The QUERIES are deliberately left "local", and that is not obviously
	# right. They return per-peer answers, and under "local" every peer publishes
	# to the same admin:result:<seq>, which is LWW -- so the caller gets an
	# arbitrary peer's roster, the same race admin_facade.gd documents at its
	# scope check. Making them "authority" would remove the ability to ask one
	# specific peer what IT thinks it is running, which is a real debugging need.
	# That trade deserves its own decision, not a quiet change inside a bug fix.
	set_meta("admin_call_scopes", {
		"register": "authority", "rebind": "authority", "despawn": "authority",
		"list": "local", "bindings_of": "local", "is_registered": "local",
	})
	set_meta("admin_roles", {
		"list": {"call": "peer"},
		"bindings_of": {"call": "peer"},
		"is_registered": {"call": "peer"},
		"register": {"call": "mod"},
		"rebind": {"call": "mod"},
		"despawn": {"call": "mod"},
	})


# ---- position bodies --------------------------------------------------------

# ---- object bodies (the `bot` scene object) ----------------------------------

## id -> node for bot OBJECTS this peer RENDERS (it is not the authority). The
## authority's attached bots live in _bots like any other, with body "object".
var _attached_remote: Dictionary = {}


## A bot scene object came into being on this peer. On the AUTHORITY its brain
## starts (one simulator per bot); everywhere else the node waits for replicated
## positions. Idempotent: attaching again replaces the previous attachment, which
## is how a changed brain / setting restarts the bot.
##
## `brain` is {kind = "script_file", path, params} — the object resolved the path
## through BrainCatalogue, so no peer-supplied path reaches ScriptHost.
func attach(id: String, node: Node3D, brain: Dictionary) -> Dictionary:
	detach(id)
	if id == "" or node == null:
		return {"ok": false, "error": "attach needs an id and a node"}
	if not _is_authority():
		_attached_remote[id] = node
		return {"ok": true, "id": id, "role": "render"}
	_bots[id] = {"bindings": [BINDING_MAP], "body": BODY_OBJECT, "brain": brain, "node": node}
	var kind: String = String(brain.get("kind", BRAIN_NONE))
	if kind == BRAIN_SCRIPT_FILE:
		var started: Dictionary = _start_script_brain(id, brain)
		if not bool(started.get("ok", false)):
			push_warning("[BotRegistry] bot '%s' attached but its brain did not start: %s"
					% [id, String(started.get("error", "?"))])
	bot_registered.emit(id)
	return {"ok": true, "id": id, "role": "simulate"}


## The bot object is going (deleted, undone, its map ended, or re-attaching). Stops
## its brain and its position row; never frees the node — the object owns it.
##
## `node`: when given, detach ONLY if that node is the one attached. A deleted bot is
## freed DEFERRED, so its _exit_tree can fire after an undo already attached a NEW
## node under the same id — an unconditional detach there killed the new bot's brain.
func detach(id: String, node: Node = null) -> void:
	if node != null:
		var cur: Variant = _attached_remote.get(id, (_bots.get(id, {}) as Dictionary).get("node"))
		if cur != null and cur != node:
			return
	_attached_remote.erase(id)
	if not _bots.has(id) or String((_bots[id] as Dictionary).get("body", "")) != BODY_OBJECT:
		return
	if scripts != null:
		scripts.unload(id)
	if state != null:
		state.erase_state(KEY_MAP_POS + id)
	_bots.erase(id)
	bot_despawned.emit(id, "detached")


## True for an attached bot OBJECT this peer simulates.
func is_object(id: String) -> bool:
	return String((_bots.get(id, {}) as Dictionary).get("body", "")) == BODY_OBJECT


## Where a brain should START: an attached bot object's placement, else the
## id-derived lattice cell every peer agrees on (_spawn_offset_for).
func start_position(id: String) -> Vector3:
	var rec: Dictionary = _bots.get(id, {})
	var node: Variant = rec.get("node")
	var body := String(rec.get("body", ""))
	if (body == BODY_OBJECT or _has_node_body(body)) and node is Node3D and is_instance_valid(node):
		return (node as Node3D).global_position        # a spawned creature roams where it began
	return _spawn_offset_for(id)


## Move a position body. Writes the realtime row every peer renders from.
##
## ⚠ ONLY THE SIMULATING PEER SHOULD CALL THIS. Owned bots are room-authoritative,
## so the authority moves them and everyone else renders the result; two peers
## calling move() for one bot is last-write-wins jitter, not a merge. The check is
## left to the caller for the same reason a spawner object checks authority at its
## own top rather than inside every write -- a per-write role lookup on a 10 Hz
## path is not worth it. The gate asserts the write, not the policy.
func move(id: String, pos: Vector3) -> Dictionary:
	if not _bots.has(id):
		return {"ok": false, "error": "not registered: " + id}
	var rec: Dictionary = _bots[id]
	var rbody := String(rec.get("body", BODY_NONE))
	if not _has_node_body(rbody) and rbody != BODY_OBJECT:
		return {"ok": false, "error": "bot '%s' has no position body" % id}
	var node: Variant = rec.get("node")
	if node != null and is_instance_valid(node) and node is Node3D:
		if (node as Node3D).has_method("set_target"):
			(node as Node3D).set_target(pos)
		else:
			(node as Node3D).global_position = pos
	if state != null:
		state.set_state(_key_pos(rec.get("bindings", []) as Array) + id, [pos.x, pos.y, pos.z])
	return {"ok": true, "id": id, "pos": pos}


## Where this bot lives, or null if its container does not exist.
##
## THE NARROWEST SPATIAL BINDING WINS: map is narrower than room is narrower than
## session. Only the narrowest needs a structural guarantee -- the wider ones are
## signals -- so a bot bound to both map and session is parented to the MAP, and
## dies with it. Nothing here lets a bot choose its own parent, which is the single
## thing creatures got wrong.
func _container_for(bindings: Array) -> Node:
	if bindings.has(BINDING_MAP):
		if map_container != null and is_instance_valid(map_container):
			return map_container
		var game: Node = get_node_or_null(^"/root/Game")
		if game != null:
			var m: Variant = game.get("current_map")
			if m != null and is_instance_valid(m) and m is Node:
				return m as Node
		return null            # bound to a map that does not exist: refuse
	# room- or session-bound: this autoload outlives every map, so it is the right
	# parent for a body that is meant to.
	return self


## A body you can read on sight.
##
## Every position body used to be the same grey box, so a room of bots was a room
## of identical boxes: nothing distinguished a map-bound bot from a session-bound
## one, or a bot with a running brain from an inert placeholder. The testbed is
## meant to be LOOKED AT, and a marker carrying no information makes it useless
## for that.
##
## What you can see is what the test is about:
##   colour    the CONTAINMENT binding — which is exactly what a map change is
##             supposed to prove. Change map: red should go, green should stay.
##   emission  a brain is running. Matte is brain=none, a body nothing drives.
##             A glowing box that never moves is a failure you can see.
##   shape     the body kind. One shape today because body=position is the only
##             visible body, derived rather than hardcoded so body=player does
##             not arrive looking identical.
##   label     the declaration in words, because colour cannot carry an id.
##
## ⚠ body=none has NO NODE and so cannot appear here at all. Those bots are
## observable only through the state they write. That is a real gap in the
## testbed, not something this should paper over by inventing a body.
func _make_marker(id: String, bindings: Array = [], body: String = BODY_POSITION,
		brain_kind: String = BRAIN_NONE) -> Node3D:
	var root_node := Node3D.new()
	# Glides to each published position instead of snapping (scenes/game/bot_marker.gd).
	root_node.set_script(load("res://scenes/game/bot_marker.gd"))
	root_node.name = "Bot_" + id

	var mesh := MeshInstance3D.new()
	mesh.mesh = _mesh_for_body(body)

	var mat := StandardMaterial3D.new()
	mat.albedo_color = _colour_for(bindings)
	# A brain is the thing that can be "not working", so it gets the loudest
	# signal: a bot that should be doing something is lit, and one that should
	# not is not.
	if brain_kind != BRAIN_NONE and brain_kind != "":
		mat.emission_enabled = true
		mat.emission = mat.albedo_color
		mat.emission_energy_multiplier = 1.6
	mesh.material_override = mat
	root_node.add_child(mesh)

	var label := Label3D.new()
	label.name = "Label"  # _relabel_markers finds it by name
	label.text = _marker_text(id, bindings, body, brain_kind)
	label.font_size = 48
	label.pixel_size = 0.004
	label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	label.modulate = mat.albedo_color
	label.no_depth_test = true
	label.position = Vector3(0.0, MARKER_SIZE.y * 0.9, 0.0)
	root_node.add_child(label)

	return root_node


## Where a body starts, derived from its id.
##
## register() used to write [0, 0, 0] for every position body and add the node
## without touching its transform, so every body began at the container's origin.
## A bot with brain=none never moves, so the whole matrix stacked into one box and
## read as a single bot.
##
## ⛔ DERIVED, NOT ASSIGNED, and that is not laziness. A REMOTE peer builds the
## marker when the registry row arrives, which is before any position row has
## landed. If the two sides chose independently the body would visibly jump the
## moment the first row arrived. Deriving from the id makes every peer agree
## without a round trip.
##
## The hash is computed here rather than with GDScript's hash(): that is not
## promised to be stable across engine versions, and "the same on every peer"
## includes peers running different builds.
## Bodies are placed on a LATTICE, not on a ring.
##
## The first version hashed the id to an angle and a radius on a continuous ring,
## and its gate caught the flaw immediately: two of seven sample ids landed 0.31
## apart, closer than a marker is wide. A near-miss is the worst outcome — two
## bodies clipping through each other read as a rendering bug rather than as two
## bots. On a lattice two distinct cells are always SPAWN_SPACING apart, so the
## only failure left is an exact hash collision, which at least looks like what it
## is. 32x32 cells keeps that unlikely for a testbed-sized roster.
const SPAWN_SPACING := 2.0
const SPAWN_CELLS := 32

static func _id_hash(id: String) -> int:
	# FNV-1a, 32-bit. Small, stable, and defined here so it cannot drift.
	var h := 2166136261
	for i in id.length():
		h = (h ^ id.unicode_at(i)) & 0xFFFFFFFF
		h = (h * 16777619) & 0xFFFFFFFF
	# ⛔ AVALANCHE, or short ids collide. The first version returned the raw FNV
	# value and took both lattice axes from its low bits; "a" and "b" landed in the
	# SAME cell, which the gate caught. FNV's low bits barely move for a one
	# character change, so they are mixed across the whole word before use.
	h = (h ^ (h >> 16)) & 0xFFFFFFFF
	h = (h * 2246822507) & 0xFFFFFFFF
	h = (h ^ (h >> 13)) & 0xFFFFFFFF
	h = (h * 3266489909) & 0xFFFFFFFF
	h = (h ^ (h >> 16)) & 0xFFFFFFFF
	return h


static func _spawn_offset_for(id: String) -> Vector3:
	var h := _id_hash(id)
	# Separate bit ranges for the two axes: adjacent cells otherwise come from
	# adjacent hashes, which is exactly what one-character-apart ids produce.
	var cx := h % SPAWN_CELLS
	var cz := (h >> 16) % SPAWN_CELLS
	# Centred on the origin, and offset by half a cell so nothing sits exactly at
	# (0, 0) where the player spawns.
	var x := (float(cx) - float(SPAWN_CELLS) * 0.5 + 0.5) * SPAWN_SPACING
	var z := (float(cz) - float(SPAWN_CELLS) * 0.5 + 0.5) * SPAWN_SPACING
	# Sitting ON the floor, not half through it: the marker's origin is its centre.
	return Vector3(x, MARKER_SIZE.y * 0.5, z)


## The narrowest binding present decides the colour, the same order that decides
## the node parent and the key prefix — one rule, three derivations.
static func _binding_word(bindings: Array) -> String:
	if bindings.has(BINDING_MAP):
		return BINDING_MAP
	if bindings.has(BINDING_ROOM):
		return BINDING_ROOM
	if bindings.has(BINDING_SESSION):
		return BINDING_SESSION
	return "unbound"


static func _colour_for(bindings: Array) -> Color:
	match _binding_word(bindings):
		BINDING_MAP:
			return Color(0.95, 0.35, 0.22)      # warm red: dies with the map
		BINDING_ROOM:
			return Color(0.25, 0.82, 0.45)      # green: outlives a map change
		BINDING_SESSION:
			return Color(0.35, 0.58, 0.98)      # blue: dies with this process
	return Color(0.75, 0.75, 0.75)              # grey: nothing declared


static func _mesh_for_body(body: String) -> Mesh:
	if body == BODY_PLAYER:
		# Not reachable yet (body=player is unimplemented), but the shape is
		# derived here so it does not arrive looking like a position marker.
		var cap := CapsuleMesh.new()
		cap.radius = MARKER_SIZE.x * 0.5
		cap.height = MARKER_SIZE.y * 1.6
		return cap
	var box := BoxMesh.new()
	box.size = MARKER_SIZE
	return box


## The node for a body: the marker box (body=position) or a creature (CREATURE_BODIES),
## labelled like a marker. NOT in the tree yet, and not initialised — _settle_body does
## that once it is placed (a creature lays out its segments from where it stands).
func _make_body(id: String, bindings: Array, body: String, brain_kind: String) -> Node3D:
	if not CREATURE_BODIES.has(body):
		return _make_marker(id, bindings, body, brain_kind)
	var c: Node3D
	match body:
		"crawler":
			c = GroundCrawler.new()
		"hopper":
			c = Hopper.new()
		_:
			c = FlyingSerpent.new()
	c.name = "Bot_" + id
	var label := Label3D.new()
	label.name = "Label"  # _relabel_markers finds it by name
	label.text = _marker_text(id, bindings, body, brain_kind)
	label.font_size = 40
	label.pixel_size = 0.004
	label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	label.no_depth_test = true
	label.position = Vector3(0.0, 1.6, 0.0)
	c.add_child(label)
	return c


## A body just added to its container: stand it at `pos`, build a creature from the
## id's hash (the same creature on every peer), and schedule it (tiers + sleep).
func _settle_body(id: String, node: Node3D, pos: Vector3) -> void:
	if node == null:
		return
	node.position = pos             # after add_child, so the parent's transform applies
	if node.has_method("initialize"):
		var rng := RandomNumberGenerator.new()
		rng.seed = _id_hash(id)
		node.initialize(rng)
		if node.has_method("set_target"):
			node.set_target(node.global_position)      # stand until the brain says otherwise
	schedule_body(node)


## Where a row says its body starts: its "pos", else the id's lattice cell.
static func _row_pos(id: String, row: Dictionary) -> Vector3:
	var p: Variant = row.get("pos")
	if p is Array and (p as Array).size() >= 3:
		return Vector3(float(p[0]), float(p[1]), float(p[2]))
	return _spawn_offset_for(id)


## Out of the scheduler, out of the tree, then freed (the scheduler must never hold a
## freed body). Out of the tree NOW, not at the deferred free: a body rebuilt in the same
## frame (a revival on taking authority) would otherwise collide with the dying one's
## name and be renamed "@Bot_<id>@N" by the engine (gate_ghost_creatures C5).
func _free_body(n: Variant) -> void:
	if n is Node3D and is_instance_valid(n):
		unschedule_body(n as Node3D)
		var p := (n as Node).get_parent()
		if p != null:
			p.remove_child(n as Node)
		(n as Node).queue_free()


## Rebuild bodies for bots OTHER peers registered. This is the render half, in the
## same object as the write half -- see _remote's comment for why that matters.
func _on_state_changed(key: String, value: Variant, _channel: int) -> void:
	# ⛔ THE NAMESPACE CARRIES THE CONTAINMENT. This used to match one prefix and
	# then parent every incoming body with _container_for([BINDING_MAP]) -- always
	# the map, whatever the bot was bound to -- so a room-bound bot was contained
	# correctly where it was registered and died with the map everywhere it was
	# RENDERED. Now the prefix the row arrived under says which it is, and the
	# remote half derives the parent from that, exactly as register() does.
	var reg_bindings: Array = []
	var reg_id := ""
	if key.begins_with(KEY_MAP_REGISTRY):
		reg_id = key.substr(KEY_MAP_REGISTRY.length())
		reg_bindings = [BINDING_MAP]
	elif key.begins_with(KEY_ROOM_REGISTRY):
		reg_id = key.substr(KEY_ROOM_REGISTRY.length())
		reg_bindings = [BINDING_ROOM]

	if reg_id != "":
		if _bots.has(reg_id):
			return                      # ours; we already built it
		if value == null:
			_drop_remote(reg_id)
		elif not _remote.has(reg_id):
			# ⛔ RENDERING IS NOT RUNNING. This branch builds a passive marker for a
			# bot ANOTHER peer is simulating. For a bot WE are meant to simulate --
			# our own declaration, come back to us after a map change -- a marker is
			# a coloured box with no brain, which is what made a declared bot
			# unrecoverable once its map ended. The authority revives it properly
			# first; everyone else falls through and renders, exactly as before.
			if _is_authority() and _revive(reg_id, reg_bindings, value):
				return
			var host: Node = _container_for(reg_bindings)
			if host == null:
				return
			# The row carries the declaration, so a remote body is coloured and
			# labelled exactly like the local one instead of being an anonymous box.
			var rowd: Dictionary = value as Dictionary if value is Dictionary else {}
			var rbody := String(rowd.get("body", BODY_POSITION))
			if not _has_node_body(rbody):
				return                  # body=none: nothing to draw
			var bv: Variant = rowd.get("brain", BRAIN_NONE)
			var bkind := String((bv as Dictionary).get("kind", BRAIN_NONE)) if bv is Dictionary else String(bv)
			var n := _make_body(reg_id, reg_bindings, rbody, bkind)
			host.add_child(n)
			_settle_body(reg_id, n, _row_pos(reg_id, rowd))
			_remote[reg_id] = n
		return

	var pos_id := ""
	if key.begins_with(KEY_MAP_POS):
		pos_id = key.substr(KEY_MAP_POS.length())
	elif key.begins_with(KEY_ROOM_POS):
		pos_id = key.substr(KEY_ROOM_POS.length())
	if pos_id == "" or _bots.has(pos_id):
		return
	# A bot OBJECT this peer renders: steer its body to the replicated position, so
	# its own gait animates here too (creatures used to freeze and slide remotely).
	if _attached_remote.has(pos_id) and value is Array and (value as Array).size() >= 3:
		var on: Variant = _attached_remote[pos_id]
		if on is Node3D and is_instance_valid(on) and (on as Node3D).has_method("set_target"):
			var av: Array = value as Array
			(on as Node3D).set_target(Vector3(av[0], av[1], av[2]))
		return
	if _remote.has(pos_id) and value is Array and (value as Array).size() >= 3:
		var n2: Node3D = _remote[pos_id]
		if is_instance_valid(n2):
			var a: Array = value as Array
			if n2.has_method("set_target"):
				n2.set_target(Vector3(a[0], a[1], a[2]))
			else:
				n2.global_position = Vector3(a[0], a[1], a[2])


## ---- revival: a declaration outlives the map it is bound to --------------------

## Rows that arrived before their map existed, id -> the durable row.
##
## _container_for resolves Game.current_map, and the durable reload on entering a
## map can land before the map node is in the tree. Dropping the row there is the
## one-shot trap that made walker.gd dead on arrival and made the remote-marker
## path render nothing: the row never comes again, because nothing CHANGED.
var _pending_rows: Dictionary = {}


## True if this peer is the game's elected authority, so exactly one peer runs the
## brain. Mirrors AdminFacade._is_authority -- the election lives on the Game node
## (menu_main.gd:_elect_authority, Keet role + pk tie-break) and is NOT Godot's
## "server". Falls back to true with no mesh, so a single peer and the gates work.
func _is_authority() -> bool:
	if multiplayer == null or multiplayer.multiplayer_peer == null:
		return true
	var game: Node = get_node_or_null(^"/root/Game")
	if game == null:
		return true
	return game.is_multiplayer_authority()


## The brain declaration as it must be STORED to be revivable later.
##
## The row used to carry the kind alone ("script_file"), which says a brain existed
## and nothing about which one -- so a bot could be recognised after a map change
## but not restarted. Path and params are what make the declaration complete.
func _brain_row(brain: Dictionary) -> Dictionary:
	var out: Dictionary = {"kind": String(brain.get("kind", BRAIN_NONE))}
	var path: String = String(brain.get("path", ""))
	if path != "":
		out["path"] = path
	var params: Variant = brain.get("params", {})
	if params is Dictionary and not (params as Dictionary).is_empty():
		out["params"] = params
	return out


## Rebuild a declared bot LOCALLY from its durable row: body, roster entry, brain.
##
## Returns true when the row was claimed -- either revived now, or held because its
## map does not exist yet. False means "not ours to revive", and the caller renders
## a marker instead.
##
## ⛔ IT WRITES NO DURABLE STATE. register() writes the row; revival is the row
## being read back. Writing here would rewrite the row we are reacting to, re-emit
## it, and revive again -- and would stamp a fresh ts over the declaration's own.
func _revive(id: String, bindings: Array, row: Variant) -> bool:
	if not (row is Dictionary):
		return false
	var r: Dictionary = row as Dictionary
	var body: String = String(r.get("body", BODY_NONE))
	var brain_v: Variant = r.get("brain", {})
	# A legacy row carries the KIND as a bare String. "none" is still complete --
	# there is no brain to restore -- but a script kind without its path is not,
	# and guessing a path would be inventing the declaration.
	var brain: Dictionary = {}
	if brain_v is Dictionary:
		brain = brain_v as Dictionary
	elif String(brain_v) == BRAIN_NONE:
		brain = {"kind": BRAIN_NONE}
	else:
		return false
	if not IMPLEMENTED_BODIES.has(body):
		return false

	var node: Node = null
	if _has_node_body(body):
		var host: Node = _container_for(bindings)
		if host == null:
			# Hold it. The map is on its way; this is not a refusal.
			_pending_rows[id] = {"bindings": bindings, "row": r}
			set_process(true)
			return true
		node = _make_body(id, bindings, body, String(brain.get("kind", BRAIN_NONE)))
		host.add_child(node)
		_settle_body(id, node as Node3D, _row_pos(id, r))

	_bots[id] = {"bindings": bindings, "body": body, "brain": brain, "node": node}
	_pending_rows.erase(id)
	# A marker may already be standing in for this bot from an earlier delivery.
	_drop_remote(id)

	var kind: String = String(brain.get("kind", BRAIN_NONE))
	if kind == BRAIN_SCRIPT or kind == BRAIN_SCRIPT_FILE:
		var started: Dictionary = _start_script_brain(id, brain)
		if not bool(started.get("ok", false)):
			push_warning("[BotRegistry] revived '%s' but its brain did not start: %s"
					% [id, String(started.get("error", "?"))])
	print("[BotRegistry] revived '%s' from its durable declaration (body=%s brain=%s)"
			% [id, body, kind])
	return true


## Retry held rows once their map exists. Idle otherwise -- processing turns itself
## off when nothing is waiting, so this costs nothing in the normal case.
func _process(_delta: float) -> void:
	if _pending_rows.is_empty():
		set_process(false)
		return
	for id: String in _pending_rows.keys():
		var held: Dictionary = _pending_rows[id]
		if _bots.has(id):
			_pending_rows.erase(id)
			continue
		if _container_for(held.get("bindings", []) as Array) == null:
			continue
		_pending_rows.erase(id)
		_revive(id, held.get("bindings", []) as Array, held.get("row"))


func _drop_remote(id: String) -> void:
	if not _remote.has(id):
		return
	_free_body(_remote[id])
	_remote.erase(id)


## Remote bodies are map-parented, so a map change frees them with the map -- but
## the handle must go too, or the next map rebuilds against stale entries and this
## becomes the unreclaimable-orphan bug in a new place.
func _clear_remote() -> void:
	for id: String in _remote.keys():
		_free_body(_remote[id])
	_remote.clear()