OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_cable_journal.gd

173 lines · gd ref:
extends Node
## GATE — CABLES IN THE FOLD (#120 L3, docs/node-tree-and-stacks.md §11.2).
##   J1  add_cable: ONE gesture (curve.create + shape.create); a 10 m cable, 10 pieces;
##       the shape's frame / size are the curve's bounds (padded)
##   J2  a curve edit re-folds and the pieces follow it (renamed: no stale restore)
##   J3  a cable param (radius) widens the pieces
##   J4  the curve removed: no pieces; Ctrl+Z: back
##   J5  two arrival orders of the same records give the same piece names
##   J6  a mirror modifier copies the cable
##   J7  a cut through the cable cuts it
##   J8  a flatten keeps the cable on its (re-minted) curve
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_journal.tscn

const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")

var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: 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 < 13:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE cable_journal: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _pts(ps: Array) -> Array:
	return ps.map(func(p: Vector3) -> Dictionary: return CurveMath.point(p, Vector3.ZERO))


func _solids(dot: String) -> Array:
	var out: Array = []
	for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
		var d: Dictionary = level.get_brush(b)
		if not bool(d.get("clearance", false)) and not bool(d.get("cutter", false)):
			out.append(b)
	return out


func _aabb(dot: String) -> AABB:
	var bb := AABB()
	var first := true
	for b: int in _solids(dot):
		for c: Vector3 in FaceKit.corners(level.get_brush(b)["planes"]):
			if first:
				bb = AABB(c, Vector3.ZERO)
				first = false
			else:
				bb = bb.expand(c)
	return bb


func _curve_of(dot: String) -> String:
	return String((persist.shape_state(dot)["params"] as Dictionary).get("curve", ""))


func _run() -> void:
	persist = map.get("_persist")
	level = map.get("level")
	persist.refold()

	# ---- J1 ----
	var n0: int = (persist.records(false) as Array).size()
	var c := map.add_cable(_pts([Vector3(300, 0.5, 300), Vector3(310, 0.5, 300)]), false, {"radius": 0.1}) as String
	var recs: Array = persist.records(false)
	_ok("J1 add_cable: two records (curve + shape)", recs.size() == n0 + 2, recs.size() - n0)
	persist.undo_last()
	persist.refold()
	_ok("J1 ... ONE Ctrl+Z takes both (one gesture)", not (persist.live_shapes() as Dictionary).has(c)
		and (persist.curve_states() as Dictionary).is_empty())
	persist.redo_last()
	persist.refold()
	_ok("J1 a 10 m cable: 10 pieces", _solids(c).size() == 10, _solids(c).size())
	var st: Dictionary = persist.shape_state(c)
	var fo: Vector3 = (st["frame"] as Transform3D).origin
	_ok("J1 the frame is the curve's bounds, padded 1.6 r",
		fo.distance_to(Vector3(300 - 0.16, 0.5 - 0.16, 300 - 0.16)) < 0.01
		and (st["size"] as Vector3).distance_to(Vector3(10.32, 0.32, 0.32)) < 0.01, [fo, st["size"]])

	# ---- J2 ----
	map.set_curve_point(_curve_of(c), 1, {"p": Vector3(310, 0.5, 310)})
	var bb := _aabb(c)
	# 310 + r cos 45° (≈ 310.07): the end now leaves along the NEW diagonal chord. Until
	# 2026-10-20 set_curve_point kept the end's stale +x handle and the cap stayed square to
	# x (310 + r = 310.1) — the #129 deferred bug (gate_curve_point_handles).
	_ok("J2 the curve's end moved: the cable reaches z 310", absf(bb.end.z - (310.0 + 0.1 * cos(PI / 4.0))) < 0.02, bb)

	# ---- J3 ----
	persist.begin_gesture("radius")
	persist.record("shape.set", {"target": c, "params": {"radius": 0.2}})
	persist.end_gesture()
	persist.refold()
	_ok("J3 radius 0.2: the cable is 0.4 high", absf(_aabb(c).size.y - 0.4) < 0.01, _aabb(c).size)

	# ---- J4 ----
	map.remove_curve(_curve_of(c))
	_ok("J4 the curve removed: no pieces", _solids(c).is_empty(), _solids(c).size())
	persist.undo_last()
	persist.refold()
	_ok("J4 Ctrl+Z: the cable is back", _solids(c).size() > 0, _solids(c).size())

	# ---- J5 ----
	var all: Array = persist.records(false)
	var shape_recs: Array = all.filter(func(x: Dictionary) -> bool: return ShapeFold.handles(String(x["kind"])))
	var curves: Dictionary = persist.curve_states()
	var n1: Array = ShapeFold.expand(shape_recs, {}, curves).map(func(x: Dictionary) -> String: return String(x["author"]))
	var rev := shape_recs.duplicate()
	rev.reverse()
	var n2: Array = ShapeFold.expand(rev, {}, curves).map(func(x: Dictionary) -> String: return String(x["author"]))
	n1.sort()
	n2.sort()
	_ok("J5 another arrival order, the same piece names", n1 == n2 and not n1.is_empty())

	# ---- J6 ----
	var m := _solids(c).size()
	var md: String = map.add_modifier("mirror", c)
	_ok("J6 a mirror modifier copies the cable", md != "" and _solids(c).size() == 2 * m, [m, _solids(c).size()])
	map.remove_modifier(md)

	# ---- J8 ---- (before J7: a flatten refuses while a live cut exists)
	var before := _solids(c).size()
	var r8: Dictionary = persist.flatten_map()
	var cables := 0
	var on_live := 0
	for d: String in (persist.live_shapes() as Dictionary):
		if String(persist.shape_state(d)["type"]) == "cable":
			cables += 1
			if persist.curve_states().has(_curve_of(d)) and _solids(d).size() == before:
				on_live += 1
	_ok("J8 a flatten keeps the cable", not r8.has("error") and cables == 1, [cables, r8.get("error", "")])
	_ok("J8 ... on its re-minted curve, the same pieces", on_live == 1, on_live)

	# ---- J7 ----
	var k := map.add_cable(_pts([Vector3(400, 0.5, 400), Vector3(404, 0.5, 400)]), false, {"radius": 0.1, "max_len": 4.0}) as String
	_ok("J7 a 4 m cable, max_len 4: one piece", _solids(k).size() == 1, _solids(k).size())
	var cutter: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
		"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(401.5, 0, 399))),
		"size": [1.0, 2.0, 2.0], "material_id": 2, "level_id": 0})
	persist.begin_gesture("Cut")
	persist.record("op.create", {"op": "subtract", "cutter": cutter, "targets": [k]})
	persist.end_gesture()
	persist.refold()
	var gap := true
	for b: int in _solids(k):
		for v: Vector3 in FaceKit.corners(level.get_brush(b)["planes"]):
			if v.x > 401.51 and v.x < 402.49:
				gap = false
	_ok("J7 a box cut through it: pieces on both sides, none in the gap", _solids(k).size() >= 2 and gap, _solids(k).size())