OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_asset_fold.gd

279 lines · gd ref:
extends Node
## GATE — PLACED ASSETS IN THE FOLD (#120 L4 step 2, docs/node-tree-and-stacks.md §15.2,
## asset_fold.gd). gate_asset_doc's room ("Kit": a box A with a mirror cut by K from
## "Cutters", a cable on its curve, a plain brush object, a scene object, "Sub" with B),
## saved as a blob, then PLACED. The oracle is the room's OWN fold of the originals — an
## independent path (shape / mod / op / curve / brush records the user wrote) against the
## synthetic one:
##   F1  placed at its own pivot: the SAME brushes, face windings bit-identical (digest)
##   F2  moved +20 m x: every instance brush = an original's box + 20 m (1 mm)
##   F3  turned 90° about y: every instance brush = an original's box turned (1 mm)
##   F4  the instance's pieces are RENAMED by the move (BrushCreate restores by name)
##   F5  a second placement: twice the brushes; both instance groups map to their placement
##   F6  undo the turn -> F2's brushes again; asset.remove -> none; undo -> back
##   F7  a blob not here: ONE wire (cutter) box, the oid listed missing; the blob arrives ->
##       the real pieces
##   F8  put_asset_blob refuses bytes that do not hash to the oid, and garbage
##   F9  the instance's object exists, moved with it
##   F10 the instance's cut: its cutter is wire (not solid), the target is cut (A's pieces)
##   F11 a placement right after a fold with NO live cut (so the tail fold is tried): it is
##       expanded, not handed to DCLevel's tail and skipped
##
## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_fold.tscn

const AssetDoc := preload("res://scenes/maps/asset_doc.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 = "probe"
	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
	persist = map.get("_persist")
	level = map.get("level")
	_run()
	if checks < 18:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE asset_fold: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _box(at: Vector3, size: Vector3) -> String:
	return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
		"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z],
		"material_id": 2, "level_id": 0})


static func _box_planes(lo: Vector3, hi: Vector3) -> Array:
	return [-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]


## Live brushes whose group is in `groups`.
func _brushes_in(groups: Dictionary) -> Array:
	var out: Array = []
	for b: int in (level.get_live_brush_ids() as PackedInt32Array):
		if groups.has(String(level.get_brush_group(b))):
			out.append(b)
	return out


## Live brushes of placement `pdot`.
func _brushes_of(pdot: String) -> Array:
	var out: Array = []
	for b: int in (level.get_live_brush_ids() as PackedInt32Array):
		if persist.instance_of(String(level.get_brush_group(b))) == pdot:
			out.append(b)
	return out


## Every brush's face windings, as bytes, sorted; hashed.
func _digest(ids: Array) -> String:
	var blobs: Array = []
	for b: int in ids:
		var bb := PackedByteArray()
		for f in (level.get_brush(b)["planes"] as PackedFloat32Array).size() / 4:
			bb.append_array((level.get_face_winding(b, f) as PackedVector3Array).to_byte_array())
			bb.append(255)
		blobs.append(bb)
	blobs.sort_custom(func(x: PackedByteArray, y: PackedByteArray) -> bool: return x.hex_encode() < y.hex_encode())
	var h := HashingContext.new()
	h.start(HashingContext.HASH_SHA256)
	for bb2: PackedByteArray in blobs:
		h.update(bb2)
	return h.finish().hex_encode().substr(0, 16)


## Each brush's box (corners mapped by `xf`), matched one to one against `want` (1 mm).
func _boxes_match(got: Array, want: Array, xf: Transform3D) -> bool:
	if got.size() != want.size():
		return false
	var pool: Array = []
	for b: int in want:
		var d: Dictionary = level.get_brush(b)
		pool.append(xf * AABB(d["aabb_min"], (d["aabb_max"] as Vector3) - (d["aabb_min"] as Vector3)))
	for b2: int in got:
		var d2: Dictionary = level.get_brush(b2)
		var g := AABB(d2["aabb_min"], (d2["aabb_max"] as Vector3) - (d2["aabb_min"] as Vector3))
		var hit := -1
		for i in pool.size():
			var w: AABB = pool[i]
			if w.position.distance_to(g.position) < 1.0e-3 and w.end.distance_to(g.end) < 1.0e-3:
				hit = i
				break
		if hit < 0:
			return false
		pool.remove_at(hit)
	return true


