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", "")])