OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / e1d75dc

#120 L4 §15 step 3: asset libraries (room + personal) and Save collection as asset
AssetLibrary (a child of the map, made with the journal): save_collection writes the blob under the content overlay's hash, publishes it to the room (ContentOverlay + AssetSync kind "asset" with bounds — publish_asset gains an `extra` arg) and the personal library (user://assets, _ci under CI); a re-save under the same name moves MY placements in one gesture, others' wait for update_placements; place() publishes a personal-only asset to the room first; after every fold, missing blobs are loaded from disk or requested. Object → Save collection as asset… (menu-only builder.save_asset), Q-menu name prompt. gate_asset_library 20/20; 4 mutants killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e1d75dc659703eae2b88e96545c1a601a3df0aa3
by plankatron <fleeky@gmail.com> · 2026-10-19T21:12:26.000Z

Changed files (8) +564 -7

autoloads/asset_sync.gd +8 -2
……@@ -118,10 +118,16 @@ func publish_file(kind: String, name: String, abs_path: String, ext: String = ""
118118
119119
120120## MUTABLE: name the newest bytes for this kind+name. Newest publish wins.
121func publish_asset(kind: String, name: String, oid: String, size: int, ext: String = "") -> String:
121## `extra`: more fields for the entry (an asset's bounds, #120 L4) — never oid/size/ext.
122func publish_asset(kind: String, name: String, oid: String, size: int, ext: String = "",
123 extra: Dictionary = {}) -> String:
122124 if not is_ready() or oid == "":
123125 return ""
124 state.set_state(_mutable_key(kind, name), {"oid": oid, "size": size, "ext": ext})
126 var entry := extra.duplicate(true)
127 entry["oid"] = oid
128 entry["size"] = size
129 entry["ext"] = ext
130 state.set_state(_mutable_key(kind, name), entry)
125131 return oid
126132
127133
autoloads/keymap.gd +2 -0
……@@ -383,6 +383,8 @@ const DEFAULTS: Dictionary = {
383383 # OBJECTS AND COLLECTIONS (#120 F1, Blender 2.79's keys: tools/keymap/blender_279_left.json).
384384 # A brush object is a group of brushes; a collection holds objects and collections.
385385 "builder.collection_new": {"key": KEY_G, "ctrl": true, "label": "New collection from the selection"},
386 # #120 L4: menu-only (user 2026-10-19), Object menu.
387 "builder.save_asset": {"label": "Save collection as asset…"},
386388 "builder.collection_move": {"key": KEY_M, "label": "Move to collection…"},
387389 "builder.collection_link": {"key": KEY_M, "shift": true, "label": "Link to collection… (also)"},
388390 "builder.collection_remove": {"key": KEY_G, "ctrl": true, "alt": true, "label": "Remove from collection…"},
docs/node-tree-and-stacks.md +16 -4
……@@ -1312,10 +1312,22 @@ Save as asset is menu-only for now).
13121312 killed, 1 equivalent (two-step frame: exact by Sterbenz), the tail one killed after F11
13131313 was added (a live cut's OP_SEQ_BASE hid it). `asset.set` re-pointing is exercised in
13141314 step 3 (Update placements). **DONE 2026-10-19.**
13153. **Libraries + Save as asset**: room (ContentOverlay + AssetSync kind `asset`, the
1316 extra field) and personal (user://assets). `gate_asset_library`: save → listed in
1317 both; re-save → new oid, YOUR placements follow (one undo restores them), OTHERS'
1318 stay until Update placements; a personal asset placed in a room is published first.
13153. **Libraries + Save as asset** (`asset_library.gd`, a child of the map made with the
1316 journal; `map.asset_library()`): room (ContentOverlay publish + AssetSync kind `asset`
1317 with `bounds` via publish_asset's new `extra`) and personal (`user://assets`,
1318 `_ci` under CI). Object → **Save collection as asset…** (menu-only `builder.save_asset`;
1319 the outliner's selected collection, else the selection's first collection; named in
1320 the Q-menu name prompt, prefilled). Both libraries by default — the per-save choice
1321 waits for the browser (step 5); the outliner has no context menu yet, so its entry
1322 waits too. Blobs reach the fold from disk (the cache, then the personal library) after
1323 each fold, else by ContentOverlay.request; AssetSync already fetches every catalogue
1324 entry it sees. `gate_asset_library` (20): save → on disk under the overlay's hash,
1325 both libraries list it; place; another member's placement (delivered as replication
1326 does); re-save → new oid, MINE follow (one undo), THEIRS stay, the row counts 1 older;
1327 Update placements (one gesture); personal-only published on place; a lost blob found
1328 on disk after the fold; empty collection refused; the menu entry. 4 mutants killed
1329 (the fetch one after the gate flushed AssetSync's deferred content_ready signals,
1330 which had restored the blob by another path). **DONE 2026-10-19.**
131913314. **Editor**: outliner rows, selection mapping, G / R / X / Shift+D / Alt+D, Make Real.
13201332 `gate_asset_edit`: each, plus Make Real = the same digest, then ordinary editable
13211333 records (a shape param edit works on one).
scenes/game/editor_top_bar.gd +1 -1
……@@ -38,7 +38,7 @@ const MENUS: Array = [
3838 "builder.pivot_individual", "-", "builder.proportional", "builder.proportional_falloff",
3939 "builder.proportional_connected", "-", "builder.make_parametric", "builder.assign_generator", "builder.add_modifier", "builder.edit_curve", "builder.drop_cable", "-", "builder.parent", "builder.clear_parent", "-",
4040 "builder.join", "builder.separate", "-", "builder.collection_new", "builder.collection_move",
41 "builder.collection_link", "builder.collection_remove"]],
41 "builder.collection_link", "builder.collection_remove", "builder.save_asset"]],
4242 ["Texture", ["builder.uv_menu", "-", "builder.texture_apply", "builder.texture_pick", "-",
4343 "builder.uv_hotspot", "builder.uv_reroll", "builder.uv_pin", "builder.uv_fit", "builder.uv_align"]],
4444 ["View", ["builder.fly", "builder.wireframe", "builder.toggle_sidebar", "builder.toggle_toolbar", "builder.reset_panels", "-",
scenes/maps/asset_library.gd +258 -0
……@@ -0,0 +1,258 @@
1extends Node
2## THE ASSET LIBRARIES (#120 L4 §15.4): the ROOM's and a PERSONAL one, and the fetch that
3## feeds placed assets' blobs to the fold. A child of the map (MapDCLevel.asset_library()).
4##
5## ROOM blob bytes served by ContentOverlay (publish), named in the room by AssetSync
6## kind "asset": world:assets:asset:<name> -> {oid, size, ext, bounds} (newest
7## wins per name, as for models). AssetSync fetches every entry it sees into
8## user://content/<oid>.hasset; _on_content_ready hands it to the fold.
9## PERSONAL user://assets/<oid>.hasset + index.json {assets: [{name, oid, saved}]} on
10## this machine, any room. Placing one in a room publishes it there first.
11## (Under CI: user://assets_ci — a gate never writes the player's library.)
12##
13## SAVE (§15.4, user 2026-10-19): AssetDoc of the collection -> blob -> both libraries.
14## Saving under a name that exists makes a new oid; placements YOU made of that name follow
15## (asset.set, one gesture: one Ctrl+Z undoes it), others' stay until Update placements.
16##
17## DELETION STORY: nothing here deletes a blob. Re-saving replaces the personal index's
18## entry for the name, and the old file stays (a placement may pin it).
19
20const AssetDoc := preload("res://scenes/maps/asset_doc.gd")
21const KIND := "asset"
22const CACHE_DIR := "user://content"
23
24var map: Node = null
25var persist: Node = null
26
27
28func setup(map_node: Node, persistence: Node) -> void:
29 map = map_node
30 persist = persistence
31 var co := _overlay()
32 if co != null and not co.content_ready.is_connected(_on_content_ready):
33 co.content_ready.connect(_on_content_ready)
34 if persist != null and not persist.folded.is_connected(_on_folded):
35 persist.folded.connect(_on_folded)
36
37
38static func personal_dir() -> String:
39 return "user://assets_ci" if OS.get_environment("CI") != "" else "user://assets"
40
41
42func _overlay() -> Node:
43 return get_node_or_null("/root/ContentOverlay")
44
45
46func _sync() -> Node:
47 return get_node_or_null("/root/AssetSync")
48
49
50static func blob_path(oid: String) -> String:
51 return CACHE_DIR.path_join(oid + "." + AssetDoc.EXT)
52
53
54# ---- save ---------------------------------------------------------------------
55
56## Save collection `col` as asset `name`. {oid, report, followed} or {error, report}.
57func save_collection(col: String, name: String, room: bool = true, personal: bool = true) -> Dictionary:
58 if persist == null:
59 return {"error": "no journal", "report": []}
60 var w: Dictionary = AssetDoc.write(persist, col, name)
61 var doc: Dictionary = w["doc"]
62 if doc.is_empty():
63 return {"error": "nothing in the collection an asset can carry", "report": w["report"]}
64 var bytes := AssetDoc.encode(doc)
65 var oid := AssetDoc.hash_of(bytes)
66 if oid == "":
67 return {"error": "the content overlay is not running — cannot name the asset", "report": w["report"]}
68 if not _write(blob_path(oid), bytes):
69 return {"error": "cannot write %s" % blob_path(oid), "report": w["report"]}
70 persist.put_asset_blob(oid, bytes)
71 var followed := 0
72 if room:
73 if not _publish_room(name, oid, bytes.size(), doc):
74 return {"error": "the room's library refused it", "report": w["report"]}
75 followed = _follow_mine(name, oid)
76 if personal:
77 _save_personal(name, oid, bytes)
78 return {"oid": oid, "report": w["report"], "followed": followed}
79
80
81func _write(path: String, bytes: PackedByteArray) -> bool:
82 DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(path.get_base_dir()))
83 var f := FileAccess.open(path, FileAccess.WRITE)
84 if f == null:
85 return false
86 f.store_buffer(bytes)
87 f.close()
88 return true
89
90
91func _publish_room(name: String, oid: String, size: int, doc: Dictionary) -> bool:
92 var co := _overlay()
93 var sync := _sync()
94 if co == null or sync == null:
95 return false
96 if String(co.publish(blob_path(oid))) != oid:
97 return false
98 var b: Array = doc["bounds"]
99 return String(sync.publish_asset(KIND, name, oid, size, AssetDoc.EXT,
100 {"bounds": [(b[0] as Vector3).x, (b[0] as Vector3).y, (b[0] as Vector3).z,
101 (b[1] as Vector3).x, (b[1] as Vector3).y, (b[1] as Vector3).z]})) == oid
102
103
104## My placements of `name` on another version follow the new one: ONE gesture.
105func _follow_mine(name: String, oid: String) -> int:
106 var me := String(persist.call("_author"))
107 var mine: Array = []
108 for p: Dictionary in (persist.placements() as Dictionary).values():
109 if not bool(p["removed"]) and String(p["name"]) == name and String(p["asset"]) != oid \
110 and String(p["author"]) == me:
111 mine.append(String(p["dot"]))
112 if mine.is_empty():
113 return 0
114 persist.begin_gesture("Save asset: %s" % name)
115 for d: String in mine:
116 persist.record("asset.set", {"target": d, "asset": oid})
117 persist.end_gesture()
118 persist.refold()
119 return mine.size()
120
121
122func _save_personal(name: String, oid: String, bytes: PackedByteArray) -> void:
123 var dir := personal_dir()
124 _write(dir.path_join(oid + "." + AssetDoc.EXT), bytes)
125 var idx := personal_index()
126 var rows: Array = idx.filter(func(r: Dictionary) -> bool: return String(r["name"]) != name)
127 rows.append({"name": name, "oid": oid, "saved": int(Time.get_unix_time_from_system())})
128 var f := FileAccess.open(dir.path_join("index.json"), FileAccess.WRITE)
129 if f != null:
130 f.store_string(JSON.stringify({"assets": rows}, "\t"))
131 f.close()
132
133
134## The personal library: [{name, oid, saved}].
135static func personal_index() -> Array:
136 var f := FileAccess.open(personal_dir().path_join("index.json"), FileAccess.READ)
137 if f == null:
138 return []
139 var v: Variant = JSON.parse_string(f.get_as_text())
140 f.close()
141 if not (v is Dictionary) or not ((v as Dictionary).get("assets") is Array):
142 return []
143 var out: Array = []
144 for r: Variant in (v as Dictionary)["assets"]:
145 if r is Dictionary and String((r as Dictionary).get("name", "")) != "" \
146 and String((r as Dictionary).get("oid", "")).length() == 64:
147 out.append({"name": String(r["name"]), "oid": String(r["oid"]), "saved": int(r.get("saved", 0))})
148 return out
149
150
151# ---- list / place / update ---------------------------------------------------------
152
153## Both libraries, by name: [{name, oid, room, personal, older}] — `room` / `personal`
154## whether each holds the name (their oids can differ: the room's is shown, else yours);
155## `older` = live placements of the name on another version than the room's.
156func rows() -> Array:
157 var by := {}
158 var sync := _sync()
159 if sync != null:
160 for r: Dictionary in (sync.list(KIND) as Array):
161 by[String(r["name"])] = {"name": String(r["name"]), "oid": String(r["oid"]), "room": true,
162 "personal": false, "older": 0, "bounds": r.get("bounds", [])}
163 for r2: Dictionary in personal_index():
164 var n := String(r2["name"])
165 if by.has(n):
166 by[n]["personal"] = true
167 else:
168 by[n] = {"name": n, "oid": String(r2["oid"]), "room": false, "personal": true, "older": 0, "bounds": []}
169 if persist != null:
170 for p: Dictionary in (persist.placements() as Dictionary).values():
171 var n2 := String(p["name"])
172 if not bool(p["removed"]) and by.has(n2) and bool(by[n2]["room"]) and String(p["asset"]) != String(by[n2]["oid"]):
173 by[n2]["older"] = int(by[n2]["older"]) + 1
174 var names: Array = by.keys()
175 names.sort()
176 return names.map(func(n3: String) -> Dictionary: return by[n3])
177
178
179## Place asset `name` at `frame`, ONE gesture. A personal-only asset is published to the
180## room first (blob + catalogue), so every peer can fetch what it is about to see.
181## Returns the placement's dot, or "".
182func place(name: String, frame: Transform3D) -> String:
183 var row: Dictionary = {}
184 for r: Dictionary in rows():
185 if String(r["name"]) == name:
186 row = r
187 if row.is_empty():
188 return ""
189 var oid := String(row["oid"])
190 if not bool(row["room"]):
191 var bytes := FileAccess.get_file_as_bytes(personal_dir().path_join(oid + "." + AssetDoc.EXT))
192 if AssetDoc.hash_of(bytes) != oid or not _write(blob_path(oid), bytes):
193 return ""
194 var doc := AssetDoc.decode(bytes)
195 if doc.is_empty() or not _publish_room(name, oid, bytes.size(), doc):
196 return ""
197 persist.put_asset_blob(oid, bytes)
198 persist.begin_gesture("Place asset: %s" % name)
199 var dot: String = persist.record("asset.place", {"asset": oid, "name": name,
200 "frame": SceneObjects.xform_to(frame)})
201 persist.end_gesture()
202 persist.refold()
203 return dot
204
205
206## Update placements: every live placement of `name` on another version -> the room's, ONE
207## gesture. Returns how many.
208func update_placements(name: String) -> int:
209 var oid := ""
210 for r: Dictionary in rows():
211 if String(r["name"]) == name and bool(r["room"]):
212 oid = String(r["oid"])
213 if oid == "":
214 return 0
215 var todo: Array = []
216 for p: Dictionary in (persist.placements() as Dictionary).values():
217 if not bool(p["removed"]) and String(p["name"]) == name and String(p["asset"]) != oid:
218 todo.append(String(p["dot"]))
219 if todo.is_empty():
220 return 0
221 persist.begin_gesture("Update placements: %s" % name)
222 for d: String in todo:
223 persist.record("asset.set", {"target": d, "asset": oid})
224 persist.end_gesture()
225 persist.refold()
226 return todo.size()
227
228
229# ---- fetch: blobs the fold is waiting for ------------------------------------------
230
231## After every fold: hand the fold any blob it is missing that is on disk (the room's
232## cache, then the personal library), and ask the mesh for the rest.
233func _on_folded(_st: Dictionary) -> void:
234 fetch_missing()
235
236
237func fetch_missing() -> int:
238 if persist == null:
239 return 0
240 var asked := 0
241 for oid: String in (persist.missing_assets() as Array):
242 if _load(blob_path(oid), oid) or _load(personal_dir().path_join(oid + "." + AssetDoc.EXT), oid):
243 continue
244 var co := _overlay()
245 if co != null and String(co.request(oid, oid + "." + AssetDoc.EXT)) != "":
246 asked += 1
247 return asked
248
249
250func _load(path: String, oid: String) -> bool:
251 if not FileAccess.file_exists(path):
252 return false
253 return persist.put_asset_blob(oid, FileAccess.get_file_as_bytes(path))
254
255
256func _on_content_ready(content_hash: String, path: String) -> void:
257 if persist != null and path.ends_with("." + AssetDoc.EXT) and not persist.has_asset_blob(content_hash):
258 _load(path, content_hash)
scenes/maps/map_dc_level.gd +72 -0
……@@ -1145,6 +1145,9 @@ func _collection_keys(event: InputEvent) -> bool:
11451145 var dot := CollectionOps.create_from_selection(self, n, "", cols)
11461146 print("[MapDCLevel] new collection %s \"%s\" from the selection" % [dot, n]))
11471147 return true
1148 if Keymap.tapped(event, "builder.save_asset"):
1149 save_asset_prompt()
1150 return true
11481151 if Keymap.tapped(event, "builder.join"):
11491152 CollectionOps.join(self)
11501153 return true
……@@ -1167,6 +1170,69 @@ func _collection_keys(event: InputEvent) -> bool:
11671170 return false
11681171
11691172
1173## ---- ASSETS (#120 L4 §15.4) ----
1174
1175const AssetLibraryScript := preload("res://scenes/maps/asset_library.gd")
1176var _asset_library: Node = null
1177
1178
1179## The room's + personal asset libraries (a child; created with the journal so placed
1180## assets' blobs are fetched whether or not a panel is open).
1181func asset_library() -> Node:
1182 _ensure_persistence()
1183 return _asset_library
1184
1185
1186## The collection Save as asset acts on: the outliner's selected collection row, else the
1187## first collection the selection is in. "" when neither.
1188func asset_collection() -> String:
1189 _ensure_persistence()
1190 var cols: Array = _outliner.selected_collections() if _outliner != null and is_instance_valid(_outliner) else []
1191 if not cols.is_empty():
1192 return String(cols[0])
1193 # The selection's members, READ ONLY (CollectionOps.selection_members regroups loose
1194 # brushes — a write — and asking which collection must not write).
1195 var ms: Array = []
1196 if _sel_shape != "":
1197 ms.append("g:" + _sel_shape)
1198 for d: Variant in selected_objects():
1199 ms.append("obj:" + String(d))
1200 for b: int in _sel_brushes:
1201 var g := String(level.get_brush_group(b)) if level != null else ""
1202 if g != "" and not ms.has("g:" + g):
1203 ms.append("g:" + g)
1204 var t: Dictionary = _persist.collection_tree()
1205 for m: String in ms:
1206 var cs: Array = CollectionFold.collections_of(t, m)
1207 if not cs.is_empty():
1208 return String(cs[0])
1209 return ""
1210
1211
1212## SAVE COLLECTION AS ASSET (menu only, user 2026-10-19): named first (Blender's popup,
1213## prefilled with the collection's name), then into the room's and your library.
1214func save_asset_prompt() -> void:
1215 var col := asset_collection()
1216 if col == "":
1217 print("[MapDCLevel] save as asset: select a collection (its outliner row, or something in it)")
1218 return
1219 var cname := String(((_persist.collection_tree()["cols"] as Dictionary)[col] as Dictionary)["name"])
1220 var make := func(n: String) -> void:
1221 var r: Dictionary = asset_library().save_collection(col, n)
1222 for line: String in (r.get("report", []) as Array):
1223 print("[MapDCLevel] save as asset: %s" % line)
1224 if r.has("error"):
1225 print("[MapDCLevel] save as asset \"%s\": %s" % [n, r["error"]])
1226 else:
1227 print("[MapDCLevel] saved asset \"%s\" %s (%d of your placements updated)"
1228 % [n, String(r["oid"]).substr(0, 12), int(r["followed"])])
1229 if _q_menu == null or not _q_menu.has_method("open_name"):
1230 make.call(cname)
1231 return
1232 _q_menu.open_name("Save as asset", cname, make, get_viewport().get_mouse_position())
1233 get_viewport().set_input_as_handled()
1234
1235
11701236## A NEW COLLECTION'S NAME (user 2026-10-18: "when creating a new group i should be
11711237## prompted for a name"), Blender's popup: make(name) runs on Enter, nothing on Esc, so the
11721238## collection is made by ONE gesture or not at all. No popup (bare harnesses): "Collection".
……@@ -3724,6 +3790,12 @@ func _ensure_persistence() -> void:
37243790 _queue_nav_rebuild())
37253791 _register_persistence_admin(_persist)
37263792 add_child(_persist)
3793 # The asset libraries (#120 L4): with the journal, so placed assets' blobs are fetched
3794 # whatever panel is open.
3795 _asset_library = AssetLibraryScript.new()
3796 _asset_library.name = "AssetLibrary"
3797 add_child(_asset_library)
3798 _asset_library.setup(self, _persist)
37273799
37283800
37293801# ---- navigation (bots) --------------------------------------------------------
scenes/test/gate_asset_library.gd +201 -0
……@@ -0,0 +1,201 @@
1extends Node
2## GATE — THE ASSET LIBRARIES (#120 L4 step 3, docs/node-tree-and-stacks.md §15.4,
3## asset_library.gd). A room with a collection "Kit" (a box with a mirror + a brush object).
4## L1 Save as asset -> the blob on disk under the CONTENT OVERLAY's own hash; the room's
5## catalogue (AssetSync kind "asset", with bounds) and the personal index list it
6## L2 place it -> a placement that folds to pieces
7## L3 a placement by ANOTHER author
8## L4 edit Kit, save under the same name -> a new oid; MY placement follows, theirs stays;
9## the row reports 1 placement on an older version
10## L5 undo -> mine back on the old version (the follow was ONE gesture)
11## L6 Update placements -> both on the new version, one gesture
12## L7 a personal-only asset: not in the room until placed; placing publishes it first
13## L8 a blob the fold lost is found on disk again (fetch after the fold) -> the pieces
14## L9 an empty collection is refused, with the reason
15## L10 the menu: Object -> Save collection as asset… (menu-only, no key)
16##
17## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_library.tscn
18
19const AssetLibrary := preload("res://scenes/maps/asset_library.gd")
20
21var fails := 0
22var checks := 0
23var map: Node3D
24var persist: Node
25var level: Node
26var lib: Node
27
28
29func _ok(what: String, cond: bool, note: Variant = "") -> void:
30 checks += 1
31 if not cond:
32 fails += 1
33 print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
34
35
36func _ready() -> void:
37 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
38 gs.route("world:level:*", 1, "peer")
39 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
40 if keet != null:
41 keet.current_member_id = "probe"
42 get_tree().root.get_node("/root/GameMode").set_mode("build")
43 # The PERSONAL library under CI is user://assets_ci: start it empty. Never the player's.
44 var pd := AssetLibrary.personal_dir()
45 if pd.ends_with("_ci"):
46 for f: String in DirAccess.get_files_at(pd):
47 DirAccess.remove_absolute(ProjectSettings.globalize_path(pd.path_join(f)))
48 map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
49 add_child(map)
50 for i in 6:
51 await get_tree().process_frame
52 persist = map.get("_persist")
53 level = map.get("level")
54 lib = map.asset_library()
55 await _run()
56 if checks < 20:
57 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
58 fails += 1
59 print("\nGATE asset_library: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
60 get_tree().quit(0 if fails == 0 else 1)
61
62
63func _box(at: Vector3, size: Vector3) -> String:
64 return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
65 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z],
66 "material_id": 2, "level_id": 0})
67
68
69func _brushes_of(pdot: String) -> int:
70 var n := 0
71 for b: int in (level.get_live_brush_ids() as PackedInt32Array):
72 if persist.instance_of(String(level.get_brush_group(b))) == pdot:
73 n += 1
74 return n
75
76
77func _row(name: String) -> Dictionary:
78 for r: Dictionary in (lib.rows() as Array):
79 if String(r["name"]) == name:
80 return r
81 return {}
82
83
84func _asset_of(pdot: String) -> String:
85 return String((persist.placements() as Dictionary).get(pdot, {}).get("asset", ""))
86
87
88func _run() -> void:
89 var O0 := Vector3(600.137, 0.31, 599.873)
90 persist.begin_gesture("room")
91 var a := _box(O0, Vector3(2, 2, 2))
92 persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
93 var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""})
94 persist.record("col.link", {"target": kit, "members": ["g:" + a]})
95 var empty: String = persist.record("col.create", {"name": "Empty", "parent": ""})
96 persist.end_gesture()
97 persist.refold()
98
99 # ---- L1 ----
100 var r1: Dictionary = lib.save_collection(kit, "Kit")
101 var oid := String(r1.get("oid", ""))
102 _ok("L1 saved: an oid", oid.length() == 64, r1)
103 var path: String = AssetLibrary.blob_path(oid)
104 var co: Node = get_tree().root.get_node("ContentOverlay")
105 _ok("L1 the blob is on disk under the overlay's own hash of it", FileAccess.file_exists(path)
106 and String(co.content_hash_of_file(path)) == oid, path)
107 var row := _row("Kit")
108 _ok("L1 the room's catalogue lists it (oid, ext, bounds)", bool(row.get("room", false)) and String(row["oid"]) == oid
109 and (row.get("bounds", []) as Array).size() == 6, row)
110 var entry: Variant = get_tree().root.get_node("AssetSync").state.get_state("world:assets:asset:Kit", null)
111 _ok("L1 ... the entry carries ext hasset", entry is Dictionary and String((entry as Dictionary).get("ext", "")) == "hasset", entry)
112 _ok("L1 the personal index lists it", AssetLibrary.personal_index().any(func(x: Dictionary) -> bool:
113 return String(x["name"]) == "Kit" and String(x["oid"]) == oid))
114
115 # ---- L2 / L3 ----
116 var mine: String = lib.place("Kit", Transform3D(Basis.IDENTITY, O0 + Vector3(30, 0, 0)))
117 _ok("L2 placed: it folds to pieces", mine != "" and _brushes_of(mine) > 0, [mine, _brushes_of(mine)])
118 # Another member's record ARRIVES the way replication delivers one (GameState's
119 # _on_durable_updated — this peer may not WRITE under their name, can_write refuses).
120 var oseq := int(mine.get_slice(":", 1)) + 7
121 var theirs := "zz9other:%d" % oseq
122 var gs: Node = get_tree().root.get_node("GameState")
123 # The STORED form of a map-scoped key ("map:<map id>:<key>", _scoped's) — built here
124 # because _scoped leaves a key bare while no map id is set, and _unscope drops bare ones.
125 var stored := "map:%s:world:level:zz9other:op:%d" % [String(gs.current_map_id), oseq]
126 gs._on_durable_updated("", stored, {"v": LevelPersistence.RECORD_V,
127 "kind": "asset.place", "undone": false, "g": theirs, "src": String(persist.find_source().get("hash", "")),
128 "asset": oid, "name": "Kit", "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 30)))})
129 var wrote := (persist.placements() as Dictionary).has(theirs)
130 persist.refold()
131 _ok("L3 another author's placement", wrote and _brushes_of(theirs) > 0, [wrote, _brushes_of(theirs)])
132
133 # ---- L4 ----
134 persist.begin_gesture("edit kit")
135 persist.record("shape.size", {"target": a, "size": [3, 2, 2]})
136 persist.end_gesture()
137 persist.refold()
138 var r2: Dictionary = lib.save_collection(kit, "Kit")
139 var oid2 := String(r2.get("oid", ""))
140 _ok("L4 re-saved: a NEW oid", oid2.length() == 64 and oid2 != oid)
141 _ok("L4 MY placement follows", _asset_of(mine) == oid2 and int(r2.get("followed", 0)) == 1, [_asset_of(mine).substr(0, 8), r2.get("followed")])
142 _ok("L4 THEIRS stays on the old version", _asset_of(theirs) == oid)
143 _ok("L4 the row: 1 placement on an older version", int(_row("Kit").get("older", -1)) == 1, _row("Kit"))
144
145 # ---- L5 ----
146 persist.undo_last()
147 persist.refold()
148 _ok("L5 undo -> mine back on the old version (one gesture)", _asset_of(mine) == oid and int(_row("Kit")["older"]) == 2)
149
150 # ---- L6 ----
151 var n6: int = lib.update_placements("Kit")
152 _ok("L6 Update placements: both on the new version", n6 == 2 and _asset_of(mine) == oid2 and _asset_of(theirs) == oid2
153 and int(_row("Kit")["older"]) == 0, n6)
154 persist.undo_last()
155 persist.refold()
156 _ok("L6 ... one gesture: one undo puts both back", _asset_of(mine) == oid and _asset_of(theirs) == oid)
157 persist.redo_last()
158 persist.refold()
159
160 # ---- L7 ----
161 persist.begin_gesture("solo")
162 var s := _box(O0 + Vector3(0, 0, -20), Vector3(1, 1, 1))
163 var solo: String = persist.record("col.create", {"name": "Solo", "parent": ""})
164 persist.record("col.link", {"target": solo, "members": ["g:" + s]})
165 persist.end_gesture()
166 persist.refold()
167 var r7: Dictionary = lib.save_collection(solo, "Solo", false, true)
168 var sync: Node = get_tree().root.get_node("AssetSync")
169 _ok("L7 personal only: not in the room", String(sync.resolve("asset", "Solo")) == ""
170 and bool(_row("Solo").get("personal", false)) and not bool(_row("Solo").get("room", true)), _row("Solo"))
171 var sp: String = lib.place("Solo", Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, -40)))
172 _ok("L7 placing it publishes it to the room first", String(sync.resolve("asset", "Solo")) == String(r7.get("oid", "")), sync.resolve("asset", "Solo"))
173 _ok("L7 ... and it folds", sp != "" and _brushes_of(sp) > 0)
174
175 # ---- L8 ----
176 # Flush first: every publish above made AssetSync request its blob, and those
177 # content_ready signals are DEFERRED — pending since L1 (no frame has passed). Left
178 # queued they restore the blob by another path and hide whether the fold's own fetch
179 # works (a mutant without it passed).
180 for i in 4:
181 await get_tree().process_frame
182 (persist.get("_asset_docs") as Dictionary).erase(oid2)
183 persist.set("_brush_ver_folded", -1)
184 persist.refold() # mine has no blob now: the wire box; the fold's
185 for i in 4: # listener finds the file and hands it back
186 await get_tree().process_frame
187 _ok("L8 a lost blob is found on disk again: the pieces", persist.has_asset_blob(oid2) and _brushes_of(mine) > 1,
188 [persist.has_asset_blob(oid2), _brushes_of(mine)])
189
190 # ---- L9 ----
191 var r9: Dictionary = lib.save_collection(empty, "Empty")
192 _ok("L9 an empty collection is refused, with the reason", r9.has("error") and not _row("Empty").get("room", false), r9.get("error", ""))
193
194 # ---- L10 ----
195 var bar_menus: Array = load("res://scenes/game/editor_top_bar.gd").MENUS
196 var obj_menu: Array = []
197 for m: Array in bar_menus:
198 if String(m[0]) == "Object":
199 obj_menu = m[1]
200 _ok("L10 Object menu: Save collection as asset…, menu-only", obj_menu.has("builder.save_asset")
201 and not Keymap.binding_of("builder.save_asset").has("key"), Keymap.binding_of("builder.save_asset"))
scenes/test/gate_asset_library.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_library.gd" id="1"]
4
5[node name="GateAssetLibrary" type="Node"]
6script = ExtResource("1")