OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_asset_doc.gd

241 lines · gd ref:
extends Node
## GATE — THE ASSET DOCUMENT (#120 L4 step 1, docs/node-tree-and-stacks.md §15.1, asset_doc.gd).
## A room built through the map's own paths, one collection "Kit" holding: a box A with a
## mirror, cut by K (a cutter in its own "Cutters" collection, as Box Cutter files it); a
## cutter K2 that only cuts a box O OUTSIDE Kit; a cable on its curve; a plain brush object;
## a scene object; a sub-collection "Sub" with a box B.
##   D1  encode -> decode -> encode: byte-identical; D1b key order does not matter
##   D2  two writes of the same room: the same oid
##   D3  nothing in the records names the room (no "probe" dot or group survives)
##   D4  every reference (target, cutter, targets, link, curve, profile, parent, members)
##       resolves to a local dot the doc defines (a record, or a brush object's group)
##   D5  contents: A, B, K (brought in), the cable shape; its curve; the mirror; ONE cut
##       (A by K) with only A as target; the plain brushes under one local group; the
##       object; the sub-collection with B
##   D6  K2 and its cut are left out and REPORTED; K's arrival is reported
##   D7  pivot = the bounds' base centre; the bounds hold every brush the contents fold to
##   D8  decode refuses: a blob with an Object, a record of another kind, a non-local dot,
##       a wrong version, garbage
##   D9  the room changes (A moved) -> a different oid (D2 is not vacuous)
##
## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_doc.tscn

const AssetDoc := preload("res://scenes/maps/asset_doc.gd")

var fails := 0
var checks := 0
var map: Node3D
var persist: 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")
	_run()
	if checks < 20:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE asset_doc: %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]


## Every String anywhere in `v`.
func _strings(v: Variant, out: Array) -> void:
	if v is String or v is StringName:
		out.append(String(v))
	elif v is Dictionary:
		for k: Variant in (v as Dictionary):
			_strings(k, out)
			_strings((v as Dictionary)[k], out)
	elif v is Array:
		for x: Variant in (v as Array):
			_strings(x, out)


