extends SceneTree
## Gate: BotRegistry — spec/bot-testbed.md (e2).
##
## The property under test is that CONTAINMENT IS DERIVED, not remembered: one
## declaration, and any binding's end ends the bot. The interesting cases are the
## refusals, because a registration that silently degrades is the leak returning —
## an unknown binding means "this container never tears me down".
##
## Body runs in _process: autoloads do not exist during a SceneTree script's
## _initialize (measured — see gate_containment.gd).
var ok := true
var _ran := false
func _p(c: bool, m: String) -> void:
print(("ok " if c else "FAIL: ") + m)
if not c:
ok = false
func _process(_delta: float) -> bool:
if _ran:
return true
_ran = true
_run()
print("GATE bot_registry: %s" % ("PASS" if ok else "FAIL"))
quit(0 if ok else 1) # returning true alone exits 0 — a FAIL never reached CI
return true
func _run() -> void:
var br: Node = root.get_node_or_null(^"/root/BotRegistry")
var gs: Node = root.get_node_or_null(^"/root/GameState")
if br == null or gs == null:
_p(false, "BotRegistry and GameState autoloads present (%s / %s)"
% [str(br != null), str(gs != null)])
return
gs.install_routes()
# --- the default is map, because leaking must not be the default ---
var r: Dictionary = br.register("b_default")
_p(bool(r.get("ok", false)), "register with defaults ok")
_p(br.bindings_of("b_default") == ["map"],
"default binding is ['map'], not session (got %s)" % str(br.bindings_of("b_default")))
# --- refusals: each would otherwise be a bot that looks registered ---
_p(not bool((br.register("b_bad", ["galaxy"]) as Dictionary).get("ok", true)),
"unknown binding refused (silently means 'never torn down')")
_p(not bool((br.register("b_body", [], "player") as Dictionary).get("ok", true)),
"body 'player' refused — not implemented, so it must not pretend")
_p(not bool((br.register("b_body2", [], "position") as Dictionary).get("ok", true)),
"body 'position' refused for the same reason")
_p(not bool((br.register("b_brain", [], "none", {"kind": "telepathy"}) as Dictionary).get("ok", true)),
"unknown brain kind refused")
_p(not bool((br.register("b_default") as Dictionary).get("ok", true)),
"duplicate id refused")
_p(not bool((br.register("") as Dictionary).get("ok", true)), "empty id refused")
# A script brain with no name cannot run, so it must not register.
_p(not bool((br.register("b_noname", [], "none", {"kind": "script"}) as Dictionary).get("ok", true)),
"brain.kind=script without a name refused")
_p(not br.is_registered("b_noname"), "a refused registration leaves no roster entry")
# --- bindings are a SET and death is their UNION ---
br.register("b_multi", ["map", "peer:7"])
_p(br.bindings_of("b_multi") == ["map", "peer:7"], "bindings kept as a set")
br.register("b_session", ["session"])
br.register("b_peer", ["peer:9"])
br.register("b_room", ["room"])
# --- a map change ends map-bound bots and NOTHING else ---
gs.current_map_id = "gate_reg_a"
gs.enter_map("gate_reg_b")
_p(not br.is_registered("b_default"), "map change despawned the map-bound bot")
_p(not br.is_registered("b_multi"), "map change despawned a bot bound to map AND peer (union)")
_p(br.is_registered("b_session"), "session-bound bot survived the map change")
_p(br.is_registered("b_peer"), "peer-bound bot survived the map change")
_p(br.is_registered("b_room"), "room-bound bot survived the map change")
# --- a peer leaving ends only that peer's bots ---
br._on_peer_left(9)
_p(not br.is_registered("b_peer"), "peer:9 leaving despawned the peer-bound bot")
_p(br.is_registered("b_session"), "an unrelated peer leaving left others alone")
# and the wiring is real, not just the handler
_p(br.multiplayer == null or br.multiplayer.peer_disconnected.is_connected(br._on_peer_left),
"peer_disconnected is actually connected")
# --- rebind is the explicit travel op ---
br.register("b_travel", ["map"])
var rb: Dictionary = br.rebind("b_travel", ["session"])
_p(bool(rb.get("ok", false)), "rebind ok")
_p(br.bindings_of("b_travel") == ["session"], "rebind replaced the bindings")
gs.enter_map("gate_reg_c")
_p(br.is_registered("b_travel"), "a rebound bot survives the map it was rebound away from")
_p(not bool((br.rebind("b_travel", ["nowhere"]) as Dictionary).get("ok", true)),
"rebind refuses an unknown binding too")
# --- room ends everything inside it ---
_p(br.is_registered("b_room"), "room-bound bot still alive before the room switch")
gs.reset_for_room_switch()
_p(not br.is_registered("b_room"), "room switch despawned the room-bound bot")
# --- despawn is idempotent, so teardown racing an explicit call is not an error
var d: Dictionary = br.despawn("b_session")
_p(bool(d.get("ok", false)), "explicit despawn ok")
_p(bool((br.despawn("b_session") as Dictionary).get("already_gone", false)),
"despawning twice is not an error")
_p(bool((br.despawn("never_existed") as Dictionary).get("ok", false)),
"despawning an unknown id is not an error")
# --- list reports the roster shape the facade exposes ---
br.register("b_list", ["session"], "none", {"kind": "none"})
var rows: Array = br.list()
var found := false
for row: Variant in rows:
var d2: Dictionary = row as Dictionary
if String(d2.get("id", "")) == "b_list":
found = true
_p(String(d2.get("body", "")) == "none", "list reports body")
_p(String(d2.get("brain", "")) == "none", "list reports brain kind")
_p((d2.get("bindings", []) as Array) == ["session"], "list reports bindings")
_p(found, "list includes a registered bot")
br.despawn("b_list")
_run_position(br, gs)
## body = position: a bot with a replicated presence. Appended as a second phase
## because it needs a stand-in map -- _container_for refuses a map-bound body when
## there is no map, which is the "refuse rather than degrade" rule applied to the
## node lifetime.
func _run_position(br: Node, gs: Node) -> void:
var fake_map := Node3D.new()
fake_map.name = "GateMapForBodies"
root.add_child(fake_map)
br.map_container = fake_map
# Refusal first: a map-bound body with no map must not register.
br.map_container = null
var nomap: Dictionary = br.register("b_nomap", ["map"], "position")
_p(not bool(nomap.get("ok", true)),
"body=position bound to a map that does not exist is refused")
br.map_container = fake_map
# world:bots:registry: is role "mod" and this gate holds no room role, so the
# durable row is refused -- and the whole registration must be refused with it.
# Reporting ok here would hand back a bot with a node, a position row and a
# roster entry that NO OTHER PEER can ever see, because the row announcing it
# was never written. That is the bug this assertion exists for.
var refused: Dictionary = br.register("b_norole", ["map"], "position")
_p(not bool(refused.get("ok", true)),
"body=position refused when the durable registry row is (%s)" % str(refused.get("error", "")))
_p(fake_map.get_node_or_null(^"Bot_b_norole") == null,
"a refused position body leaves no node behind")
_p(not br.is_registered("b_norole"), "and no roster entry")
# Everything below needs the durable row to land, so run it against an injected
# permissive state double. The ROLE is a room concern and is covered by
# GameState's own gate; what is under test here is the body, not the policy.
var real_state: Object = br.state
br.state = load("res://scenes/test/permissive_state.gd").new()
var r: Dictionary = br.register("b_pos", ["map"], "position")
_p(bool(r.get("ok", false)), "body=position registers (%s)" % str(r.get("error", "")))
var node: Node = fake_map.get_node_or_null(^"Bot_b_pos")
_p(node != null, "body is parented to the MAP, not to the registry")
_p(br.state.has_state("world:bots:registry:b_pos"), "durable identity row written")
_p(br.state.has_state("world:bots:pos:b_pos"), "realtime position row written")
var mv: Dictionary = br.move("b_pos", Vector3(3, 0, 4))
_p(bool(mv.get("ok", false)), "move() ok")
var p: Variant = br.state.get_state("world:bots:pos:b_pos")
_p(p is Array and (p as Array).size() == 3 and float((p as Array)[0]) == 3.0,
"move() wrote the realtime row (%s)" % str(p))
if node != null:
_p((node as Node3D).global_position == Vector3(3, 0, 4), "and moved the local node")
_p(not bool((br.move("b_default_none", Vector3.ZERO) as Dictionary).get("ok", true)),
"move() on an unregistered bot refuses")
# Despawn must erase BOTH rows: leaving the durable one resurrects the body on
# any peer that reloads room state.
br.despawn("b_pos")
_p(not br.state.has_state("world:bots:registry:b_pos"), "despawn erased the identity row")
_p(not br.state.has_state("world:bots:pos:b_pos"), "despawn erased the position row")
# A map change evicts the realtime row automatically, because world:bots:pos:
# is map-scoped -- the containment fix covers the new route with no extra code.
br.register("b_pos2", ["map"], "position")
br.state = real_state # the REAL GameState owns eviction
gs.set_state("world:bots:pos:b_pos2", [1.0, 2.0, 3.0])
_p(gs.has_state("world:bots:pos:b_pos2"), "a map-scoped position row exists")
gs.enter_map("gate_reg_bodies")
_p(not gs.has_state("world:bots:pos:b_pos2"),
"map change evicted the map-scoped position row")
_p(not br.is_registered("b_pos2"), "and despawned the bot")
fake_map.queue_free()