OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_containment.gd

142 lines · gd ref:
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