extends Node
## GATE — SHIFT+D KEEPS STRUCTURE (#120 F1, outliner audit bug 2: a copy lost its group,
## its parent and its collections).
## D1 two brushes of one object copy into ONE new object (not loose, not the source's)
## D2 ... and the new object is in the source object's collections
## D3 a shape's copy is in the source shape's collections
## D4 an object's copy keeps its parent
## D5 a parent and its child copied together: the child's copy hangs under the
## PARENT's copy
## D6 an object's copy is in the source's collections
## D7 one Ctrl+Z takes a duplicate back, links and all
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_duplicate_structure.tscn
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var stack: Node
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
gs.route("world:level:*", 1, "peer")
var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
get_tree().root.get_node("/root/GameMode").set_mode("build")
map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
add_child(map)
for i in 6:
await get_tree().process_frame
await _run()
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 10:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE duplicate_structure: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _dup() -> void:
map.duplicate_selection()
stack.cancel() # leave the copy where it is
persist.refold()
await get_tree().process_frame
func _in(member: String) -> Array:
return CollectionOps.CollectionFold.collections_of(persist.collection_tree(), member)
func _run() -> void:
persist = map.get("_persist")
level = map.get("level")
stack = map.call("_ensure_ops")
persist.refold()
var loose: Array = []
for b: int in (level.get_live_brush_ids() as PackedInt32Array):
if String(level.get_brush_group(b)) == "":
loose.append(b)
_ok("setup: two loose brushes", loose.size() >= 2)
if loose.size() < 2:
return
# One object of two brushes (Ctrl+J), in collection W.
map.select_brushes(PackedInt32Array([int(loose[0]), int(loose[1])]))
CollectionOps.join(map)
persist.refold()
var g := String(level.get_brush_group(int(loose[1])))
map.select_brushes(PackedInt32Array([int(loose[0])]))
var w := CollectionOps.create_from_selection(map, "W")
persist.refold()
# ---- D1 / D2 ----
map.select_brushes(PackedInt32Array([int(loose[0]), int(loose[1])]))
await _dup()
var copies: Array = map.get("_sel_brushes")
var cg := String(level.get_brush_group(int(copies[0]))) if not copies.is_empty() else ""
_ok("D1 the copies form ONE new object", copies.size() == 2 and cg != "" and cg != g
and String(level.get_brush_group(int(copies[1]))) == cg, "%s vs %s" % [cg, g])
_ok("D2 ... in the source's collection", _in("g:" + cg) == [w], _in("g:" + cg))
persist.undo_last()
persist.refold()
_ok("D7 one Ctrl+Z takes the copies and the link back", _in("g:" + cg).is_empty()
and (level.get_group_brush_ids(cg) as PackedInt32Array).is_empty())
# ---- D3 ----
var shape: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(20, 0, 20))),
"size": [2, 1, 2], "material_id": 2, "level_id": 0})
persist.refold()
map.select_shape(shape)
CollectionOps.link_selection(map, w)
persist.refold()
map.select_shape(shape)
await _dup()
var sc := String(map.selected_shape())
_ok("D3 the shape's copy is in W too", sc != "" and sc != shape and _in("g:" + sc) == [w], [sc, _in("g:" + sc)])
# ---- D4 / D5 / D6 ----
var parent: String = map.create_object("marker", Transform3D(Basis(), Vector3(30, 1, 0)))
var child: String = map.create_object("marker", Transform3D(Basis(), Vector3(31, 1, 0)))
map.parent_objects([child], parent)
persist.begin_gesture("Link")
persist.record("col.link", {"target": w, "members": ["obj:" + child]})
persist.end_gesture()
persist.refold()
map.select_object(child)
await _dup()
var cc := String((map.selected_objects() as Array)[-1])
_ok("D4 the child's copy keeps its parent", cc != child and map.object_parent(cc) == parent, map.object_parent(cc))
_ok("D6 ... and is in the source's collection", _in("obj:" + cc) == [w], _in("obj:" + cc))
_ok("D4 ... where the child is", (map.object_node(cc) as Node3D).global_position.is_equal_approx(
(map.object_node(child) as Node3D).global_position))
map.select_objects([parent, child], child)
await _dup()
var made: Array = map.selected_objects()
var pc := "" # the parent's copy: top level
var kc := "" # the child's copy
for d: Variant in made:
if map.object_parent(String(d)) == "":
pc = String(d)
else:
kc = String(d)
_ok("D5 both copied", made.size() == 2 and pc != "" and kc != "", made)
_ok("D5 the child's copy hangs under the parent's copy", map.object_parent(kc) == pc, [map.object_parent(kc), pc])
# ---- D8 / D9: a shape selected as its PIECES (user 2026-10-17: "shift d seems to just
# move" — duplicating a staircase copied nothing and started no Move) ----
var st8: String = persist.record("shape.create", {"type": "stairs", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(60, 0, 60))),
"size": [3.0, 2.0, 1.5], "material_id": 2, "level_id": 0})
persist.refold()
var stairs_before := _count_type("stairs")
map.select_brushes(level.get_group_brush_ids(st8))
await _key(KEY_D, true)
_ok("D8 Shift+D on a staircase's pieces: a new staircase", _count_type("stairs") == stairs_before + 1,
[stairs_before, _count_type("stairs")])
_ok("D8 ... and Move picks it up", stack.get("active") != null, stack.get("active"))
stack.cancel()
persist.refold()
var plain := -1
for b: int in (level.get_live_brush_ids() as PackedInt32Array):
if map.shape_of_brush(b) == "":
plain = b
break
var ids9 := PackedInt32Array(level.get_group_brush_ids(st8))
ids9.append(plain)
var n9: int = (level.get_live_brush_ids() as PackedInt32Array).size()
var s9 := _count_type("stairs")
map.select_brushes(ids9)
await _key(KEY_D, true)
stack.cancel()
persist.refold()
_ok("D9 stairs pieces + a plain brush: the staircase AND the brush copied",
_count_type("stairs") == s9 + 1 and (level.get_live_brush_ids() as PackedInt32Array).size() == n9 + 1 + 11,
[_count_type("stairs") - s9, (level.get_live_brush_ids() as PackedInt32Array).size() - n9])
func _count_type(t: String) -> int:
var n := 0
for d: String in (persist.live_shapes() as Dictionary):
if String(persist.shape_state(d)["type"]) == t:
n += 1
return n
## A real key press (Input, not a call): the path the player's Shift+D takes.
func _key(k: Key, shift := false) -> void:
var e := InputEventKey.new()
e.keycode = k
e.physical_keycode = k
e.shift_pressed = shift
e.pressed = true
Input.parse_input_event(e)
await get_tree().process_frame
var u := e.duplicate()
u.pressed = false
Input.parse_input_event(u)
await get_tree().process_frame