func _keys(ids: Array) -> Array:
	var out: Array = []
	for b: int in ids:
		out.append(String(level.get_brush_key(b)))
	out.sort()
	return out


func _place(oid: String, frame: Transform3D) -> String:
	persist.begin_gesture("Place asset")
	var d: String = persist.record("asset.place", {"asset": oid, "name": "Kit", "frame": SceneObjects.xform_to(frame)})
	persist.end_gesture()
	persist.refold()
	return d


func _xform(pdot: String, frame: Transform3D) -> void:
	persist.begin_gesture("Move asset")
	persist.record("asset.xform", {"target": pdot, "frame": SceneObjects.xform_to(frame)})
	persist.end_gesture()
	persist.refold()


func _run() -> void:
	# NON-ROUND on purpose: at exact binary values (500, 502.5) (x - p) + p == x anyway, and
	# F1 could not tell a transform composed first from one applied in two steps.
	var O0 := Vector3(500.137, 0.31, 499.873)
	persist.begin_gesture("asset room")
	var a := _box(O0, Vector3(2, 2, 2))
	var b := _box(O0 + Vector3(6, 0, 0), Vector3(1, 3, 1))
	var k := _box(O0 + Vector3(0.5, 0.5, 0), Vector3(1, 1, 3))
	persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
	var op1: String = persist.record("op.create", {"op": "subtract", "cutter": k, "targets": [a]})
	var grp := "probe:grp1"
	for i in 2:
		var lo := O0 + Vector3(0, 0, 6 + i * 2)
		persist.record("brush.create", {"planes": _box_planes(lo, lo + Vector3(1, 1, 1)), "proj": [],
			"material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": grp, "face_tex": []})
	persist.end_gesture()
	persist.refold()
	var cable: String = map.add_cable([CurveMath.point(O0 + Vector3(0, 1, -4)), CurveMath.point(O0 + Vector3(6, 1, -4))],
		false, {"radius": 0.05})
	var obj: String = map.create_object(PrefabCatalogue.ids()[0], Transform3D(Basis.IDENTITY, O0 + Vector3(3, 0, 3)))
	persist.begin_gesture("asset collections")
	var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""})
	var sub: String = persist.record("col.create", {"name": "Sub", "parent": kit})
	var cuts: String = persist.record("col.create", {"name": "Cutters", "parent": ""})
	persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + cable, "g:" + grp, "obj:" + obj]})
	persist.record("col.link", {"target": sub, "members": ["g:" + b]})
	persist.record("col.link", {"target": cuts, "members": ["g:" + k]})
	persist.end_gesture()
	persist.refold()

	var w: Dictionary = AssetDoc.write(persist, kit)
	var bytes := AssetDoc.encode(w["doc"])
	var oid := AssetDoc.hash_of(bytes)
	var doc: Dictionary = w["doc"]
	var pivot: Vector3 = doc["pivot"]
	var originals := _brushes_in({a: true, b: true, k: true, cable: true, grp: true})
	_ok("setup: the Kit's brushes (A + mirror + cut pieces, B, K, the cable, 2 brushes)", originals.size() >= 8, originals.size())

	# ---- F8 first: refusals ----
	var tampered := bytes.duplicate()
	tampered[tampered.size() - 1] ^= 1
	_ok("F8 put_asset_blob refuses bytes that do not hash to the oid", not persist.put_asset_blob(oid, tampered))
	var junk := PackedByteArray([9, 9, 9, 9])
	_ok("F8 ... and garbage under its own hash", not persist.put_asset_blob(AssetDoc.hash_of(junk), junk))

	# ---- F7: placed before the blob is here ----
	var at_pivot := Transform3D(Basis.IDENTITY, pivot)
	var p1 := _place(oid, at_pivot)
	var waiting := _brushes_of(p1)
	_ok("F7 no blob yet: ONE wire box", waiting.size() == 1 and bool(level.get_brush(waiting[0]).get("cutter", false)) if waiting.size() == 1 else false, waiting.size())
	_ok("F7 ... and the oid is listed missing", (persist.missing_assets() as Array).has(oid))
	_ok("F7 the blob arrives (put_asset_blob)", persist.put_asset_blob(oid, bytes))
	persist.refold()

	# ---- F1 ----
	var inst := _brushes_of(p1)
	_ok("F1 at its own pivot: as many brushes as the originals", inst.size() == originals.size(), [inst.size(), originals.size()])
	_ok("F1 ... with bit-identical face windings (digest)", _digest(inst) == _digest(originals), [_digest(inst), _digest(originals)])
	_ok("F7 ... and nothing is missing any more", (persist.missing_assets() as Array).is_empty())

	# ---- F10 ----
	var cutter_n := 0
	for b3: int in inst:
		if bool(level.get_brush(b3).get("cutter", false)):
			cutter_n += 1
	var k_n := (level.get_group_brush_ids(k) as PackedInt32Array).size()
	_ok("F10 the instance's cutter is wire, as the original's", cutter_n == k_n and k_n > 0, [cutter_n, k_n])

	# ---- F2 / F4 ----
	var keys0 := _keys(inst)
	_xform(p1, Transform3D(Basis.IDENTITY, pivot + Vector3(20, 0, 0)))
	var moved := _brushes_of(p1)
	_ok("F2 moved +20 m x: every brush = an original's box + 20 m", _boxes_match(moved, originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0))))
	var keys1 := _keys(moved)
	var shared := keys1.filter(func(x: String) -> bool: return keys0.has(x) and not x.contains("/A0007") and not x.contains("/A0008"))
	_ok("F4 the move renamed the shapes' pieces", keys1.size() == keys0.size() and shared.size() < keys0.size() / 2, [shared.size(), keys0.size()])

	# ---- F3 ----
	var turn := Basis(Vector3.UP, PI / 2)
	_xform(p1, Transform3D(turn, pivot + Vector3(20, 0, 0)))
	var turned := _brushes_of(p1)
	var m := Transform3D(turn, pivot + Vector3(20, 0, 0)) * Transform3D(Basis.IDENTITY, -pivot)
	_ok("F3 turned 90° about y: every brush = an original's box turned", _boxes_match(turned, originals, m))

	# ---- F9 ----
	var objn := 0
	var obj_ok := false
	for e: Dictionary in (map.get_node("SceneObjects").entries() as Array):
		if persist.instance_of(String(e["dot"])) == p1:
			objn += 1
			var want: Vector3 = m * (map.get_node("SceneObjects").node_for(obj) as Node3D).position
			obj_ok = (e["node"] as Node3D).position.distance_to(want) < 1.0e-3
	_ok("F9 the instance's object exists, turned and moved with it", objn == 1 and obj_ok, objn)

	# ---- F6 ----
	persist.undo_last()
	persist.refold()
	_ok("F6 undo the turn -> F2's brushes again", _boxes_match(_brushes_of(p1), originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0))))
	persist.begin_gesture("Delete asset")
	persist.record("asset.remove", {"target": p1})
	persist.end_gesture()
	persist.refold()
	_ok("F6 asset.remove -> no instance brushes", _brushes_of(p1).is_empty())
	persist.undo_last()
	persist.refold()
	_ok("F6 undo the remove -> back", _brushes_of(p1).size() == originals.size())

	# ---- F5 ----
	var p2 := _place(oid, Transform3D(Basis.IDENTITY, pivot + Vector3(0, 0, 40)))
	_ok("F5 a second placement: its own brushes, as many", _brushes_of(p2).size() == originals.size()
		and _boxes_match(_brushes_of(p2), originals, Transform3D(Basis.IDENTITY, Vector3(0, 0, 40))))
	_ok("F5 ... and the first is untouched", _boxes_match(_brushes_of(p1), originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0))))
	_ok("F5 the fold skipped nothing", int(persist.get("_last_fold").get("skipped", 0)) == 0, persist.get("_last_fold").get("skipped", 0))

	# ---- F11: a placement as the ONLY new record after a fold with no live cut ----
	# A live cut folds at OP_SEQ_BASE + seq, so while one exists every new record sorts
	# before it and the tail fold is refused anyway. With none, an asset.place must still
	# not be handed to DCLevel's tail (it would be skipped there, never expanded).
	persist.begin_gesture("clear")
	persist.record("asset.remove", {"target": p1})
	persist.record("asset.remove", {"target": p2})
	persist.record("op.remove", {"target": op1})
	persist.end_gesture()
	persist.refold()
	var p3 := _place(oid, Transform3D(Basis.IDENTITY, pivot + Vector3(0, 0, 80)))
	_ok("F11 placed with no live cut in the room: all its brushes", _brushes_of(p3).size() == inst.size(),
		[_brushes_of(p3).size(), inst.size(), persist.get("_last_fold").get("brush_fold", "")])