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()