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