OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_bot_object.gd

179 lines · gd ref:
extends SceneTree
## GATE — bots as scene objects (bot MVP, 2026-10-04), on a real map_flat.
##   B1  a placed `bot` builds its body (default crawler) and, on the authority,
##       attaches to BotRegistry and starts its brain (default roam)
##   B2  changing brain / its settings / body restarts it with the NEW settings,
##       converted to engine values (a vec3 setting arrives as a Vector3)
##   B3  the brain moves the body through BotRegistry.move -> set_target, and the
##       replicated world:bots:pos: row is written
##   B4  a peer that only RENDERS steers the body from that row (gait animates)
##   B5  delete detaches (brain stopped, row gone); undo re-attaches
##   B6  a brain id not in BrainCatalogue is refused by the fold
##   B7  the body is built from the id: two builds of one id are the same creature
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_bot_object.gd

var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _persist: Node
var _reg: Node
var _sh: Node
var _dot := ""
var _id := ""
var _step := 0
var _wait := 0
var _t0 := Vector3.ZERO


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 _node() -> Node:
	return _map.object_node(_dot)


func _brain() -> Object:
	var rec: Dictionary = (_sh.get("_loaded") as Dictionary).get(_id, {})
	return rec.get("instance")


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)
		var game := root.get_node_or_null("Game")
		return false
	if _frame < 30:
		return false
	if _wait > 0:
		_wait -= 1
		return false
	_step += 1
	match _step:
		1: _b1()
		2: _b2()
		3: _b3_start()
		4: _b3_check()
		5: _b4_b5()
		6: _b5_check()
		7: _b5_same_frame_check()
		8: _b6_b7()
		_:
			print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
			quit(0 if ok else 1)
			return true
	_wait = 3
	return false


func _b1() -> void:
	_persist = _map.get("_persist")
	_reg = root.get_node("/root/BotRegistry")
	_sh = root.get_node("/root/ScriptHost")
	_dot = _map.create_object("bot", Transform3D(Basis(), Vector3(4, 0.5, 4)))
	var n: Node = _node()
	_p(n != null and n.has_method("bot_id"), "B1 a bot object is placed", _dot)
	if n == null:
		return
	_id = n.bot_id()
	_p(n.body() is GroundCrawler, "B1 default body: a crawler", n.body())
	_p(_reg.is_object(_id), "B1 attached to BotRegistry as an object bot (authority)", _id)
	_p(_sh.is_loaded(_id), "B1 its brain is running (default roam)")
	_p(String(n.brain_id()) == "roam", "B1 brain id roam (the default since #114 phase 2)", n.brain_id())


func _b2() -> void:
	_map.set_object_props(_dot, {"brain": "patrol", "p.radius": 3.0, "body": "hopper"})
	var n: Node = _node()
	_p(n.body() is Hopper, "B2 body changed to a hopper", n.body())
	var b: Object = _brain()
	_p(b != null and b.get_script().resource_path == "res://bots/patrol.gd", "B2 the brain restarted as patrol",
		b.get_script().resource_path if b != null else "none")
	_p(b != null and is_equal_approx(float(b.get("radius")), 3.0), "B2 with its new setting (radius 3)", b.get("radius") if b != null else "?")
	_p(b != null and (b.get("centre") as Vector3).is_equal_approx((n as Node3D).global_position),
		"B2 a placed patrol circles its placement", b.get("centre") if b != null else "?")
	# a vec3 setting reaches the brain as a Vector3
	_map.set_object_props(_dot, {"brain": "pathwalker", "p.wander": false, "p.goal": [10.0, 0.0, 2.0]})
	var b2: Object = _brain()
	_p(b2 != null and b2.get("goal") is Vector3 and (b2.get("goal") as Vector3).is_equal_approx(Vector3(10, 0, 2)),
		"B2 a vec3 setting arrives as a Vector3", b2.get("goal") if b2 != null else "?")
	_p(b2 != null and b2.get("wander") == false, "B2 and a bool one as a bool")


