OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_asset_library.gd

202 lines · gd ref:
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"))