extends Node
## GATE — SEVERAL COLLECTIONS, ONE NEW PARENT (user 2026-10-18: "in the organizer i can
## select a parent node and all of its children but i can't shift select many parent nodes
## and subsequent children ... i want to be able to select several groups and turn them
## into a new meta group that keeps the inheritance but now has another parent
## collection"). Rows are picked the way the mouse picks them: select + multi_selected.
## M1 Shift-selecting two collection rows selects ALL their brushes (a child
## collection's too), not just the last row's; both rows stay lit
## M2 Ctrl+G wraps them: a new collection under their common parent, both moved into it
## with their subtrees (a child collection keeps its parent), members untouched, and
## nothing linked twice into the new one
## M3 one Ctrl+Z puts it all back
## M4 a selected collection INSIDE another selected one rides along (not pulled out)
## M5 different parents: the new collection goes to Scene
## M6 a brush object selected with them (not inside them) is linked into the new one
## M7 + Collection with several collections selected wraps them too
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_meta_collection.tscn
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var panel: 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 < 18:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE meta_collection: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _frames(n: int) -> void:
for i in n:
await get_tree().process_frame
func _cols() -> Dictionary:
return persist.collection_tree()["cols"]
func _parent(c: String) -> String:
return String(_cols()[c]["parent"]) if _cols().has(c) else "<gone>"
## The collections not in `before` (what the last action made).
func _new_since(before: Dictionary) -> Array:
var out: Array = []
for d: String in _cols():
if not before.has(d):
out.append(d)
return out
## Select these rows as Shift / Ctrl-clicks do: each select() then multi_selected; the
## panel reads the whole selection one deferred pass later.
func _pick_rows(row_ids: Array) -> void:
panel.refresh()
var tree: Tree = panel.get("_tree")
tree.deselect_all()
var items: Dictionary = panel.get("_items")
for rid: String in row_ids:
var it: TreeItem = items.get(rid, null)
if it == null:
_ok("setup: row %s exists" % rid, false)
continue
it.select(0)
tree.multi_selected.emit(it, 0, true)
await _frames(3)
## Ctrl+G, then the name prompt confirmed (Enter) with `name`.
func _ctrl_g(name := "Collection") -> void:
Keymap.run("builder.collection_new")
await _frames(3)
var q: Node = map.get("_q_menu")
if q.name_open():
q.submit_name(name)
await _frames(4)
persist.refold()
await _frames(1)
func _undo() -> void:
persist.undo_gesture(persist.newest_live_gesture())
persist.refold()
await _frames(2)
## A loose brush made a brush object of its own in a new collection named `name`.
func _col_with(name: String, b: int, parent: String) -> String:
map.select_brushes(PackedInt32Array([b]))
var d := CollectionOps.create_from_selection(map, name, parent)
persist.refold()
return d
func _brushes_of(cs: Array) -> Array:
var out: Array = []
var t: Dictionary = persist.collection_tree()
for c: String in cs:
for m: String in CollectionFold.members_of(t, c, true):
if m.begins_with("g:"):
for b: int in (level.get_group_brush_ids(m.substr(2)) as PackedInt32Array):
if not out.has(b):
out.append(b)
out.sort()
return out
func _run() -> void:
persist = map.get("_persist")
level = map.get("level")
panel = map.get("_outliner")
var loose: Array = []
for x: int in (level.get_live_brush_ids() as PackedInt32Array):
if String(level.get_brush_group(x)) == "":
loose.append(String(level.get_brush_key(x)))
_ok("setup: five loose brushes", loose.size() >= 5, loose.size())
if loose.size() < 5 or panel == null:
return
var by := func(k: String) -> int: return int(level.brush_by_key(k))
var p := CollectionOps.create_empty(map, "P")
persist.refold()
var a := _col_with("A", by.call(loose[0]), p)
var b := _col_with("B", by.call(loose[1]), p)
var b1 := _col_with("B1", by.call(loose[2]), b)
var d := _col_with("D", by.call(loose[3]), "")
_ok("setup: P{A, B{B1}}, D", _parent(a) == p and _parent(b) == p and _parent(b1) == b and _parent(d) == "",
[_parent(a), _parent(b), _parent(b1), _parent(d)])
var a_members: Array = (_cols()[a]["members"] as Array).duplicate()
# ---- M1 ----
await _pick_rows(["col:" + a, "col:" + b])
var sel: Array = Array(map.get("_sel_brushes"))
sel.sort()
var want := _brushes_of([a, b])
_ok("M1 two collection rows select ALL their brushes (B1's too)", sel == want and want.size() >= 3, [sel, want])
_ok("M1 both rows stay lit", panel.selected_collections() == [a, b], panel.selected_collections())
# ---- M2 ----
var before := _cols().duplicate()
await _ctrl_g("Rooms")
var made := _new_since(before)
_ok("M2 Ctrl+G made one collection", made.size() == 1, made)
if made.size() != 1:
return
var n: String = made[0]
_ok("M2 named as typed in the prompt", String(_cols()[n]["name"]) == "Rooms", _cols()[n]["name"])
_ok("M2 it sits where they were (under P)", _parent(n) == p, _parent(n))
_ok("M2 A and B moved into it", _parent(a) == n and _parent(b) == n, [_parent(a), _parent(b)])
_ok("M2 B1 is still B's child (the subtree came along)", _parent(b1) == b, _parent(b1))
_ok("M2 A's members untouched", _cols()[a]["members"] == a_members, _cols()[a]["members"])
_ok("M2 nothing linked twice into the new one", (_cols()[n]["members"] as Array).is_empty(), _cols()[n]["members"])
# ---- M3 ----
await _undo()
_ok("M3 one Ctrl+Z: the new one is gone, A and B back under P",
not _cols().has(n) and _parent(a) == p and _parent(b) == p and _parent(b1) == b,
[_cols().has(n), _parent(a), _parent(b), _parent(b1)])
# ---- M4 ----
await _pick_rows(["col:" + b, "col:" + b1])
before = _cols().duplicate()
await _ctrl_g()
made = _new_since(before)
var n4: String = made[0] if made.size() == 1 else ""
_ok("M4 B and its own child B1 selected: B moves, B1 stays inside B",
n4 != "" and _parent(b) == n4 and _parent(b1) == b and _parent(n4) == p, [made, _parent(b), _parent(b1)])
await _undo()
# ---- M5 ----
await _pick_rows(["col:" + a, "col:" + d])
before = _cols().duplicate()
await _ctrl_g()
made = _new_since(before)
var n5: String = made[0] if made.size() == 1 else ""
_ok("M5 different parents (P, Scene): the new one goes to Scene",
n5 != "" and _parent(n5) == "" and _parent(a) == n5 and _parent(d) == n5, [made, _parent(n5) if n5 != "" else ""])
await _undo()
# ---- M6 ----
var k4: String = loose[4]
persist.begin_gesture("setup")
persist.record("brush.regroup", {"brush_keys": [k4], "group": k4})
persist.end_gesture()
persist.refold()
panel.refresh()
var row4: String = panel.call("_row_of_brush", by.call(k4), level)
_ok("setup: the brush object has a row of its own", row4 == "group:" + k4, row4)
await _pick_rows(["col:" + a, row4])
before = _cols().duplicate()
await _ctrl_g()
made = _new_since(before)
var n6: String = made[0] if made.size() == 1 else ""
_ok("M6 a brush object picked with them is linked in; A's own is not",
n6 != "" and _cols()[n6]["members"] == ["g:" + k4] and _parent(a) == n6,
_cols()[n6]["members"] if n6 != "" else made)
await _undo()
# ---- M7 ----
await _pick_rows(["col:" + a, "col:" + b])
before = _cols().duplicate()
panel.call("_on_new_collection")
await _frames(2)
var q: Node = map.get("_q_menu")
_ok("M7 + Collection asks for the name", q.name_open())
q.submit_name("Meta")
await _frames(4)
persist.refold()
await _frames(1)
made = _new_since(before)
var n7: String = made[0] if made.size() == 1 else ""
_ok("M7 + Collection with several selected wraps them too, named",
n7 != "" and _parent(n7) == p and _parent(a) == n7 and _parent(b) == n7
and String(_cols()[n7]["name"]) == "Meta", [made])
await _undo()