extends SceneTree
## GATE — object REPARENTING (demo sprint §3; design approved 2026-10-04).
## P1 parent_objects journals object.parent; the child's node is under the parent's,
## and it did not move in the world (its xform is parent-local)
## P2 moving the parent carries the child
## P3 a child's object.transform is parent-local
## P4 one undo of the parenting puts the child back at the top level, where it was
## P5 a cycle is refused (fold and writer)
## P6 a bot cannot be a child; it can be a parent
## P7 X on a parent deletes the PARENT: its child moves up to the parent's parent and
## stays where it is in the world (#120 F1, Blender's relink); one undo brings both
## back, still parented. Delete Hierarchy deletes both; one undo, both back
## P8 Ctrl+P parents the selection to the active object; Alt+P clears, keeping the
## world position
## P9 G with a parent AND its child selected moves the child once
## P10 R with both selected turns the child once
## P11 Outliner rows nest (child row under the parent row), parents first even when
## the parent was created after the child
## P12 an Outliner drop parents; a drop on the SceneObjects row unparents
## P13 undoing the PARENT'S create keeps the child (at the top level), not freed
## P14 a refold does not re-parent an unchanged child (same node instance)
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_object_parent.gd
var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _persist: Node
var _stage2 := false
var _objs: Node
var _orphan := ""
var _gone := ""
var _c := ""
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 _process(_d: float) -> bool:
_frame += 1
if _frame == 1:
var dock := root.get_node("/root/BuildDock")
dock.set_pref("snap", true)
dock.set_pref("grid", 5)
_map = (load("res://scenes/maps/map_flat.tscn") as PackedScene).instantiate()
root.add_child(_map)
return false
if _frame < 30:
return false
if _frame == 30:
_run()
return false
if _frame < 33 and _stage2:
return false
if _stage2:
_run2()
print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
quit(0 if ok else 1)
return true
func _key(code: int, ctrl := false, alt := false) -> InputEventKey:
var e := InputEventKey.new()
e.physical_keycode = code
e.keycode = code
e.ctrl_pressed = ctrl
e.alt_pressed = alt
e.pressed = true
return e
func _node(dot: String) -> Node3D:
return _map.object_node(dot) as Node3D
func _world(dot: String) -> Vector3:
var n := _node(dot)
return n.global_position if n != null else Vector3.INF
func _undo() -> void:
_persist.undo_gesture(_persist.newest_live_gesture())
_persist.refold()
func _at(p: Vector3) -> Transform3D:
return Transform3D(Basis(), p)
func _run() -> void:
_persist = _map.get("_persist")
var keet: Node = root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
var gs: Node = root.get_node_or_null("/root/GameState")
if gs != null:
gs.route("world:level:*", 1, "peer")
root.get_node("/root/GameMode").set_mode("build")
_p(_map.has_method("parent_objects"), "the map has parent_objects")
if not _map.has_method("parent_objects"):
return
var objs: Node = _map.get_node("SceneObjects")
# ---- P1 ----
var a: String = _map.create_object("block.bevelled", Transform3D(Basis(Vector3.UP, PI / 2), Vector3(2, 0, 0)))
var c: String = _map.create_object("marker", _at(Vector3(5, 1, 3)))
_p(_map.parent_objects([c], a) == 1, "P1 parent_objects writes one record")
_p(_node(c).get_parent() == _node(a), "P1 the child's node is under the parent's")
_p(_world(c).is_equal_approx(Vector3(5, 1, 3)), "P1 the child did not move in the world", _world(c))
_p(String(_map.object_parent(c)) == a, "P1 object_parent names the parent")
# ---- P2 ----
_map.move_objects({a: Transform3D(Basis(Vector3.UP, PI / 2), Vector3(2, 0, 10))}, "Move")
_p(_world(c).is_equal_approx(Vector3(5, 1, 13)), "P2 moving the parent carries the child", _world(c))
# ---- P3 ----
_map.move_object(c, _at(Vector3(0, 2, 0)))
_p(_world(c).is_equal_approx(Vector3(2, 2, 10)), "P3 a child's transform is parent-local", _world(c))
_undo()
_undo()
_p(_world(c).is_equal_approx(Vector3(5, 1, 3)), "P3 (two undos: back to the parenting)", _world(c))
# ---- P4 ----
_undo()
_p(_node(c).get_parent() == objs and _world(c).is_equal_approx(Vector3(5, 1, 3)),
"P4 undoing the parenting: top level, same place", _world(c))
# ---- P5 ----
_map.parent_objects([c], a)
_p(_map.parent_objects([a], c) == 0, "P5 the writer refuses a cycle")
_persist.record_at(_persist.reserve_dot(), "object.parent", {"target": a, "parent": c,
"xform": SceneObjects.xform_to(Transform3D.IDENTITY)})
_persist.refold()
_p(_node(a).get_parent() == objs and _node(c).get_parent() == _node(a), "P5 the fold refuses a forged cycle")
_undo() # the forged record
# ---- P6 ----
var bot: String = _map.place_bot("idle", "marker")
_p(_map.parent_objects([bot], a) == 0, "P6 a bot cannot be a child")
var m2: String = _map.create_object("marker", _at(Vector3(-4, 0, 0)))
_p(_map.parent_objects([m2], bot) == 1 and _node(m2).get_parent() == _node(bot), "P6 a bot can be a parent")
# ---- P7 ----
var cw := _world(c)
_map.delete_objects([a])
_p(_node(a) == null and _node(c) != null, "P7 X on the parent deletes only the parent")
_p(_node(c) != null and _node(c).get_parent() == objs and _world(c).is_equal_approx(cw),
"P7 ... its child moved up, same place in the world", _world(c) if _node(c) != null else null)
_undo()
_p(_node(a) != null and _node(c) != null and _node(c).get_parent() == _node(a), "P7 one undo: both back, still parented")
_map.select_objects([a], a)
var rows_x: Array = (_map.delete_sections()[0] as Array)[1]
_p(rows_x.any(func(r: Array) -> bool: return String(r[0]) == "builder.delete_hierarchy"),
"P7 the X menu offers Delete Hierarchy (the parent has a child)")
var dh := InputEventAction.new()
dh.action = "builder.delete_hierarchy"
dh.pressed = true
_map._unhandled_input(dh) # Keymap.run's path for a menu-only action
_p(_node(a) == null and _node(c) == null, "P7 Delete Hierarchy deletes the child too")
_undo()
_p(_node(a) != null and _node(c) != null and _node(c).get_parent() == _node(a), "P7 one undo: both back, still parented")
# ---- P8 ----
var k1: String = _map.create_object("marker", _at(Vector3(20, 0, 0)))
var k2: String = _map.create_object("marker", _at(Vector3(22, 0, 0)))
var pa: String = _map.create_object("block.bevelled", _at(Vector3(25, 0, 0)))
_map.select_objects([k1, k2, pa], pa)
_map._unhandled_key_input(_key(KEY_P, true))
_p(_node(k1).get_parent() == _node(pa) and _node(k2).get_parent() == _node(pa), "P8 Ctrl+P parents the selection to the active")
_map.move_objects({pa: _at(Vector3(25, 3, 0))}, "Move")
_map.select_objects([k1], k1)
_map._unhandled_key_input(_key(KEY_P, false, true))
_p(_node(k1).get_parent() == objs and _world(k1).is_equal_approx(Vector3(20, 3, 0)),
"P8 Alt+P clears the parent and keeps the world position", _world(k1))
# ---- P9 ----
var stack: Node = _map.call("_ensure_ops")
var c0 := _world(k2)
var p0 := _world(pa)
_map.select_objects([k2, pa], pa)
_map._unhandled_key_input(_key(KEY_G))
_p(stack.active is MoveOperator, "P9 G starts move")
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
stack.confirm()
var dp := _world(pa) - p0
var dc := _world(k2) - c0
_p(dp.length() > 0.01 and dc.is_equal_approx(dp), "P9 the child moved ONCE with its parent", "%s vs %s" % [dc, dp])
# ---- P10 ----
c0 = _world(k2)
_map.select_objects([k2, pa], pa)
_map._unhandled_key_input(_key(KEY_R))
for i in 6:
_map._unhandled_key_input(_key(KEY_BRACKETRIGHT)) # 90° about Y
stack.confirm()
var pv := _world(pa) # one object root -> turns about its own origin
var want := pv + Basis(Vector3.UP, PI / 2) * (c0 - pv)
_p(_world(k2).is_equal_approx(want), "P10 the child turned ONCE with its parent", "%s vs %s" % [_world(k2), want])
# ---- P11 ----
var late_child: String = _map.create_object("marker", _at(Vector3(30, 0, 0)))
var late_parent: String = _map.create_object("block.bevelled", _at(Vector3(32, 0, 0)))
_map.parent_objects([late_child], late_parent)
var om := OutlinerModel.new(_map)
var order: Array = []
var parent_of_row := {}
for r: Dictionary in om.rows():
order.append(String(r["id"]))
parent_of_row[String(r["id"])] = String(r["parent"])
_p(String(parent_of_row.get("obj:" + late_child, "")) == "obj:" + late_parent, "P11 the child row nests under the parent row",
parent_of_row.get("obj:" + late_child))
_p(order.find("obj:" + late_parent) >= 0 and order.find("obj:" + late_parent) < order.find("obj:" + late_child),
"P11 parent row before child row, though created after it")
# ---- P12 ----
var panel: Node = load("res://scenes/game/outliner_panel.gd").new()
root.add_child(panel)
panel.setup(_map, "gate_object_parent")
var dropper: String = _map.create_object("marker", _at(Vector3(40, 0, 0)))
panel.refresh()
_p(panel.has_method("drop_objects"), "P12 the Outliner has drop_objects")
if panel.has_method("drop_objects"):
panel.drop_objects([dropper], "obj:" + late_parent)
_p(_node(dropper).get_parent() == _node(late_parent), "P12 a drop on an object row parents")
panel.drop_objects([dropper], "node:SceneObjects")
_p(_node(dropper).get_parent() == objs and _world(dropper).is_equal_approx(Vector3(40, 0, 0)),
"P12 a drop on SceneObjects unparents, in place", _world(dropper))
panel.queue_free()
# ---- P13 ----
_orphan = _map.create_object("marker", _at(Vector3(50, 0, 0)))
_gone = _map.create_object("block.bevelled", _at(Vector3(52, 0, 0)))
_map.parent_objects([_orphan], _gone)
for r: Dictionary in _persist.records():
if "%s:%d" % [String(r.get("author", "")), int(r.get("seq", -1))] == _gone:
_persist.undo_gesture(String(r.get("g", _gone)))
_persist.refold()
_objs = objs
_c = c
_stage2 = true # checked after the frame ends (queue_free is deferred)
func _run2() -> void:
var objs := _objs
_p(_node(_gone) == null and _node(_orphan) != null and is_instance_valid(_node(_orphan)),
"P13 undoing the parent's create keeps the child")
_p(_node(_orphan) != null and _node(_orphan).get_parent() == objs, "P13 the child is at the top level")
# ---- P14 ----
var before := _node(_c).get_instance_id()
var parent_before := _node(_c).get_parent()
_persist.refold()
_p(_node(_c).get_instance_id() == before and _node(_c).get_parent() == parent_before,
"P14 a refold keeps the child node and its parent")
root.get_node("/root/GameMode").set_mode("play")