extends SceneTree
## GATE — the SPAWNER object (creatures → bots, #114 phase 4): ambient creatures are a
## room's own placed spawner, not a map default (user 2026-10-13).
## S1 on the authority it fills up to its counts (2 crawlers + 1 hopper here) with
## registered creature bots near the observer, each running ROAM, scheduled
## S2 every observer far away: they are despawned (rows retired)
## S3 back: it fills again
## S4 taking authority retires every bot with its prefix it does not run (a left-over
## row, and its own bots from before a hand-off), then refills afresh
## S5 deleting the spawner despawns its bots
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_spawner_object.gd
const HERE := Vector3(0, 1, 0)
const AWAY := Vector3(5000, 1, 5000)
var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _reg: Node
var _gs: Node
var _sh: Node
var _dot := ""
var _obs: Node3D
var _step := 0
var _wait := 0
var _was: Array = []
var _leftover := ""
func _p(c: bool, m: String, got: Variant = "") -> void:
checks += 1
print(("ok " if c else "FAIL: ") + m + ("" if str(got) == "" else " (" + str(got) + ")"))
if not c:
ok = false
func _sp() -> Node:
return _map.object_node(_dot)
func _look_from(at: Vector3) -> void:
_obs.global_position = at
var cam := root.get_viewport().get_camera_3d()
if cam != null:
cam.global_position = at
func _process(_d: float) -> bool:
_frame += 1
if _frame == 1:
_map = (load("res://scenes/maps/map_flat.tscn") as PackedScene).instantiate()
root.add_child(_map)
_obs = Node3D.new()
_obs.name = "GateObserver"
root.add_child(_obs)
_obs.add_to_group("player")
return false
if _frame < 30:
return false
# PHYSICS frames: headless, process frames outrun the 60 Hz the spawner ticks on.
if Engine.get_physics_frames() < _wait:
return false
_step += 1
match _step:
1: _s1_place()
2: _s1_check()
3: _s2()
4: _s2_check()
5: _s3_check()
6: _s4()
7: _s4_check()
8: _s5()
9: _s5_check()
_:
_reg.map_container = null
if checks < 10: # a gate that died early must not read as a pass
ok = false
print("FAIL: only %d checks ran -- the gate died before its assertions" % checks)
print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
quit(0 if ok else 1)
return true
_wait = Engine.get_physics_frames() + 90 # 1.5 s: several 0.2 s spawn intervals
return false
func _s1_place() -> void:
_reg = root.get_node("/root/BotRegistry")
_gs = root.get_node("/root/GameState")
_sh = root.get_node("/root/ScriptHost")
var keet: Node = root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
_gs.route("world:bots:registry:*", 1, "peer") # offline: no role; grant, as other gates do
_reg.map_container = _map # no Game node here: the map its bots live in, as other bot gates do
_look_from(HERE)
_dot = _map.create_object("spawner", Transform3D(Basis(), HERE))
_map.set_object_props(_dot, {"crawlers": 2, "hoppers": 1, "serpents": 0, "radius": 10.0,
"despawn": 40.0, "interval": 0.2})
_p(_sp() != null and _sp().has_method("bots"), "S1 a spawner object is placed", _dot)
func _s1_check() -> void:
var mine: Dictionary = _sp().bots()
_was = mine.keys()
var kinds := {"crawler": 0, "hopper": 0}
var all_ok := true
for id: String in mine:
kinds[String(mine[id])] = int(kinds.get(String(mine[id]), 0)) + 1
var b: Node3D = _reg.body_of(id)
if b == null or not (_reg.scheduler().get("_e") as Dictionary).has(b) or not _sh.is_loaded(id) \
or not id.begins_with(_sp().id_prefix()):
all_ok = false
_p(int(kinds["crawler"]) == 2 and int(kinds["hopper"]) == 1 and mine.size() == 3,
"S1 it fills to its counts: 2 crawlers + 1 hopper", mine)
_p(all_ok and not mine.is_empty(), "S1 each a registered creature bot, scheduled, running roam, with its prefix")
# Where each was SPAWNED (its row's pos): by now roam has walked it further.
var near := true
for id: String in mine:
var row: Variant = _gs.get_state(_reg.KEY_MAP_REGISTRY + id)
var p: Variant = (row as Dictionary).get("pos") if row is Dictionary else null
if not (p is Array) or Vector2(float(p[0]) - HERE.x, float(p[2]) - HERE.z).length() > 10.5:
near = false
_p(near, "S1 spawned within the spawn distance of the observer")
func _s2() -> void:
_look_from(AWAY)
func _s2_check() -> void:
var gone := true
for id: String in _was:
if _reg.is_registered(id) or _gs.get_state(_reg.KEY_MAP_REGISTRY + id) != null:
gone = false
_p(gone, "S2 every observer far away: its bots are despawned, rows retired")
_look_from(HERE)
func _s3_check() -> void:
_p(_sp().bots().size() == 3, "S3 back near: it fills again", _sp().bots().size())
func _s4() -> void:
# A left-over from an earlier session: a row with this spawner's prefix nobody runs.
_leftover = _sp().id_prefix() + "old-0"
_gs.set_state(_reg.KEY_MAP_REGISTRY + _leftover, {"body": "crawler", "brain": {"kind": "none"}, "pos": [2.0, 0.1, 2.0]})
# Another peer ran this spawner's bots for a while, then authority comes back (as
# BotRegistry.on_authority_changed announces it): every bot with its prefix is now a
# left-over — the planted one AND the ones it ran before — and it starts afresh.
_was = _sp().bots().keys()
_sp()._on_authority(false)
_reg.authority_changed.emit(true)
func _s4_check() -> void:
_p(not _reg.is_registered(_leftover) and _gs.get_state(_reg.KEY_MAP_REGISTRY + _leftover) == null,
"S4 taking authority retires a left-over with its prefix")
var stale := 0
for id: String in _was:
if _reg.is_registered(id):
stale += 1
_p(stale == 0 and not _was.is_empty(), "S4 the bots it ran before the hand-off are retired too", stale)
_p(_sp().bots().size() == 3, "S4 and it refills afresh", _sp().bots().size())
func _s5() -> void:
_was = _sp().bots().keys()
_map.delete_objects([_dot])
func _s5_check() -> void:
var gone := true
for id: String in _was:
if _reg.is_registered(id):
gone = false
_p(gone and not _was.is_empty(), "S5 deleting the spawner despawns its bots")