func _run() -> void:
	var O0 := Vector3(500, 0, 500)
	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))
	var o := _box(O0 + Vector3(-20, 0, 0), Vector3(2, 2, 2))
	var k2 := _box(O0 + Vector3(-20, 0.5, 0), Vector3(1, 1, 3))
	persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
	persist.record("op.create", {"op": "subtract", "cutter": k, "targets": [a]})
	persist.record("op.create", {"op": "subtract", "cutter": k2, "targets": [o]})
	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)))
	var cable_curve := String((persist.shape_state(cable)["params"] as Dictionary).get("curve", ""))
	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:" + k2, "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()
	_ok("setup: the room has A, B, K, the cable, the brush object, the object",
		(persist.live_shapes() as Dictionary).has(a) and (persist.live_shapes() as Dictionary).has(cable)
		and cable_curve != "" and obj != "" and not (map.level.get_group_brush_ids(grp) as PackedInt32Array).is_empty(),
		[a, cable, cable_curve, obj])

	var w1: Dictionary = AssetDoc.write(persist, kit)
	var doc: Dictionary = w1["doc"]
	var bytes := AssetDoc.encode(doc)
	_ok("setup: a doc was written", not doc.is_empty() and bytes.size() > 0, bytes.size())
	if doc.is_empty():
		return
	var recs: Array = doc["records"]

	# ---- D1 / D2 ----
	var back := AssetDoc.decode(bytes)
	_ok("D1 encode -> decode -> encode is byte-identical", not back.is_empty() and AssetDoc.encode(back) == bytes)
	# D1b: another peer may build the same Dictionaries in another key order.
	_ok("D1b the same doc with every key inserted in reverse order: the same bytes",
		AssetDoc.encode(_reversed(doc)) == bytes)
	var oid := AssetDoc.hash_of(bytes)
	_ok("D2 a second write of the same room: the same oid",
		AssetDoc.hash_of(AssetDoc.encode(AssetDoc.write(persist, kit)["doc"])) == oid, oid.substr(0, 12))

	# ---- D3 / D4 ----
	var strs: Array = []
	_strings(recs, strs)
	var leaks := strs.filter(func(s: String) -> bool: return s.contains("probe"))
	_ok("D3 no room dot or group survives in the records", leaks.is_empty(), leaks)
	# Defined: every record's dot, and every brush object's group (a name its brush.create
	# records share — not a record of its own).
	var defined := {}
	for r: Dictionary in recs:
		defined[String(r["dot"])] = true
		if String(r["kind"]) == "brush.create":
			defined[String(r["group"])] = true
	var refs: Array = []
	for r2: Dictionary in recs:
		for f: String in ["target", "cutter", "link"]:
			if r2.has(f) and String(r2[f]) != "":
				refs.append(String(r2[f]))
		if r2.has("parent") and String(r2["parent"]) != "":
			refs.append(String(r2["parent"]))
		for t: Variant in (r2.get("targets", []) as Array):
			refs.append(String(t))
		for m: Variant in (r2.get("members", []) as Array):
			refs.append(String(m).substr(String(m).find(":") + 1))
		var p: Dictionary = r2.get("params", {})
		for pk: String in ["curve", "profile_curve"]:
			if String(p.get(pk, "")) != "":
				refs.append(String(p[pk]))
	var dangling := refs.filter(func(x: String) -> bool: return not defined.has(x))
	_ok("D4 every reference names a local dot the doc defines (%d refs)" % refs.size(),
		refs.size() >= 5 and dangling.is_empty(), [refs, dangling])

	# ---- D5 ----
	var by_kind := {}
	for r3: Dictionary in recs:
		(by_kind.get_or_add(String(r3["kind"]), []) as Array).append(r3)
	var shapes: Array = by_kind.get("shape.create", [])
	_ok("D5 shapes: A, B, K and the cable (4); K2 left out", shapes.size() == 4,
		shapes.map(func(r4: Dictionary) -> String: return String(r4["type"])))
	_ok("D5 the cable's curve is in it", (by_kind.get("curve.create", []) as Array).size() == 1)
	var mods: Array = by_kind.get("mod.create", [])
	_ok("D5 the mirror is in it", mods.size() == 1 and String(mods[0]["mod"]) == "mirror")
	var ops: Array = by_kind.get("op.create", [])
	_ok("D5 ONE cut, A by K, with only A as its target", ops.size() == 1 and (ops[0]["targets"] as Array).size() == 1
		and String(ops[0]["targets"][0]) == String(mods[0]["target"]) if not mods.is_empty() and ops.size() == 1 else false, ops)
	var bc: Array = by_kind.get("brush.create", [])
	var bgroups := {}
	for r5: Dictionary in bc:
		bgroups[String(r5["group"])] = true
	_ok("D5 the brush object: 2 brushes, one local group", bc.size() == 2 and bgroups.size() == 1, bgroups.keys())
	_ok("D5 the object is in it", (by_kind.get("object.create", []) as Array).size() == 1)
	var cols: Array = by_kind.get("col.create", [])
	_ok("D5 the sub-collection with B (its one member)", cols.size() == 1 and String(cols[0]["name"]) == "Sub"
		and (cols[0]["members"] as Array).size() == 1 if cols.size() == 1 else false, cols)

	# ---- D6 ----
	var rep: Array = w1["report"]
	_ok("D6 K2 (cuts only outside) is reported left out", rep.any(func(s: String) -> bool: return s.contains(k2) and s.contains("left out")), rep)
	_ok("D6 K (from Cutters) is reported brought in", rep.any(func(s: String) -> bool: return s.contains(k) and s.contains("brought in")), rep)

	# ---- D7 ----
	var ids := PackedInt32Array()
	for g: String in [a, b, k, cable, grp]:
		ids.append_array(map.level.get_group_brush_ids(g) as PackedInt32Array)
	var bb: Dictionary = map.level.get_brushes_bounds(ids)
	var lo2: Vector3 = (bb["aabb_min"] as Vector3).min(SceneObjects.xform_from(by_kind["object.create"][0]["xform"]).origin)
	var hi2: Vector3 = (bb["aabb_max"] as Vector3).max(SceneObjects.xform_from(by_kind["object.create"][0]["xform"]).origin)
	var pv: Vector3 = doc["pivot"]
	_ok("D7 the bounds = every brush the contents fold to + the object", (doc["bounds"][0] as Vector3).is_equal_approx(lo2)
		and (doc["bounds"][1] as Vector3).is_equal_approx(hi2), [doc["bounds"], lo2, hi2])
	_ok("D7 the pivot = the bounds' base centre", pv.is_equal_approx(Vector3((lo2.x + hi2.x) * 0.5, lo2.y, (lo2.z + hi2.z) * 0.5)), pv)

	# ---- D8 ----
	var with_obj := doc.duplicate(true)
	with_obj["evil"] = Resource.new()
	_ok("D8 refuses a blob carrying an Object", AssetDoc.decode(var_to_bytes_with_objects(with_obj)).is_empty())
	var bad_kind := doc.duplicate(true)
	(bad_kind["records"] as Array).append({"kind": "brush.remove", "dot": "L:999", "brush_key": "x"})
	_ok("D8 refuses a record of another kind", AssetDoc.decode(AssetDoc.encode(bad_kind)).is_empty())
	var bad_dot := doc.duplicate(true)
	(bad_dot["records"][0] as Dictionary)["dot"] = "probe:1"
	_ok("D8 refuses a non-local dot", AssetDoc.decode(AssetDoc.encode(bad_dot)).is_empty())
	var bad_v := doc.duplicate(true)
	bad_v["v"] = 99
	_ok("D8 refuses another version", AssetDoc.decode(AssetDoc.encode(bad_v)).is_empty())
	_ok("D8 refuses garbage", AssetDoc.decode(PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8])).is_empty())

	# ---- D9 ----
	persist.begin_gesture("move A")
	persist.record("shape.xform", {"target": a, "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 1)))})
	persist.end_gesture()
	persist.refold()
	_ok("D9 A moved -> a different oid", AssetDoc.hash_of(AssetDoc.encode(AssetDoc.write(persist, kit)["doc"])) != oid)


## `v` with every Dictionary's keys inserted in reverse order.
func _reversed(v: Variant) -> Variant:
	if v is Dictionary:
		var keys: Array = (v as Dictionary).keys()
		keys.reverse()
		var out := {}
		for k: Variant in keys:
			out[k] = _reversed((v as Dictionary)[k])
		return out
	if v is Array:
		return (v as Array).map(func(x: Variant) -> Variant: return _reversed(x))
	return v