OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / brush_group_gate.gd

193 lines · gd ref:
extends SceneTree
## OPERATOR GROUPS — one arch is one thing, hyper-games #28.
##
##     "$HYPER_TPS_GODOT" --headless --script scenes/test/brush_group_gate.gd
##
## An arch is eight voussoirs and a clearance volume. Until the group key existed
## nothing said they belonged together, so moving the ring left the clearance
## behind — and because the clearance is what the terrain shell is cut from, the
## hillside kept a passage where the arch no longer was. That is the failure this
## exists to stop, and it is invisible from inside the level: the arch looks fine,
## the hole is somewhere else.
##
## TWO TIERS, deliberately (the user's call, 2026-08-29): the GROUP is durable —
## it replicates and it survives a reload, because an arch has to be one thing on
## every peer. HIDING stays view-local. So this gate asserts the group is in the
## journal and says nothing about eyes.
##
## THE ROW IS DERIVED, NOT STORED. The outliner's "passage" row is every brush
## carrying the key, computed each build. There is no membership list, so there is
## nothing to fall out of step with the geometry — which is the failure mode of
## keeping the two beside each other.

var _failures: Array[String] = []
var _frame := 0


func _check(label: String, ok: bool, detail: String) -> void:
	if not ok:
		_failures.append("%s (%s)" % [label, detail])
	print("  %-52s %s  %s" % [label, "OK  " if ok else "FAIL", detail])


static func _box(lo: Vector3, hi: Vector3) -> PackedFloat32Array:
	return PackedFloat32Array([
		-1, 0, 0, -lo.x,   1, 0, 0, hi.x,
		0, -1, 0, -lo.y,   0, 1, 0, hi.y,
		0, 0, -1, -lo.z,   0, 0, 1, hi.z,
	])


