OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 7bb232c

#120 L4 §15 step 1: AssetDoc — a collection's contents as an asset blob
write(persist, col): the subtree's shapes (resolved), modifiers, live cuts (carried with a shape target inside, cutter brought in from outside, brush-object targets baked), curves they name, brush objects (current planes), objects (+ props, parent), sub-collections — local dots L:<n>, world as captured + the base-centre pivot, model deps. encode = var_to_bytes with sorted keys; decode trusts nothing (no Objects, kinds, local dots, version). SHA-256 oid. gate_asset_doc 24/24; 5 mutants killed. Design §15.1 updated to what was built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7bb232c9cbaa197aecd84cf53aa07b26a8dd40cb
by plankatron <fleeky@gmail.com> · 2026-10-19T20:46:51.000Z

Changed files (4) +614 -10

docs/node-tree-and-stacks.md +26 -10
……@@ -1143,18 +1143,31 @@ An asset is ONE immutable blob, `.hasset`, named by its content hash (the oid):
11431143```
11441144
11451145- `records` are the payloads `flatten_map` would write for the collection's subtree:
1146 `shape.create` (merged params, frame, size, tex, input, `link` re-rooted inside the
1147 asset), `mod.create`, live `op.create` (cutter and targets both inside — an op whose
1148 cutter or target is OUTSIDE the collection is dropped and reported), `curve.create`,
1146 `shape.create` (merged params, frame, size, tex, input, `link` kept when its root is
1147 inside, else dropped — the resolved data is written whole), `mod.create` (+ `on`), live
1148 `op.create` (+ `on`), `curve.create`,
11491149 `brush.create` for brush objects (current planes, group kept), `object.create` +
11501150 props + parent (parents outside → none), `col.create` + `col.link` for
11511151 sub-collections, and `asset.place` for placements inside it (nesting, §15.2).
11521152- LOCAL dots: `L:<n>`, numbered in fold order. Every reference (link, target, cutter,
11531153 curve, parent, member) is remapped to them. Nothing in the blob names the room.
1154- Positions are relative to the PIVOT, the collection's bounds' base centre (so a pillar
1155 sits ON the surface it is dropped on). Every frame / point / plane is moved by −pivot.
1156- CANONICAL bytes: keys sorted, floats written exactly (var_to_str of each value), so the
1157 same contents give the same oid on every peer and on every save.
1154- **Cuts (built 2026-10-19):** an op comes along when one of its TARGETS is a shape in
1155 the asset, narrowed to those targets, and its CUTTER comes too even from outside (Box
1156 Cutter files cutters in their own "Cutters" collection — "both inside" would have
1157 dropped most cuts). Targets that are brush objects are dropped from it: a brush
1158 object's planes are its CURRENT ones, already cut. A shape inside that only cuts
1159 outside is left out (it would fold as a solid). Every one of these is REPORTED.
1160- **Positions as captured, in world space, plus the PIVOT** (the contents' bounds' base
1161 centre, so a pillar sits ON the surface it is dropped on). A placement folds to
1162 `frame × translate(−pivot) × captured` (§15.2). Not shifted at save: a brush's texture
1163 follows it only through the store's texture lock (`brush.translate / rotate` with
1164 tex_lock, `dc_face_tex_lock_*` in C++), so the expansion lets the store do that rather
1165 than a second copy of the math in GDScript. Cost: the same contents saved elsewhere
1166 hash differently (a new version, harmless).
1167- **Bytes:** `var_to_bytes` with every Dictionary's keys sorted (Binary Variant keeps ints
1168 ints and floats exact; JSON does neither). `bytes_to_var` refuses Objects by default,
1169 and decode checks the shape, the kinds and that every dot is local: a blob comes from
1170 another peer (`str_to_var` would construct Objects — never used for blobs).
11581171- A nested asset is named by oid; a blob cannot contain its own oid (the hash depends
11591172 on the contents), so cycles are impossible by construction. Depth is still capped (8).
11601173
……@@ -1272,9 +1285,12 @@ Save as asset is menu-only for now).
12721285
12731286### 15.8 Steps (each gated, one commit each)
12741287
12751. **AssetDoc** (pure): write a collection subtree to a blob and read it back.
1276 `gate_asset_doc`: round trip byte-identical; the same contents → the same oid on two
1277 saves; an outside op is dropped and reported; local dots remapped everywhere.
12881. **AssetDoc**: write a collection subtree to a blob and read it back.
1289 `gate_asset_doc` (24): round trip byte-identical, key order irrelevant; the same room →
1290 the same oid, a moved shape → another; the cutter from "Cutters" comes, the outside-only
1291 cutter and its cut are left out and reported; no room dot survives, every reference
1292 local; bounds / pivot; decode refuses Objects, other kinds, room dots, versions,
1293 garbage. 5 mutants killed. **DONE 2026-10-19.**
127812942. **AssetFold**: `asset.*` records, the expansion, nesting, the missing-blob box.
12791295 `gate_asset_fold`: a collection saved and placed at its own pivot folds to the SAME
12801296 geometry digest as the original (the oracle is the room's own fold — independent);
scenes/maps/asset_doc.gd +342 -0
……@@ -0,0 +1,342 @@
1extends RefCounted
2## THE ASSET DOCUMENT (#120 L4, docs/node-tree-and-stacks.md §15.1): a collection's
3## contents as ONE immutable blob, named by its hash. Written from the room's folded state,
4## read back without trusting it.
5##
6## {v, name, pivot: Vector3, bounds: [min, max], (world, as captured)
7## records: [{kind, dot: "L:<n>", …payload}], ordinary payloads, LOCAL dots
8## assets: [oid…]} blobs it needs: models (nested later)
9##
10## AS CAPTURED, IN WORLD SPACE, plus the PIVOT (the contents' base centre). A placement
11## folds to frame x translate(-pivot) x captured (§15.2). Not shifted here: a brush's
12## texture moves with it only through the store's texture lock (brush.translate / rotate
13## with tex_lock — dc_face_tex_lock_*), so the expansion lets the store do it rather than
14## a second copy of that math in GDScript. Cost: the same contents saved somewhere else
15## hash differently.
16##
17## WHAT IS IN IT — the collection's subtree (members_of, recursive):
18## "g:<shape dot>" the shape's RESOLVED state (shape.create), its modifiers
19## (mod.create + on), and every curve it or a modifier names
20## "g:<group>" a brush object: each live brush as brush.create (its CURRENT planes,
21## so cuts already made on it are baked in)
22## "obj:<dot>" object.create {type, xform (scene space), props, parent}; a parent
23## outside the asset -> top level, keeping its scene-space xform
24## sub-collections col.create {name, parent, members}
25## live cuts an op whose TARGET is a shape in the asset comes along, narrowed to
26## its targets inside, and its CUTTER comes too even from outside (Box
27## Cutter keeps cutters in their own "Cutters" collection). Targets that
28## are brush objects are dropped from it: their planes are already cut.
29## Left out, and REPORTED: an op with no shape target inside; a shape inside that only cuts
30## outside (it would fold as a solid); a linked duplicate's root outside (the link is
31## dropped — its resolved data is written whole).
32##
33## CANONICAL BYTES: var_to_bytes of the doc with every Dictionary's keys sorted. Binary
34## Variant keeps ints as ints and floats exact (JSON does neither), and bytes_to_var
35## refuses Objects by default — a blob comes from another peer.
36
37const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")
38const ModFold := preload("res://scenes/maps/mod_fold.gd")
39const OpFold := preload("res://scenes/maps/op_fold.gd")
40const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
41
42const V := 1
43const EXT := "hasset"
44## Record kinds a blob may hold (decode refuses anything else).
45const KINDS := ["curve.create", "shape.create", "mod.create", "op.create", "brush.create",
46 "object.create", "col.create"]
47
48
49## Write the subtree of collection `col` from the room `persist` folds (LevelPersistence,
50## with its level and objects). {doc, report: [String]} — doc {} when there is nothing.
51static func write(persist: Node, col: String, name: String = "") -> Dictionary:
52 var report: Array = []
53 var t: Dictionary = persist.collection_tree()
54 if not (t["cols"] as Dictionary).has(col):
55 return {"doc": {}, "report": ["no such collection"]}
56 var level: Node = persist.get("level")
57 var objects: Node = persist.get("objects")
58 var recs: Array = persist.records(false)
59 var curves: Dictionary = persist.curve_states()
60 var shape_recs: Array = recs.filter(func(r: Dictionary) -> bool: return ShapeFold.handles(String(r.get("kind", ""))))
61 var states: Dictionary = ShapeFold.resolve_states(shape_recs, curves)
62 var live_shape := func(d: String) -> bool:
63 return states.has(d) and not bool(states[d]["detached"]) and not bool(states[d].get("removed", false))
64
65 # ---- what the collection holds ----
66 var shapes := {} # shape dot -> true
67 var groups := {} # brush-object group -> true
68 var objs := {} # object dot -> true
69 for m: String in CollectionFold.members_of(t, col, true):
70 if m.begins_with("g:"):
71 var g := m.substr(2)
72 if live_shape.call(g):
73 shapes[g] = true
74 elif level != null and not (level.get_group_brush_ids(g) as PackedInt32Array).is_empty():
75 groups[g] = true
76 elif m.begins_with("obj:") and objects != null and objects.node_for(m.substr(4)) != null:
77 objs[m.substr(4)] = true
78
79 # ---- live cuts: an op comes with a shape target inside, and brings its cutter ----
80 var ops: Array = []
81 var cutter_of_kept := {}
82 var cutter_any := {}
83 for s: Dictionary in OpFold.existing_ops(recs):
84 cutter_any[String(s["cutter"])] = true
85 var inside: Array = []
86 var baked := 0
87 for g2: String in (s["targets"] as PackedStringArray):
88 if shapes.has(g2):
89 inside.append(g2)
90 elif groups.has(g2):
91 baked += 1
92 if baked > 0:
93 report.append("cut %s: its brush-object targets are baked (their planes are already cut)" % s["dot"])
94 if inside.is_empty():
95 if baked == 0 and shapes.has(String(s["cutter"])):
96 report.append("cut %s: it cuts only outside the collection — left out" % s["dot"])
97 continue
98 if not live_shape.call(String(s["cutter"])):
99 report.append("cut %s: its cutter is gone — left out" % s["dot"])
100 continue
101 var s2 := s.duplicate()
102 s2["targets"] = inside
103 ops.append(s2)
104 cutter_of_kept[String(s["cutter"])] = true
105 if not shapes.has(String(s["cutter"])):
106 shapes[String(s["cutter"])] = true
107 report.append("cutter %s: brought in from outside the collection" % s["cutter"])
108 for d: String in shapes.keys():
109 if cutter_any.has(d) and not cutter_of_kept.has(d):
110 shapes.erase(d)
111 report.append("shape %s: it only cuts outside the collection — left out" % d)
112
113 # ---- curves the shapes and their modifiers name ----
114 var mods: Array = []
115 for m2: Dictionary in ModFold.resolve_states(recs).values():
116 if not bool(m2["removed"]) and shapes.has(String(m2["target"])):
117 mods.append(m2)
118 mods.sort_custom(_before)
119 var used_curves := {}
120 for d2: String in shapes:
121 var sp: Dictionary = states[d2]["params"]
122 for ck: String in ["curve", "profile_curve"]:
123 if curves.has(String(sp.get(ck, ""))):
124 used_curves[String(sp[ck])] = true
125 for m3: Dictionary in mods:
126 if curves.has(String((m3["params"] as Dictionary).get("curve", ""))):
127 used_curves[String(m3["params"]["curve"])] = true
128
129 # ---- LOCAL DOTS, in a fixed order: curves, shapes, modifiers, cuts, brush objects
130 # (their brushes), objects, collections — each by its room order ----
131 var local := {} # room dot / group -> "L:<n>"
132 var n := [0]
133 var mint := func(k: String) -> String:
134 n[0] += 1
135 local[k] = "L:%d" % n[0]
136 return local[k]
137 var out: Array = []
138 var cdots: Array = used_curves.keys()
139 cdots.sort_custom(func(a: String, b: String) -> bool: return _dot_before(curves[a], curves[b]))
140 for cd: String in cdots:
141 var cs: Dictionary = curves[cd]
142 out.append({"kind": "curve.create", "dot": mint.call(cd),
143 "points": LevelPersistence.CurveFold.to_wire(cs["points"]), "closed": bool(cs["closed"]),
144 "name": String(cs.get("name", ""))})
145 var sdots: Array = shapes.keys()
146 sdots.sort_custom(func(a: String, b: String) -> bool: return _dot_before(states[a], states[b]))
147 for sd: String in sdots:
148 mint.call(sd)
149 for sd2: String in sdots:
150 var s3: Dictionary = states[sd2]
151 var ps: Dictionary = (s3["params"] as Dictionary).duplicate(true)
152 for ck2: String in ["curve", "profile_curve"]:
153 if ps.has(ck2):
154 ps[ck2] = String(local.get(String(ps[ck2]), ""))
155 var sz: Vector3 = s3["size"]
156 var rec := {"kind": "shape.create", "dot": local[sd2], "type": String(s3["type"]),
157 "gv": int(s3["gv"]), "params": ps, "frame": SceneObjects.xform_to(s3["frame"]),
158 "size": [sz.x, sz.y, sz.z], "material_id": int(s3["material_id"]),
159 "level_id": int(s3["level_id"]), "tex": (s3.get("tex", {}) as Dictionary).duplicate(true),
160 "input": (s3.get("input", {}) as Dictionary).duplicate(true)}
161 var root := String(s3.get("link", ""))
162 if root != "":
163 if local.has(root):
164 rec["link"] = local[root]
165 else:
166 report.append("shape %s: its linked-duplicate root is outside — written unlinked" % sd2)
167 out.append(rec)
168 for m4: Dictionary in mods:
169 var mp: Dictionary = (m4["params"] as Dictionary).duplicate(true)
170 if mp.has("curve"):
171 mp["curve"] = String(local.get(String(mp["curve"]), ""))
172 out.append({"kind": "mod.create", "dot": mint.call(String(m4["dot"])),
173 "target": local[String(m4["target"])], "mod": String(m4["kind"]), "params": mp,
174 "on": bool(m4["on"])})
175 for s4: Dictionary in ops:
176 var tg: Array = []
177 for g3: String in s4["targets"]:
178 tg.append(local[g3])
179 out.append({"kind": "op.create", "dot": mint.call(String(s4["dot"])), "op": String(s4["kind"]),
180 "cutter": local[String(s4["cutter"])], "targets": tg, "depth": float(s4["depth"]),
181 "on": bool(s4["on"])})
182 var gk: Array = groups.keys()
183 gk.sort()
184 var brush_ids := PackedInt32Array()
185 for g4: String in gk:
186 var lg: String = mint.call("g:" + g4)
187 var ids: Array = Array(level.get_group_brush_ids(g4) as PackedInt32Array)
188 ids.sort_custom(func(a: int, b: int) -> bool: return String(level.get_brush_key(a)) < String(level.get_brush_key(b)))
189 for b: int in ids:
190 brush_ids.append(b)
191 var p: Dictionary = persist.call("_brush_payload", level.get_brush(b))
192 p["group"] = lg
193 p["kind"] = "brush.create"
194 p["dot"] = mint.call("b:" + String(level.get_brush_key(b)))
195 out.append(p)
196 var odots: Array = []
197 if objects != null:
198 for e: Dictionary in (objects.entries() as Array): # fold order
199 if objs.has(String(e["dot"])):
200 odots.append(String(e["dot"]))
201 for od: String in odots:
202 mint.call("obj:" + od)
203 var model_oids := {}
204 for od2: String in odots:
205 var node := objects.node_for(od2) as Node3D
206 var pd := String(objects.parent_of(od2))
207 var props := {}
208 var base: Dictionary = (objects.get("_base") as Dictionary).get(od2, {})
209 for k: String in (base.get("props", {}) as Dictionary):
210 props[k] = base["props"][k]
211 var set_here: Dictionary = (objects.get("_props") as Dictionary).get(od2, {})
212 for k2: String in set_here:
213 props[k2] = set_here[k2]
214 if String(props.get("asset", "")) != "":
215 model_oids[String(props["asset"])] = true
216 var rec2 := {"kind": "object.create", "dot": local["obj:" + od2],
217 "type": String(node.get_meta("object_type", "")), "props": props.duplicate(true)}
218 if pd != "" and local.has("obj:" + pd):
219 rec2["parent"] = local["obj:" + pd]
220 rec2["xform"] = SceneObjects.xform_to(node.transform)
221 else:
222 if pd != "":
223 report.append("object %s: its parent is outside — placed at the top level" % od2)
224 rec2["xform"] = SceneObjects.xform_to(objects.scene_xform(node))
225 out.append(rec2)
226 # Sub-collections (the root itself is the asset): parents first, members remapped.
227 var cols: Dictionary = t["cols"]
228 var corder: Array = []
229 var stack: Array = [col]
230 while not stack.is_empty():
231 var c: String = stack.pop_front()
232 if c != col:
233 corder.append(c)
234 var kids: Array = (cols[c]["children"] as Array).duplicate()
235 kids.sort()
236 stack.append_array(kids)
237 for c2: String in corder:
238 mint.call("col:" + c2)
239 for c3: String in corder:
240 var ms: Array = []
241 for m5: String in cols[c3]["members"]:
242 var key := m5.substr(2) if m5.begins_with("g:") else m5
243 if m5.begins_with("g:") and groups.has(key):
244 key = "g:" + key
245 if local.has(key):
246 ms.append(("g:" if m5.begins_with("g:") else "obj:") + String(local[key]))
247 var par := String(cols[c3]["parent"])
248 out.append({"kind": "col.create", "dot": local["col:" + c3], "name": String(cols[c3]["name"]),
249 "parent": String(local.get("col:" + par, "")), "members": ms})
250 # Members of the ROOT collection are the asset's top level: no record needed.
251
252 if out.is_empty():
253 return {"doc": {}, "report": report + ["the collection holds nothing an asset can carry"]}
254
255 # ---- bounds and pivot: every brush the contents fold to, every object's origin ----
256 var lo := Vector3.INF
257 var hi := -Vector3.INF
258 var any := false
259 for sd3: String in sdots:
260 brush_ids.append_array(level.get_group_brush_ids(sd3) as PackedInt32Array)
261 var bb: Dictionary = level.get_brushes_bounds(brush_ids)
262 if int(bb["count"]) > 0:
263 lo = bb["aabb_min"]
264 hi = bb["aabb_max"]
265 any = true
266 for od3: String in odots:
267 var o: Vector3 = objects.scene_xform(objects.node_for(od3) as Node3D).origin
268 lo = lo.min(o)
269 hi = hi.max(o)
270 any = true
271 if not any:
272 lo = Vector3.ZERO
273 hi = Vector3.ZERO
274 var deps: Array = model_oids.keys()
275 deps.sort()
276 var doc := {"v": V, "name": name if name != "" else String(cols[col]["name"]),
277 "pivot": Vector3((lo.x + hi.x) * 0.5, lo.y, (lo.z + hi.z) * 0.5),
278 "bounds": [lo, hi], "records": out, "assets": deps}
279 return {"doc": doc, "report": report}
280
281
282static func _before(a: Dictionary, b: Dictionary) -> bool:
283 return _dot_before(a, b)
284
285
286## Room order: (seq, author) — the fold's order.
287static func _dot_before(a: Dictionary, b: Dictionary) -> bool:
288 if int(a.get("seq", 0)) != int(b.get("seq", 0)):
289 return int(a.get("seq", 0)) < int(b.get("seq", 0))
290 return String(a.get("author", a.get("dot", ""))) < String(b.get("author", b.get("dot", "")))
291
292
293# ---- bytes -------------------------------------------------------------------
294
295## The blob: var_to_bytes of `doc` with every Dictionary's keys sorted.
296static func encode(doc: Dictionary) -> PackedByteArray:
297 return var_to_bytes(_canon(doc))
298
299
300static func _canon(v: Variant) -> Variant:
301 if v is Dictionary:
302 var keys: Array = (v as Dictionary).keys()
303 keys.sort_custom(func(a: Variant, b: Variant) -> bool: return str(a) < str(b))
304 var out := {}
305 for k: Variant in keys:
306 out[k] = _canon((v as Dictionary)[k])
307 return out
308 if v is Array:
309 var out2: Array = []
310 for x: Variant in (v as Array):
311 out2.append(_canon(x))
312 return out2
313 return v
314
315
316## The blob's name: SHA-256, hex.
317static func hash_of(bytes: PackedByteArray) -> String:
318 var h := HashingContext.new()
319 h.start(HashingContext.HASH_SHA256)
320 h.update(bytes)
321 return h.finish().hex_encode()
322
323
324## Read a blob — FROM ANOTHER PEER, so nothing is trusted: no Objects (bytes_to_var's
325## default), the shape checked, every record's kind one of KINDS and every dot local.
326## {} when any of it fails.
327static func decode(bytes: PackedByteArray) -> Dictionary:
328 if bytes.is_empty():
329 return {}
330 var v: Variant = bytes_to_var(bytes)
331 if not (v is Dictionary):
332 return {}
333 var d: Dictionary = v
334 if int(d.get("v", 0)) != V or not (d.get("records") is Array) or not (d.get("pivot") is Vector3) \
335 or not (d.get("bounds") is Array) or (d["bounds"] as Array).size() != 2 \
336 or not (d.get("assets", []) is Array) or not (d.get("name", "") is String):
337 return {}
338 for r: Variant in (d["records"] as Array):
339 if not (r is Dictionary) or not KINDS.has(String((r as Dictionary).get("kind", ""))) \
340 or not String((r as Dictionary).get("dot", "")).begins_with("L:"):
341 return {}
342 return d
scenes/test/gate_asset_doc.gd +240 -0
……@@ -0,0 +1,240 @@
1extends Node
2## GATE — THE ASSET DOCUMENT (#120 L4 step 1, docs/node-tree-and-stacks.md §15.1, asset_doc.gd).
3## A room built through the map's own paths, one collection "Kit" holding: a box A with a
4## mirror, cut by K (a cutter in its own "Cutters" collection, as Box Cutter files it); a
5## cutter K2 that only cuts a box O OUTSIDE Kit; a cable on its curve; a plain brush object;
6## a scene object; a sub-collection "Sub" with a box B.
7## D1 encode -> decode -> encode: byte-identical; D1b key order does not matter
8## D2 two writes of the same room: the same oid
9## D3 nothing in the records names the room (no "probe" dot or group survives)
10## D4 every reference (target, cutter, targets, link, curve, profile, parent, members)
11## resolves to a local dot the doc defines (a record, or a brush object's group)
12## D5 contents: A, B, K (brought in), the cable shape; its curve; the mirror; ONE cut
13## (A by K) with only A as target; the plain brushes under one local group; the
14## object; the sub-collection with B
15## D6 K2 and its cut are left out and REPORTED; K's arrival is reported
16## D7 pivot = the bounds' base centre; the bounds hold every brush the contents fold to
17## D8 decode refuses: a blob with an Object, a record of another kind, a non-local dot,
18## a wrong version, garbage
19## D9 the room changes (A moved) -> a different oid (D2 is not vacuous)
20##
21## 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
22
23const AssetDoc := preload("res://scenes/maps/asset_doc.gd")
24
25var fails := 0
26var checks := 0
27var map: Node3D
28var persist: Node
29
30
31func _ok(what: String, cond: bool, note: Variant = "") -> void:
32 checks += 1
33 if not cond:
34 fails += 1
35 print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
36
37
38func _ready() -> void:
39 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
40 gs.route("world:level:*", 1, "peer")
41 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
42 if keet != null:
43 keet.current_member_id = "probe"
44 get_tree().root.get_node("/root/GameMode").set_mode("build")
45 map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
46 add_child(map)
47 for i in 6:
48 await get_tree().process_frame
49 persist = map.get("_persist")
50 _run()
51 if checks < 20:
52 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
53 fails += 1
54 print("\nGATE asset_doc: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
55 get_tree().quit(0 if fails == 0 else 1)
56
57
58func _box(at: Vector3, size: Vector3) -> String:
59 return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
60 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z],
61 "material_id": 2, "level_id": 0})
62
63
64static func _box_planes(lo: Vector3, hi: Vector3) -> Array:
65 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]
66
67
68## Every String anywhere in `v`.
69func _strings(v: Variant, out: Array) -> void:
70 if v is String or v is StringName:
71 out.append(String(v))
72 elif v is Dictionary:
73 for k: Variant in (v as Dictionary):
74 _strings(k, out)
75 _strings((v as Dictionary)[k], out)
76 elif v is Array:
77 for x: Variant in (v as Array):
78 _strings(x, out)
79
80
81func _run() -> void:
82 var O0 := Vector3(500, 0, 500)
83 persist.begin_gesture("asset room")
84 var a := _box(O0, Vector3(2, 2, 2))
85 var b := _box(O0 + Vector3(6, 0, 0), Vector3(1, 3, 1))
86 var k := _box(O0 + Vector3(0.5, 0.5, 0), Vector3(1, 1, 3))
87 var o := _box(O0 + Vector3(-20, 0, 0), Vector3(2, 2, 2))
88 var k2 := _box(O0 + Vector3(-20, 0.5, 0), Vector3(1, 1, 3))
89 persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
90 persist.record("op.create", {"op": "subtract", "cutter": k, "targets": [a]})
91 persist.record("op.create", {"op": "subtract", "cutter": k2, "targets": [o]})
92 var grp := "probe:grp1"
93 for i in 2:
94 var lo := O0 + Vector3(0, 0, 6 + i * 2)
95 persist.record("brush.create", {"planes": _box_planes(lo, lo + Vector3(1, 1, 1)), "proj": [],
96 "material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": grp, "face_tex": []})
97 persist.end_gesture()
98 persist.refold()
99 var cable: String = map.add_cable([CurveMath.point(O0 + Vector3(0, 1, -4)), CurveMath.point(O0 + Vector3(6, 1, -4))],
100 false, {"radius": 0.05})
101 var obj: String = map.create_object(PrefabCatalogue.ids()[0], Transform3D(Basis.IDENTITY, O0 + Vector3(3, 0, 3)))
102 var cable_curve := String((persist.shape_state(cable)["params"] as Dictionary).get("curve", ""))
103 persist.begin_gesture("asset collections")
104 var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""})
105 var sub: String = persist.record("col.create", {"name": "Sub", "parent": kit})
106 var cuts: String = persist.record("col.create", {"name": "Cutters", "parent": ""})
107 persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + k2, "g:" + cable, "g:" + grp, "obj:" + obj]})
108 persist.record("col.link", {"target": sub, "members": ["g:" + b]})
109 persist.record("col.link", {"target": cuts, "members": ["g:" + k]})
110 persist.end_gesture()
111 persist.refold()
112 _ok("setup: the room has A, B, K, the cable, the brush object, the object",
113 (persist.live_shapes() as Dictionary).has(a) and (persist.live_shapes() as Dictionary).has(cable)
114 and cable_curve != "" and obj != "" and not (map.level.get_group_brush_ids(grp) as PackedInt32Array).is_empty(),
115 [a, cable, cable_curve, obj])
116
117 var w1: Dictionary = AssetDoc.write(persist, kit)
118 var doc: Dictionary = w1["doc"]
119 var bytes := AssetDoc.encode(doc)
120 _ok("setup: a doc was written", not doc.is_empty() and bytes.size() > 0, bytes.size())
121 if doc.is_empty():
122 return
123 var recs: Array = doc["records"]
124
125 # ---- D1 / D2 ----
126 var back := AssetDoc.decode(bytes)
127 _ok("D1 encode -> decode -> encode is byte-identical", not back.is_empty() and AssetDoc.encode(back) == bytes)
128 # D1b: another peer may build the same Dictionaries in another key order.
129 _ok("D1b the same doc with every key inserted in reverse order: the same bytes",
130 AssetDoc.encode(_reversed(doc)) == bytes)
131 var oid := AssetDoc.hash_of(bytes)
132 _ok("D2 a second write of the same room: the same oid",
133 AssetDoc.hash_of(AssetDoc.encode(AssetDoc.write(persist, kit)["doc"])) == oid, oid.substr(0, 12))
134
135 # ---- D3 / D4 ----
136 var strs: Array = []
137 _strings(recs, strs)
138 var leaks := strs.filter(func(s: String) -> bool: return s.contains("probe"))
139 _ok("D3 no room dot or group survives in the records", leaks.is_empty(), leaks)
140 # Defined: every record's dot, and every brush object's group (a name its brush.create
141 # records share — not a record of its own).
142 var defined := {}
143 for r: Dictionary in recs:
144 defined[String(r["dot"])] = true
145 if String(r["kind"]) == "brush.create":
146 defined[String(r["group"])] = true
147 var refs: Array = []
148 for r2: Dictionary in recs:
149 for f: String in ["target", "cutter", "link"]:
150 if r2.has(f) and String(r2[f]) != "":
151 refs.append(String(r2[f]))
152 if r2.has("parent") and String(r2["parent"]) != "":
153 refs.append(String(r2["parent"]))
154 for t: Variant in (r2.get("targets", []) as Array):
155 refs.append(String(t))
156 for m: Variant in (r2.get("members", []) as Array):
157 refs.append(String(m).substr(String(m).find(":") + 1))
158 var p: Dictionary = r2.get("params", {})
159 for pk: String in ["curve", "profile_curve"]:
160 if String(p.get(pk, "")) != "":
161 refs.append(String(p[pk]))
162 var dangling := refs.filter(func(x: String) -> bool: return not defined.has(x))
163 _ok("D4 every reference names a local dot the doc defines (%d refs)" % refs.size(),
164 refs.size() >= 5 and dangling.is_empty(), [refs, dangling])
165
166 # ---- D5 ----
167 var by_kind := {}
168 for r3: Dictionary in recs:
169 (by_kind.get_or_add(String(r3["kind"]), []) as Array).append(r3)
170 var shapes: Array = by_kind.get("shape.create", [])
171 _ok("D5 shapes: A, B, K and the cable (4); K2 left out", shapes.size() == 4,
172 shapes.map(func(r4: Dictionary) -> String: return String(r4["type"])))
173 _ok("D5 the cable's curve is in it", (by_kind.get("curve.create", []) as Array).size() == 1)
174 var mods: Array = by_kind.get("mod.create", [])
175 _ok("D5 the mirror is in it", mods.size() == 1 and String(mods[0]["mod"]) == "mirror")
176 var ops: Array = by_kind.get("op.create", [])
177 _ok("D5 ONE cut, A by K, with only A as its target", ops.size() == 1 and (ops[0]["targets"] as Array).size() == 1
178 and String(ops[0]["targets"][0]) == String(mods[0]["target"]) if not mods.is_empty() and ops.size() == 1 else false, ops)
179 var bc: Array = by_kind.get("brush.create", [])
180 var bgroups := {}
181 for r5: Dictionary in bc:
182 bgroups[String(r5["group"])] = true
183 _ok("D5 the brush object: 2 brushes, one local group", bc.size() == 2 and bgroups.size() == 1, bgroups.keys())
184 _ok("D5 the object is in it", (by_kind.get("object.create", []) as Array).size() == 1)
185 var cols: Array = by_kind.get("col.create", [])
186 _ok("D5 the sub-collection with B (its one member)", cols.size() == 1 and String(cols[0]["name"]) == "Sub"
187 and (cols[0]["members"] as Array).size() == 1 if cols.size() == 1 else false, cols)
188
189 # ---- D6 ----
190 var rep: Array = w1["report"]
191 _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)
192 _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)
193
194 # ---- D7 ----
195 var ids := PackedInt32Array()
196 for g: String in [a, b, k, cable, grp]:
197 ids.append_array(map.level.get_group_brush_ids(g) as PackedInt32Array)
198 var bb: Dictionary = map.level.get_brushes_bounds(ids)
199 var lo2: Vector3 = (bb["aabb_min"] as Vector3).min(SceneObjects.xform_from(by_kind["object.create"][0]["xform"]).origin)
200 var hi2: Vector3 = (bb["aabb_max"] as Vector3).max(SceneObjects.xform_from(by_kind["object.create"][0]["xform"]).origin)
201 var pv: Vector3 = doc["pivot"]
202 _ok("D7 the bounds = every brush the contents fold to + the object", (doc["bounds"][0] as Vector3).is_equal_approx(lo2)
203 and (doc["bounds"][1] as Vector3).is_equal_approx(hi2), [doc["bounds"], lo2, hi2])
204 _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)
205
206 # ---- D8 ----
207 var with_obj := doc.duplicate(true)
208 with_obj["evil"] = Resource.new()
209 _ok("D8 refuses a blob carrying an Object", AssetDoc.decode(var_to_bytes_with_objects(with_obj)).is_empty())
210 var bad_kind := doc.duplicate(true)
211 (bad_kind["records"] as Array).append({"kind": "brush.remove", "dot": "L:999", "brush_key": "x"})
212 _ok("D8 refuses a record of another kind", AssetDoc.decode(AssetDoc.encode(bad_kind)).is_empty())
213 var bad_dot := doc.duplicate(true)
214 (bad_dot["records"][0] as Dictionary)["dot"] = "probe:1"
215 _ok("D8 refuses a non-local dot", AssetDoc.decode(AssetDoc.encode(bad_dot)).is_empty())
216 var bad_v := doc.duplicate(true)
217 bad_v["v"] = 99
218 _ok("D8 refuses another version", AssetDoc.decode(AssetDoc.encode(bad_v)).is_empty())
219 _ok("D8 refuses garbage", AssetDoc.decode(PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8])).is_empty())
220
221 # ---- D9 ----
222 persist.begin_gesture("move A")
223 persist.record("shape.xform", {"target": a, "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 1)))})
224 persist.end_gesture()
225 persist.refold()
226 _ok("D9 A moved -> a different oid", AssetDoc.hash_of(AssetDoc.encode(AssetDoc.write(persist, kit)["doc"])) != oid)
227
228
229## `v` with every Dictionary's keys inserted in reverse order.
230func _reversed(v: Variant) -> Variant:
231 if v is Dictionary:
232 var keys: Array = (v as Dictionary).keys()
233 keys.reverse()
234 var out := {}
235 for k: Variant in keys:
236 out[k] = _reversed((v as Dictionary)[k])
237 return out
238 if v is Array:
239 return (v as Array).map(func(x: Variant) -> Variant: return _reversed(x))
240 return v
scenes/test/gate_asset_doc.tscn +6 -0
……@@ -0,0 +1,6 @@
1[gd_scene load_steps=2 format=3]
2
3[ext_resource type="Script" path="res://scenes/test/gate_asset_doc.gd" id="1"]
4
5[node name="GateAssetDoc" type="Node"]
6script = ExtResource("1")