OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_spawner_object.gd

179 lines · gd ref:
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")