extends SceneTree
## Gate: containment — spec/bot-testbed.md (e1).
##
## An entity must die with its container. A bot bound to a map must not survive a
## map change, and "survive" means three separate things that are declared in
## three separate places: its statebase keys, its scene-tree node, and whoever
## runs its brain. This gate checks all three against ONE map change.
##
## Written RED first, on purpose: creatures and ScriptHost both leaked in 0.1123.0
## and a gate authored after the fix would only prove the fix agrees with itself.
## It went green in four steps -- realtime eviction in enter_map, map-bound script
## unload, creature container parented to the map, container freed on exit -- and
## each assertion below names the property, not the old defect.
##
## ⚠ THE BODY RUNS IN _process, NOT _initialize. Autoloads do not exist during a
## SceneTree script's _initialize -- measured: GameState and ScriptHost are both
## null there and both present on frame 1. Gates that hand-instantiate an autoload
## (gate_net_stats.gd) do so to avoid this; here we need the REAL singletons,
## because BotRegistry and ScriptHost reach the `GameState` global by name and a
## stand-in node cannot satisfy that.
var ok := true
var _ran := false
const MAP_A := "gate_containment_a"
const MAP_B := "gate_containment_b"
## A bot with body=none: exactly what a published .gd with _bot_tick is.
const BOT_SOURCE := """
extends RefCounted
var ticks := 0
func _bot_tick(_delta: float) -> void:
ticks += 1
"""
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 containment: %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 gs: Node = root.get_node_or_null(^"/root/GameState")
var sh: Node = root.get_node_or_null(^"/root/ScriptHost")
if gs == null or sh == null:
_p(false, "GameState and ScriptHost autoloads present (got %s / %s)"
% [str(gs != null), str(sh != null)])
return
# Routes are installed by game.gd, which does not exist here.
gs.install_routes()
gs.current_map_id = MAP_A
_check_realtime_state(gs)
_check_script_host(gs, sh)
_check_creature_node_parent(gs)
## LIFETIME 1 — DATA. A map-scoped REALTIME key must not cross a map change.
## enter_map clears _durable and not _realtime (only reset_for_room_switch does),
## so a creature's last position outlives the map it belonged to.
func _check_realtime_state(gs: Node) -> void:
gs.current_map_id = MAP_A
gs.set_state("world:creatures:pos:gatebot", [1.0, 2.0, 3.0])
gs.set_state("world:sky:current_time", 11.0)
var rt_a: Array = gs.keys_with_prefix("world:creatures:pos:")
var du_a: Array = gs.keys_with_prefix("world:sky:")
_p(rt_a.size() == 1, "map A holds the realtime pos key (%d)" % rt_a.size())
_p(du_a.size() == 1, "map A holds the durable sky key (%d)" % du_a.size())
gs.enter_map(MAP_B)
var du_b: Array = gs.keys_with_prefix("world:sky:")
_p(du_b.size() == 0, "durable state cleared by enter_map (%d left)" % du_b.size())
var rt_b: Array = gs.keys_with_prefix("world:creatures:pos:")
_p(rt_b.size() == 0,
"realtime state cleared by enter_map (%d left: %s)"
% [rt_b.size(), str(rt_b)])
## LIFETIME 3 — SIMULATION. A ticking script is a bot with body=none. ScriptHost
## is an autoload with no map_changing connection, so _loaded survives change_map
## and the brain keeps running in the next map with no way to declare otherwise.
func _check_script_host(gs: Node, sh: Node) -> void:
gs.current_map_id = MAP_A
var inst: Object = sh.run(BOT_SOURCE, "gate_contain_bot")
if inst == null:
_p(false, "bot source compiled and instantiated")
return
_p(sh.is_loaded("gate_contain_bot"), "bot is loaded in map A")
gs.enter_map(MAP_B)
_p(not sh.is_loaded("gate_contain_bot"),
"map-bound bot unloaded by the map change")
# Leave nothing running regardless of the verdict.
sh.unload("gate_contain_bot")
## LIFETIME 2 — NODE. A creature's body must live inside the map it belongs to. The
## old creature listener parented its container to Game, so every creature outlived
## the map, unreclaimably. Creatures are bots now (#114 phase 5): a map-bound bot's
## body is parented by BotRegistry._container_for, and the map change despawns it.
## The REAL wiring (game.tscn + change_map) is gate_ghost_creatures.
func _check_creature_node_parent(gs: Node) -> void:
var br: Node = root.get_node_or_null(^"/root/BotRegistry")
if br == null:
_p(false, "BotRegistry autoload present")
return
var map := Node3D.new()
map.name = "GateMap"
root.add_child(map)
br.map_container = map
var real_state: Object = br.state
br.state = load("res://scenes/test/permissive_state.gd").new()
var r: Dictionary = br.register("gate_contain_crawler", ["map"], "crawler", {"kind": "none"}, Vector3(2, 0, 2))
var body: Node3D = br.body_of("gate_contain_crawler")
_p(bool(r.get("ok", false)) and body != null, "a map-bound creature bot is registered")
_p(body != null and map.is_ancestor_of(body), "its body is inside the map (parent is '%s')"
% str(body.get_parent().name if body != null and body.get_parent() != null else "<none>"))
# The map change ends it; then the map is freed as change_map does.
gs.map_changing.emit("gate_containment_c")
_p(not br.is_registered("gate_contain_crawler"), "the map change despawns it")
map.free()
# Gone, or out of the tree and queued (BotRegistry._free_body detaches a body at once
# and frees it at the end of the frame) — never still standing anywhere.
_p(not is_instance_valid(body) or (not body.is_inside_tree() and body.is_queued_for_deletion()),
"nothing survives the map being freed")
br.state = real_state
br.map_container = null