extends Node
## GATE — THE ASSET LIBRARIES (#120 L4 step 3, docs/node-tree-and-stacks.md §15.4,
## asset_library.gd). A room with a collection "Kit" (a box with a mirror + a brush object).
## L1 Save as asset -> the blob on disk under the CONTENT OVERLAY's own hash; the room's
## catalogue (AssetSync kind "asset", with bounds) and the personal index list it
## L2 place it -> a placement that folds to pieces
## L3 a placement by ANOTHER author
## L4 edit Kit, save under the same name -> a new oid; MY placement follows, theirs stays;
## the row reports 1 placement on an older version
## L5 undo -> mine back on the old version (the follow was ONE gesture)
## L6 Update placements -> both on the new version, one gesture
## L7 a personal-only asset: not in the room until placed; placing publishes it first
## L8 a blob the fold lost is found on disk again (fetch after the fold) -> the pieces
## L9 an empty collection is refused, with the reason
## L10 the menu: Object -> Save collection as asset… (menu-only, no key)
##
## 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
const AssetLibrary := preload("res://scenes/maps/asset_library.gd")
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var lib: 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")
# The PERSONAL library under CI is user://assets_ci: start it empty. Never the player's.
var pd := AssetLibrary.personal_dir()
if pd.ends_with("_ci"):
for f: String in DirAccess.get_files_at(pd):
DirAccess.remove_absolute(ProjectSettings.globalize_path(pd.path_join(f)))
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")
lib = map.asset_library()
await _run()
if checks < 20:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE asset_library: %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})
func _brushes_of(pdot: String) -> int:
var n := 0
for b: int in (level.get_live_brush_ids() as PackedInt32Array):
if persist.instance_of(String(level.get_brush_group(b))) == pdot:
n += 1
return n
func _row(name: String) -> Dictionary:
for r: Dictionary in (lib.rows() as Array):
if String(r["name"]) == name:
return r
return {}
func _asset_of(pdot: String) -> String:
return String((persist.placements() as Dictionary).get(pdot, {}).get("asset", ""))
func _run() -> void:
var O0 := Vector3(600.137, 0.31, 599.873)
persist.begin_gesture("room")
var a := _box(O0, Vector3(2, 2, 2))
persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""})
persist.record("col.link", {"target": kit, "members": ["g:" + a]})
var empty: String = persist.record("col.create", {"name": "Empty", "parent": ""})
persist.end_gesture()
persist.refold()
# ---- L1 ----
var r1: Dictionary = lib.save_collection(kit, "Kit")
var oid := String(r1.get("oid", ""))
_ok("L1 saved: an oid", oid.length() == 64, r1)
var path: String = AssetLibrary.blob_path(oid)
var co: Node = get_tree().root.get_node("ContentOverlay")
_ok("L1 the blob is on disk under the overlay's own hash of it", FileAccess.file_exists(path)
and String(co.content_hash_of_file(path)) == oid, path)
var row := _row("Kit")
_ok("L1 the room's catalogue lists it (oid, ext, bounds)", bool(row.get("room", false)) and String(row["oid"]) == oid
and (row.get("bounds", []) as Array).size() == 6, row)
var entry: Variant = get_tree().root.get_node("AssetSync").state.get_state("world:assets:asset:Kit", null)
_ok("L1 ... the entry carries ext hasset", entry is Dictionary and String((entry as Dictionary).get("ext", "")) == "hasset", entry)
_ok("L1 the personal index lists it", AssetLibrary.personal_index().any(func(x: Dictionary) -> bool:
return String(x["name"]) == "Kit" and String(x["oid"]) == oid))
# ---- L2 / L3 ----
var mine: String = lib.place("Kit", Transform3D(Basis.IDENTITY, O0 + Vector3(30, 0, 0)))
_ok("L2 placed: it folds to pieces", mine != "" and _brushes_of(mine) > 0, [mine, _brushes_of(mine)])
# Another member's record ARRIVES the way replication delivers one (GameState's
# _on_durable_updated — this peer may not WRITE under their name, can_write refuses).
var oseq := int(mine.get_slice(":", 1)) + 7
var theirs := "zz9other:%d" % oseq
var gs: Node = get_tree().root.get_node("GameState")
# The STORED form of a map-scoped key ("map:<map id>:<key>", _scoped's) — built here
# because _scoped leaves a key bare while no map id is set, and _unscope drops bare ones.
var stored := "map:%s:world:level:zz9other:op:%d" % [String(gs.current_map_id), oseq]
gs._on_durable_updated("", stored, {"v": LevelPersistence.RECORD_V,
"kind": "asset.place", "undone": false, "g": theirs, "src": String(persist.find_source().get("hash", "")),
"asset": oid, "name": "Kit", "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 30)))})
var wrote := (persist.placements() as Dictionary).has(theirs)
persist.refold()
_ok("L3 another author's placement", wrote and _brushes_of(theirs) > 0, [wrote, _brushes_of(theirs)])
# ---- L4 ----
persist.begin_gesture("edit kit")
persist.record("shape.size", {"target": a, "size": [3, 2, 2]})
persist.end_gesture()
persist.refold()
var r2: Dictionary = lib.save_collection(kit, "Kit")
var oid2 := String(r2.get("oid", ""))
_ok("L4 re-saved: a NEW oid", oid2.length() == 64 and oid2 != oid)
_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")])
_ok("L4 THEIRS stays on the old version", _asset_of(theirs) == oid)
_ok("L4 the row: 1 placement on an older version", int(_row("Kit").get("older", -1)) == 1, _row("Kit"))
# ---- L5 ----
persist.undo_last()
persist.refold()
_ok("L5 undo -> mine back on the old version (one gesture)", _asset_of(mine) == oid and int(_row("Kit")["older"]) == 2)
# ---- L6 ----
var n6: int = lib.update_placements("Kit")
_ok("L6 Update placements: both on the new version", n6 == 2 and _asset_of(mine) == oid2 and _asset_of(theirs) == oid2
and int(_row("Kit")["older"]) == 0, n6)
persist.undo_last()
persist.refold()
_ok("L6 ... one gesture: one undo puts both back", _asset_of(mine) == oid and _asset_of(theirs) == oid)
persist.redo_last()
persist.refold()
# ---- L7 ----
persist.begin_gesture("solo")
var s := _box(O0 + Vector3(0, 0, -20), Vector3(1, 1, 1))
var solo: String = persist.record("col.create", {"name": "Solo", "parent": ""})
persist.record("col.link", {"target": solo, "members": ["g:" + s]})
persist.end_gesture()
persist.refold()
var r7: Dictionary = lib.save_collection(solo, "Solo", false, true)
var sync: Node = get_tree().root.get_node("AssetSync")
_ok("L7 personal only: not in the room", String(sync.resolve("asset", "Solo")) == ""
and bool(_row("Solo").get("personal", false)) and not bool(_row("Solo").get("room", true)), _row("Solo"))
var sp: String = lib.place("Solo", Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, -40)))
_ok("L7 placing it publishes it to the room first", String(sync.resolve("asset", "Solo")) == String(r7.get("oid", "")), sync.resolve("asset", "Solo"))
_ok("L7 ... and it folds", sp != "" and _brushes_of(sp) > 0)
# ---- L8 ----
# Flush first: every publish above made AssetSync request its blob, and those
# content_ready signals are DEFERRED — pending since L1 (no frame has passed). Left
# queued they restore the blob by another path and hide whether the fold's own fetch
# works (a mutant without it passed).
for i in 4:
await get_tree().process_frame
(persist.get("_asset_docs") as Dictionary).erase(oid2)
persist.set("_brush_ver_folded", -1)
persist.refold() # mine has no blob now: the wire box; the fold's
for i in 4: # listener finds the file and hands it back
await get_tree().process_frame
_ok("L8 a lost blob is found on disk again: the pieces", persist.has_asset_blob(oid2) and _brushes_of(mine) > 1,
[persist.has_asset_blob(oid2), _brushes_of(mine)])
# ---- L9 ----
var r9: Dictionary = lib.save_collection(empty, "Empty")
_ok("L9 an empty collection is refused, with the reason", r9.has("error") and not _row("Empty").get("room", false), r9.get("error", ""))
# ---- L10 ----
var bar_menus: Array = load("res://scenes/game/editor_top_bar.gd").MENUS
var obj_menu: Array = []
for m: Array in bar_menus:
if String(m[0]) == "Object":
obj_menu = m[1]
_ok("L10 Object menu: Save collection as asset…, menu-only", obj_menu.has("builder.save_asset")
and not Keymap.binding_of("builder.save_asset").has("key"), Keymap.binding_of("builder.save_asset"))