func _process(_delta: float) -> bool:
	_frame += 1
	if _frame < 3:
		return false

	print("brush group gate")
	var lvl := ClassDB.instantiate("DCLevel") as Node3D
	root.add_child(lvl)

	# A "passage": two solids and a clearance volume, one invocation.
	var g := "peerA:12"
	var a: int = lvl.add_brush(_box(Vector3(0, 0, 0), Vector3(2, 2, 2)), 0,
		PackedInt32Array(), 0, false, "peerA:12", false, g)
	var b: int = lvl.add_brush(_box(Vector3(2, 0, 0), Vector3(4, 2, 2)), 0,
		PackedInt32Array(), 0, false, "peerA:13", false, g)
	var c: int = lvl.add_brush(_box(Vector3(0, 2, 0), Vector3(4, 4, 2)), 0,
		PackedInt32Array(), 0, false, "peerA:14", true, g)
	# And an ungrouped wall, so "everything is in a group" cannot pass by accident.
	var loose: int = lvl.add_brush(_box(Vector3(9, 0, 0), Vector3(11, 2, 2)), 0,
		PackedInt32Array(), 0, false, "peerA:15")
	lvl.rebuild()

	_check("every piece carries the key", lvl.get_brush_group(a) == g
		and lvl.get_brush_group(b) == g and lvl.get_brush_group(c) == g, g)
	_check("an ungrouped brush carries none", lvl.get_brush_group(loose) == "",
		"'%s'" % lvl.get_brush_group(loose))
	var members: PackedInt32Array = lvl.get_group_brush_ids(g)
	_check("the group resolves to its three brushes", members.size() == 3,
		"%d" % members.size())
	_check("including the clearance volume", members.has(c), "#%d" % c)
	_check("and not the loose wall", not members.has(loose), "#%d excluded" % loose)
	_check("get_group_keys finds exactly one group",
		(lvl.get_group_keys() as PackedStringArray).size() == 1,
		"%s" % str(lvl.get_group_keys()))
	# "" is the ABSENCE of a group, not a group — otherwise every imported brush
	# in START.MAP would resolve as one enormous 1164-member row.
	_check("\"\" resolves to nothing, not to every ungrouped brush",
		(lvl.get_group_brush_ids("") as PackedInt32Array).is_empty(),
		"%d" % (lvl.get_group_brush_ids("") as PackedInt32Array).size())
	_check("get_brush reports the group too",
		String((lvl.get_brush(a) as Dictionary).get("group", "")) == g, g)

	# ---- IT REPLICATES. The durable half of the two-tier decision. ---------
	var peer := ClassDB.instantiate("DCLevel") as Node3D
	root.add_child(peer)
	var st: Dictionary = peer.apply_journal([
		{"author": "peerA", "seq": 12, "kind": "brush.create", "undone": false,
			"planes": _to_list(_box(Vector3(0, 0, 0), Vector3(2, 2, 2))), "proj": [],
			"material_id": 0, "level_id": 0, "sky": false, "clearance": false,
			"group": g},
		{"author": "peerA", "seq": 14, "kind": "brush.create", "undone": false,
			"planes": _to_list(_box(Vector3(0, 2, 0), Vector3(4, 4, 2))), "proj": [],
			"material_id": 0, "level_id": 0, "sky": false, "clearance": true,
			"group": g},
	])
	_check("a replaying peer applies both", int(st.get("applied", 0)) == 2,
		"applied %d" % int(st.get("applied", 0)))
	_check("and rebuilds them AS A GROUP",
		(peer.get_group_brush_ids(g) as PackedInt32Array).size() == 2,
		"%d member(s)" % (peer.get_group_brush_ids(g) as PackedInt32Array).size())
	# THE POINT: without the group in the record a peer would hold nine unrelated
	# brushes and could take the arch apart in ways its author could not.
	_check("the clearance came back as clearance too",
		(peer.get_clearance_brush_ids() as PackedInt32Array).size() == 1,
		"%d" % (peer.get_clearance_brush_ids() as PackedInt32Array).size())

	# ---- moving the group moves the cut with it ---------------------------
	# The failure in one line: translate the ring, not the clearance, and the
	# terrain keeps a hole where the arch is not.
	var before: Vector3 = (lvl.get_brush(c) as Dictionary)["center"]
	for m: int in members:
		lvl.translate_brush(m, Vector3(5, 0, 0))
	lvl.rebuild()
	var after: Vector3 = (lvl.get_brush(c) as Dictionary)["center"]
	_check("translating the group carries the clearance",
		after.is_equal_approx(before + Vector3(5, 0, 0)),
		"%s -> %s" % [str(before), str(after)])

	# ---- SCALING THE GROUP TAKES THE CUT WITH IT --------------------------
	# The reported ask: "if the length changes it should be able to change with
	# it". A clearance volume is IN the group, so scaling the group by one factor
	# about one pivot scales the cut identically — the ring cannot outgrow the
	# bore that serves it. That is why this is a group op and not a per-brush one.
	var pivot := Vector3(2, 2, 1)
	var ring_before: Vector3 = (lvl.get_brush(a) as Dictionary)["aabb_max"] as Vector3 \
		- ((lvl.get_brush(a) as Dictionary)["aabb_min"] as Vector3)
	var cut_before: Vector3 = (lvl.get_brush(c) as Dictionary)["aabb_max"] as Vector3 \
		- ((lvl.get_brush(c) as Dictionary)["aabb_min"] as Vector3)
	var sf := Vector3(2.0, 1.0, 1.0)
	var ok_all := true
	for m: int in lvl.get_group_brush_ids(g):
		if not lvl.scale_brush(m, sf, pivot):
			ok_all = false
	lvl.rebuild()
	_check("every member scales", ok_all, "x2 on X")
	var ring_after: Vector3 = (lvl.get_brush(a) as Dictionary)["aabb_max"] as Vector3 \
		- ((lvl.get_brush(a) as Dictionary)["aabb_min"] as Vector3)
	var cut_after: Vector3 = (lvl.get_brush(c) as Dictionary)["aabb_max"] as Vector3 \
		- ((lvl.get_brush(c) as Dictionary)["aabb_min"] as Vector3)
	_check("the ring doubled on X", absf(ring_after.x - ring_before.x * 2.0) < 1e-3,
		"%.3f -> %.3f" % [ring_before.x, ring_after.x])
	_check("AND SO DID THE CUT", absf(cut_after.x - cut_before.x * 2.0) < 1e-3,
		"%.3f -> %.3f" % [cut_before.x, cut_after.x])
	_check("the untouched axes are untouched",
		absf(cut_after.y - cut_before.y) < 1e-3 and absf(cut_after.z - cut_before.z) < 1e-3,
		"y %.3f z %.3f" % [cut_after.y, cut_after.z])

	# EXACTLY REVERSIBLE, which is what "planes, not vertices" buys: scaling back
	# by the inverse about the same pivot restores the original to the digit.
	for m: int in lvl.get_group_brush_ids(g):
		lvl.scale_brush(m, Vector3(0.5, 1, 1), pivot)
	lvl.rebuild()
	var cut_back: Vector3 = (lvl.get_brush(c) as Dictionary)["aabb_max"] as Vector3 \
		- ((lvl.get_brush(c) as Dictionary)["aabb_min"] as Vector3)
	_check("scaling back restores it exactly", cut_back.is_equal_approx(cut_before),
		"%s vs %s" % [str(cut_back), str(cut_before)])

	# A ZERO FACTOR IS REFUSED, not clamped: it collapses the solid to a plane and
	# no later scale brings it back.
	_check("a zero factor is refused",
		not lvl.scale_brush(a, Vector3(0, 1, 1), pivot), "nothing changed")

	# AND IT REPLICATES: brush.scale carries the pivot, because "the group's
	# centre" is a property of the selection and a peer scaling about its own
	# idea of it would land the pieces apart.
	var sc: Dictionary = peer.apply_journal([
		{"author": "peerA", "seq": 12, "kind": "brush.create", "undone": false,
			"planes": _to_list(_box(Vector3(0, 0, 0), Vector3(2, 2, 2))), "proj": [],
			"material_id": 0, "level_id": 0, "sky": false, "clearance": false,
			"group": g},
		{"author": "peerA", "seq": 20, "kind": "brush.scale", "undone": false,
			"brush_key": "peerA:12", "scale": Vector3(2, 1, 1), "pivot": pivot},
	])
	_check("a peer replays brush.scale", int(sc.get("applied", 0)) == 2,
		"applied %d skipped %d" % [int(sc.get("applied", 0)), int(sc.get("skipped", 0))])
	var pb: Dictionary = peer.get_brush(peer.brush_by_key("peerA:12"))
	_check("and lands the same size the author had",
		absf(((pb["aabb_max"] as Vector3) - (pb["aabb_min"] as Vector3)).x - 4.0) < 1e-3,
		"%.3f wide" % ((pb["aabb_max"] as Vector3) - (pb["aabb_min"] as Vector3)).x)

	print("\nRESULT: %s" % ("PASS" if _failures.is_empty() else "FAIL"))
	for f: String in _failures:
		print("  - %s" % f)
	quit(0 if _failures.is_empty() else 1)
	return true


static func _to_list(p: PackedFloat32Array) -> Array:
	var out: Array = []
	for f: float in p:
		out.append(f)
	return out