func _b3_start() -> void:
	_map.set_object_props(_dot, {"brain": "patrol", "p.radius": 3.0, "body": "marker"})
	_t0 = Vector3(INF, INF, INF)


func _b3_check() -> void:
	var row: Variant = root.get_node("/root/GameState").get_state("world:bots:pos:" + _id)
	_p(row is Array and (row as Array).size() == 3, "B3 the brain published world:bots:pos:", row)
	var body: Node3D = _node().body()
	var tgt: Variant = body.get("_target")
	_p(tgt is Vector3 and row is Array and (tgt as Vector3).is_equal_approx(Vector3(row[0], row[1], row[2])),
		"B3 and the body was steered to it (set_target)", tgt)


func _b4_b5() -> void:
	# B4: render role — what a non-authority peer does with a replicated row.
	var n: Node3D = _node()
	_reg.detach(_id)
	_reg.get("_attached_remote")[_id] = n
	var gs: Node = root.get_node("/root/GameState")
	_reg._on_state_changed("world:bots:pos:" + _id, [7.0, 0.5, 9.0], 0)
	var tgt: Variant = n.body().get("_target")
	_p(tgt is Vector3 and (tgt as Vector3).is_equal_approx(Vector3(7, 0.5, 9)),
		"B4 a rendering peer steers the body from the replicated row", tgt)
	_reg.get("_attached_remote").erase(_id)
	_map.set_object_props(_dot, {"p.radius": 4.0})       # re-attach (authority again)
	_p(_reg.is_object(_id), "B4 re-attached after a setting change")
	# B5: delete -> (deferred free) -> detached by the NEXT frame.
	_map.delete_objects([_dot])


func _b5_check() -> void:
	var gs: Node = root.get_node("/root/GameState")
	_p(not _reg.is_object(_id) and not _sh.is_loaded(_id), "B5 delete detaches (once the node is freed): brain stopped")
	_p(gs.get_state("world:bots:pos:" + _id) == null, "B5 and its position row is gone")
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()
	_p(_node() != null and _reg.is_object(_id) and _sh.is_loaded(_id), "B5 undo brings it back running")
	# SAME-FRAME delete + undo: the old node's deferred _exit_tree must not detach the NEW one.
	_map.delete_objects([_dot])
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()


func _b5_same_frame_check() -> void:
	_p(_node() != null and _reg.is_object(_id) and _sh.is_loaded(_id),
		"B5 delete + undo in ONE frame: the stale node's exit does not kill the new bot")


func _b6_b7() -> void:
	var st: Dictionary = _map.get("_objects").apply_journal(_persist.records() + [{"author": "zz", "seq": 999999999999,
		"kind": "object.set_prop", "undone": false, "target": _dot, "props": {"brain": "res://evil.gd"}}])
	_p(int(st.get("rejected_props", 0)) == 1, "B6 a brain not in BrainCatalogue is refused", st.get("rejected_props", 0))
	_persist.refold()
	# B7: one id, two builds -> the same creature.
	var a := (load("res://scenes/prefabs/bot.tscn") as PackedScene).instantiate()
	var b := (load("res://scenes/prefabs/bot.tscn") as PackedScene).instantiate()
	for x: Node in [a, b]:
		x.set_meta("object_dot", "peer:42")
		_map.add_child(x)
		x.set_bot_prop("body", "crawler")
		x.set_bot_prop("brain", "idle")
		x._build_body("crawler")
	var ca: Node = a.body()
	var cb: Node = b.body()
	_p(int(ca.get("segment_count")) == int(cb.get("segment_count")) and is_equal_approx(float(ca.get("creature_scale")), float(cb.get("creature_scale"))),
		"B7 every peer builds the same creature for one bot id", "%d/%.2f vs %d/%.2f" % [ca.get("segment_count"), ca.get("creature_scale"), cb.get("segment_count"), cb.get("creature_scale")])
	a.queue_free()
	b.queue_free()