OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / scene_objects_gate.gd

420 lines · gd ref:
extends SceneTree
## object.create — hyper-games #28, the durable half.
##
##     "$HYPER_TPS_GODOT" --headless --script scenes/test/scene_objects_gate.gd
##
## Godot's _do_create ends in `set_owner`, and that line IS its persistence: it
## saves by walking the live tree into a PackedScene, and an unowned node is
## invisible to the walk. We have no scene file — the durable state is a journal
## replayed on every peer — so the equivalent is that the create is journalled
## and the fold rebuilds it. What that swaps is a serializer for a replay, and
## replay has failure modes a serializer does not:
##
##   IT IS NOT ONCE-ONLY. GameState.state_changed fires on bulk load AND on every
##   live update, INCLUDING your own writes coming back confirmed. A create that
##   was not idempotent would grow an object per round trip — and it would look
##   fine locally, because the first one is correct.
##
##   THE TYPE ARRIVES FROM SOMEONE ELSE. `type` is a string another peer wrote,
##   handed to a loader. Open ClassDB search — what Godot's CreateDialog does —
##   would make that "instantiate any class this build has, named by a peer".
##   The catalogue is the allow-list, and a record naming something outside it
##   must be refused rather than loaded.
##
##   UNDO IS A FLAG, NOT AN INVERSE. There is no durable delete path, so undo is
##   the record marked `undone` and another fold. The object must go.

var _failures: Array[String] = []
var _frame := 0


func _check(label: String, ok: bool, detail: String) -> void:
	if not ok:
		_failures.append("%s (%s)" % [label, detail])
	print("  %-52s %s  %s" % [label, "OK  " if ok else "FAIL", detail])


func _rec(author: String, seq: int, type_id: String, pos: Vector3,
		undone: bool = false) -> Dictionary:
	return {
		"author": author, "seq": seq, "kind": "object.create", "undone": undone,
		"type": type_id, "xform": Transform3D(Basis(), pos),
	}


func _process(_delta: float) -> bool:
	_frame += 1
	if _frame < 3:
		return false

	print("scene objects gate")

	# ---- the catalogue is an allow-list, not a search --------------------
	_check("the catalogue has ids", PrefabCatalogue.ids().size() >= 3,
		"%s" % str(PrefabCatalogue.ids()))
	_check("a known id instantiates",
		PrefabCatalogue.instantiate("light.point") != null, "light.point")
	# THE ONE THAT MATTERS. Godot would happily instantiate this.
	_check("an arbitrary class is REFUSED",
		PrefabCatalogue.instantiate("Node3D") == null, "not in the table")
	_check("and so is a raw path",
		PrefabCatalogue.instantiate("res://scenes/game/game.tscn") == null, "refused")

	var objs := SceneObjects.new()
	root.add_child(objs)

	# ---- a fold builds it ------------------------------------------------
	var recs: Array = [
		_rec("peerA", 1, "light.point", Vector3(1, 2, 3)),
		_rec("peerB", 2, "marker", Vector3(4, 5, 6)),
	]
	var st: Dictionary = objs.apply_journal(recs)
	_check("both records applied", int(st["applied"]) == 2,
		"applied %d skipped %d" % [int(st["applied"]), int(st["skipped"])])
	_check("two objects exist", objs.count() == 2, "%d" % objs.count())
	var light: Node = objs.node_for("peerA:1")
	_check("named by its DOT, not a node path", light != null, "peerA:1")
	_check("and placed where the record says",
		light != null and (light as Node3D).transform.origin.is_equal_approx(Vector3(1, 2, 3)),
		str((light as Node3D).transform.origin) if light != null else "?")
	_check("the node carries its dot", SceneObjects.dot_of(light) == "peerA:1",
		SceneObjects.dot_of(light))

	# ---- REPLAY IS NOT ONCE-ONLY -----------------------------------------
	# The same records again, three times, as a peer's own writes coming back.
	for i in 3:
		objs.apply_journal(recs)
	_check("re-folding does NOT duplicate", objs.count() == 2, "%d after 4 folds" % objs.count())
	_check("and it is the SAME node, not a replacement",
		objs.node_for("peerA:1") == light, "identity held")

	# ---- undo is the flag ------------------------------------------------
	var undone: Array = [_rec("peerA", 1, "light.point", Vector3(1, 2, 3), true), recs[1]]
	st = objs.apply_journal(undone)
	_check("an undone record removes its object", objs.count() == 1, "%d" % objs.count())
	_check("and it is counted as undone", int(st["undone"]) == 1, "%d" % int(st["undone"]))
	_check("the other survives", objs.node_for("peerB:2") != null, "peerB:2")
	# REDO. The flag going back is all it takes — there is no inverse op to run.
	st = objs.apply_journal(recs)
	_check("clearing the flag brings it back", objs.count() == 2, "%d" % objs.count())

	# ---- version skew ----------------------------------------------------
	# A record from a build that knows a prefab this one does not. Reported, not
	# crashed, and not silently dropped either.
	st = objs.apply_journal([recs[0], recs[1], _rec("peerC", 3, "light.disco", Vector3.ZERO)])
	_check("an unknown prefab is skipped, not fatal", int(st["skipped"]) == 1,
		"skipped %d" % int(st["skipped"]))
	_check("and the known ones still applied", objs.count() == 2, "%d" % objs.count())

	# ---- ORDER IS (seq, author), not arrival -----------------------------
	var shuffled: Array = [_rec("zz", 9, "marker", Vector3(9, 9, 9)),
		_rec("aa", 1, "marker", Vector3(1, 1, 1))]
	var fresh := SceneObjects.new()
	root.add_child(fresh)
	fresh.apply_journal(shuffled)
	_check("both land regardless of arrival order", fresh.count() == 2, "%d" % fresh.count())
	_check("and each keeps its own transform",
		(fresh.node_for("aa:1") as Node3D).transform.origin.is_equal_approx(Vector3(1, 1, 1)),
		str((fresh.node_for("aa:1") as Node3D).transform.origin))

	# ---- the partition: object records are not DCLevel's -----------------
	_check("SceneObjects claims object.* only",
		SceneObjects.handles("object.create") and not SceneObjects.handles("brush.create"),
		"object.create yes, brush.create no")

	# ---- the transform survives the durable encoding ---------------------
	# 12 floats out and back. A partial decode would put an object somewhere
	# nobody chose, which is why the helper returns identity rather than half a
	# basis — assert both directions.
	var t := Transform3D(Basis(Vector3(0, 1, 0), 0.7), Vector3(3, -2, 11))
	var round_trip: Transform3D = SceneObjects.xform_from(SceneObjects.xform_to(t))
	_check("a transform round-trips through 12 floats",
		round_trip.is_equal_approx(t), str(round_trip.origin))
	_check("and a malformed one is the identity, not a partial basis",
		SceneObjects.xform_from([1, 2, 3]) == Transform3D.IDENTITY, "identity")

	# ---- object.transform ------------------------------------------------
	# ABSOLUTE, not a delta — the fold replays the whole journal, so a relative
	# move would compound on every re-fold. That is the same reason DCLevel
	# rewinds to base planes instead of accumulating.
	var moved := SceneObjects.new()
	root.add_child(moved)
	var base: Array = [_rec("a", 1, "marker", Vector3(0, 0, 0))]
	var xf := func(author: String, seq: int, target: String, pos: Vector3,
			undone: bool = false) -> Dictionary:
		return {"author": author, "seq": seq, "kind": "object.transform",
			"undone": undone, "target": target, "xform": Transform3D(Basis(), pos)}
	var with_move: Array = base + [xf.call("a", 2, "a:1", Vector3(5, 0, 0))]
	moved.apply_journal(with_move)
	var mk: Node3D = moved.node_for("a:1") as Node3D
	_check("a transform moves the object", mk != null
		and mk.transform.origin.is_equal_approx(Vector3(5, 0, 0)),
		str(mk.transform.origin) if mk != null else "?")
	_check("and does not create a second one", moved.count() == 1, "%d" % moved.count())
	_check("it is the same node", moved.node_for("a:1") == mk, "identity held")

	# RE-FOLDING MUST NOT COMPOUND. Four folds of an absolute move land in the
	# same place; a delta would be at x=20 by now.
	for i in 3:
		moved.apply_journal(with_move)
	_check("re-folding does not compound the move",
		mk.transform.origin.is_equal_approx(Vector3(5, 0, 0)), str(mk.transform.origin))

	# LAST IN (seq, author) ORDER WINS.
	moved.apply_journal(with_move + [xf.call("a", 3, "a:1", Vector3(9, 0, 0))])
	_check("the later transform wins", mk.transform.origin.is_equal_approx(Vector3(9, 0, 0)),
		str(mk.transform.origin))

	# UNDO FALLS BACK, with no inverse to compute. THE ONE THAT WOULD ROT: the
	# create is already applied, so if the fold skipped it the object would keep
	# the position the undone record gave it and nothing would look wrong.
	moved.apply_journal(with_move + [xf.call("a", 3, "a:1", Vector3(9, 0, 0), true)])
	_check("undoing the later move falls back to the earlier one",
		mk.transform.origin.is_equal_approx(Vector3(5, 0, 0)), str(mk.transform.origin))
	moved.apply_journal(base + [xf.call("a", 2, "a:1", Vector3(5, 0, 0), true)])
	_check("undoing every move returns it to where it was created",
		mk.transform.origin.is_equal_approx(Vector3.ZERO), str(mk.transform.origin))

	# A move with nothing to move is a disagreement, and is counted.
	var orphan: Dictionary = moved.apply_journal(base + [xf.call("a", 4, "nobody:9", Vector3.ONE)])
	_check("a transform naming an unknown object is skipped",
		int(orphan["skipped"]) == 1, "skipped %d" % int(orphan["skipped"]))

	# ---- objects are listed for the outliner -----------------------------
	var ents: Array = moved.entries()
	_check("entries() reports the object with its dot and type",
		ents.size() == 1 and String(ents[0]["dot"]) == "a:1"
		and String(ents[0]["type"]) == "marker",
		"%s" % str(ents))

	_set_prop_section()
	_remove_section()

	print("\nRESULT: %s" % ("PASS" if _failures.is_empty() else "FAIL"))
	for f: String in _failures:
		print("  - %s" % f)
	quit(0 if _failures.is_empty() else 1)
	return true


# ---- object.set_prop ----------------------------------------------------------
#
# A light's colour and range used to be whatever the prefab said (#41 §7 item 5).
# set_prop is the third object record, and it has the create's problem twice over:
# the PROPERTY NAME and the VALUE both arrive from another peer. So the catalogue
# lists, per prefab, what may be set, its kind, its range and its default — and the
# fold applies nothing outside that list.
#
# Per-PROPERTY last-write-wins in (seq, author) order: two people changing different
# properties at once must not clobber each other. And because the fold is rebuilt,
# not patched, every object goes back to its prefab defaults before the records
# apply — that is what makes an undone set_prop fall back with no inverse.

func _sp(author: String, seq: int, target: String, props: Dictionary,
		undone: bool = false) -> Dictionary:
	return {"author": author, "seq": seq, "kind": "object.set_prop", "undone": undone,
		"target": target, "props": props}


func _color_of(c: Color) -> String:
	return "(%.2f, %.2f, %.2f)" % [c.r, c.g, c.b]


func _tint_mat(n: Node, mesh_path: String) -> StandardMaterial3D:
	var mi := n.get_node_or_null(NodePath(mesh_path)) as MeshInstance3D
	return mi.material_override as StandardMaterial3D if mi != null else null


# ---- object.remove ------------------------------------------------------------
#
# DELETE IS A RECORD, like brush.remove: the create stays in the journal, a live
# remove targeting it takes the object out of the fold, and undoing the remove (its
# flag) brings the object back — with every transform and property it had.

func _rm(author: String, seq: int, target: String, undone: bool = false) -> Dictionary:
	return {"author": author, "seq": seq, "kind": "object.remove", "undone": undone, "target": target}


func _remove_section() -> void:
	print("\n  -- object.remove --")
	var objs := SceneObjects.new()
	root.add_child(objs)
	var base: Array = [_rec("a", 1, "marker", Vector3.ZERO), _rec("a", 2, "light.point", Vector3.ONE),
		_sp("a", 3, "a:2", {"energy": 7.0})]
	objs.apply_journal(base)
	var st: Dictionary = objs.apply_journal(base + [_rm("a", 4, "a:1")])
	_check("a remove takes the object out", objs.node_for("a:1") == null and objs.count() == 1, "%d left" % objs.count())
	_check("and is counted as applied", int(st["applied"]) == 4, "applied %d" % int(st["applied"]))
	for i in 3:
		objs.apply_journal(base + [_rm("a", 4, "a:1")])
	_check("re-folding a remove changes nothing", objs.count() == 1, "%d" % objs.count())
	objs.apply_journal(base + [_rm("a", 4, "a:1", true)])
	_check("undoing the remove brings it back", objs.node_for("a:1") != null and objs.count() == 2, "%d" % objs.count())
	objs.apply_journal(base + [_rm("a", 5, "a:2")])
	objs.apply_journal(base + [_rm("a", 5, "a:2", true)])
	var l := objs.node_for("a:2") as OmniLight3D
	_check("a removed-then-restored light keeps its properties", l != null and is_equal_approx(l.light_energy, 7.0),
		str(l.light_energy) if l != null else "gone")
	st = objs.apply_journal(base + [_rm("a", 6, "ghost:9")])
	_check("a remove naming an unknown object is skipped", int(st["skipped"]) == 1, "skipped %d" % int(st["skipped"]))
	st = objs.apply_journal(base + [_rm("a", 4, "a:2"), _sp("a", 7, "a:2", {"energy": 1.0})])
	_check("a set_prop on a removed object is skipped", objs.node_for("a:2") == null and int(st["skipped"]) == 1,
		"skipped %d" % int(st["skipped"]))
	objs.queue_free()


func _block_mat() -> StandardMaterial3D:
	return load("res://scenes/prefabs/bevelled_block.gd").shared_material()


func _set_prop_section() -> void:
	print("\n  -- object.set_prop --")
	_check("SceneObjects claims object.set_prop", SceneObjects.handles("object.set_prop"), "")

	# ---- the schema: what each prefab exposes (user, 2026-10-04) ----------
	var want := {
		"light.point": ["color", "energy", "range", "shadows"],
		"light.spot": ["color", "energy", "range", "shadows", "angle"],
		"block.bevelled": ["tint"],
		"marker": ["tint"],
		"teleporter": ["label", "destination"],
		"model": ["asset", "name", "collision", "scale"],
	}
	for id: String in want:
		var got: Array = []
		for s: Dictionary in PrefabCatalogue.props_of(id):
			got.append(String(s["id"]))
		_check("%s exposes %s" % [id, str(want[id])], got == want[id], str(got))
	_check("teleporter does NOT expose target_map",
		PrefabCatalogue.prop_spec("teleporter", "target_map").is_empty(), "a peer could aim it at any scene")

	# ---- defaults ARE the prefab's values ---------------------------------
	# The fold resets to the schema's defaults, so a default that disagreed with the
	# .tscn would change every existing object the first time it folded.
	for id: String in want:
		var node: Node = PrefabCatalogue.instantiate(id)
		root.add_child(node)
		for s: Dictionary in PrefabCatalogue.props_of(id):
			var live: Variant = SceneObjects.read_prop(node, s)
			_check("%s.%s default matches the prefab" % [id, s["id"]],
				SceneObjects.same_value(live, s["default"]), "%s vs %s" % [str(live), str(s["default"])])
		node.queue_free()

	# ---- fold ---------------------------------------------------------------
	var objs := SceneObjects.new()
	root.add_child(objs)
	var base: Array = [_rec("a", 1, "light.point", Vector3.ZERO), _rec("a", 2, "light.spot", Vector3.ONE),
		_rec("a", 3, "block.bevelled", Vector3(20, 0, 0)), _rec("a", 4, "block.bevelled", Vector3(40, 0, 0)),
		_rec("a", 5, "marker", Vector3(0, 5, 0)), _rec("a", 6, "marker", Vector3(0, 6, 0)),
		_rec("a", 7, "teleporter", Vector3(0, 0, 9))]
	objs.apply_journal(base)
	var pl := objs.node_for("a:1") as OmniLight3D
	var sl := objs.node_for("a:2") as SpotLight3D

	var st: Dictionary = objs.apply_journal(base + [_sp("a", 10, "a:1", {"energy": 5.0})])
	_check("set_prop applies", pl != null and is_equal_approx(pl.light_energy, 5.0),
		"energy %s" % (str(pl.light_energy) if pl != null else "?"))
	_check("and is counted as applied", int(st["applied"]) == base.size() + 1, "applied %d" % int(st["applied"]))
	_check("props() reports the effective value", is_equal_approx(float(objs.props("a:1").get("energy", -1)), 5.0),
		str(objs.props("a:1")))
	_check("and the defaults for the rest", is_equal_approx(float(objs.props("a:1").get("range", -1)), 8.0),
		str(objs.props("a:1").get("range")))

	# Per-property: an earlier colour and a later energy BOTH hold.
	var two: Array = base + [_sp("b", 11, "a:1", {"color": [1.0, 0.0, 0.0]}), _sp("a", 12, "a:1", {"energy": 3.0})]
	objs.apply_journal(two)
	_check("a later write to ANOTHER prop does not clobber this one",
		pl.light_color.is_equal_approx(Color(1, 0, 0)) and is_equal_approx(pl.light_energy, 3.0),
		"%s / %s" % [_color_of(pl.light_color), str(pl.light_energy)])
	# Same prop: the later (seq, author) wins, whatever order they arrive in.
	var race: Array = base + [_sp("z", 14, "a:1", {"range": 20.0}), _sp("a", 13, "a:1", {"range": 12.0})]
	objs.apply_journal(race)
	_check("same prop: the later (seq, author) wins, not arrival", is_equal_approx(pl.omni_range, 20.0),
		str(pl.omni_range))

	# Re-folding is idempotent.
	for i in 3:
		objs.apply_journal(race)
	_check("re-folding changes nothing", is_equal_approx(pl.omni_range, 20.0) and objs.count() == base.size(),
		"%s, %d objects" % [str(pl.omni_range), objs.count()])

	# Undo falls back to the earlier write, then to the default.
	objs.apply_journal(base + [_sp("a", 13, "a:1", {"range": 12.0}), _sp("z", 14, "a:1", {"range": 20.0}, true)])
	_check("undoing the later write falls back to the earlier", is_equal_approx(pl.omni_range, 12.0),
		str(pl.omni_range))
	objs.apply_journal(base)
	_check("undoing every write returns the prefab default",
		is_equal_approx(pl.omni_range, 8.0) and is_equal_approx(pl.light_energy, 2.0)
		and pl.light_color.is_equal_approx(Color(1, 0.92, 0.78)),
		"range %s energy %s colour %s" % [str(pl.omni_range), str(pl.light_energy), _color_of(pl.light_color)])

	# ---- values from a peer are untrusted -----------------------------------
	st = objs.apply_journal(base + [_sp("a", 20, "a:1", {"energy": 1.0e9, "color": [5.0, -1.0, 0.5]})])
	var e_max: float = float(PrefabCatalogue.prop_spec("light.point", "energy")["max"])
	_check("an out-of-range number is CLAMPED", is_equal_approx(pl.light_energy, e_max), str(pl.light_energy))
	_check("and so is a colour", pl.light_color.is_equal_approx(Color(1, 0, 0.5)), _color_of(pl.light_color))
	st = objs.apply_journal(base + [_sp("a", 21, "a:1", {"script": "res://evil.gd", "energy": 4.0})])
	_check("a prop not in the schema is NOT applied", pl.get_script() == null, "script untouched")
	_check("but the valid prop in the same record is", is_equal_approx(pl.light_energy, 4.0), str(pl.light_energy))
	_check("and the refusal is counted", int(st.get("rejected_props", 0)) == 1,
		"rejected_props %d" % int(st.get("rejected_props", 0)))
	st = objs.apply_journal(base + [_sp("a", 22, "a:1", {"energy": "lots"})])
	_check("a value of the wrong kind is refused", is_equal_approx(pl.light_energy, 2.0)
		and int(st.get("rejected_props", 0)) == 1, "energy %s" % str(pl.light_energy))
	_check("a record with nothing valid in it is skipped", int(st["skipped"]) == 1, "skipped %d" % int(st["skipped"]))
	_check("the spot light's angle is not a point-light prop",
		int(objs.apply_journal(base + [_sp("a", 23, "a:1", {"angle": 10.0})]).get("rejected_props", 0)) == 1, "")
	st = objs.apply_journal(base + [_sp("a", 24, "nobody:9", {"energy": 1.0})])
	_check("a set_prop naming an unknown object is skipped", int(st["skipped"]) == 1, "skipped %d" % int(st["skipped"]))

	# ---- every kind reaches the node ----------------------------------------
	objs.apply_journal(base + [
		_sp("a", 30, "a:2", {"angle": 20.0, "shadows": false}),
		_sp("a", 31, "a:3", {"tint": [0.2, 0.4, 0.8]}),
		_sp("a", 32, "a:4", {"tint": [0.2, 0.4, 0.8]}),
		_sp("a", 33, "a:5", {"tint": [0.0, 1.0, 0.0]}),
		_sp("a", 34, "a:7", {"label": "Up top", "destination": [1.0, 2.0, 3.0]}),
	])
	_check("spot: angle + shadows off", is_equal_approx(sl.spot_angle, 20.0) and not sl.shadow_enabled,
		"%s / %s" % [str(sl.spot_angle), str(sl.shadow_enabled)])
	var m3 := _tint_mat(objs.node_for("a:3"), "Mesh")
	var m4 := _tint_mat(objs.node_for("a:4"), "Mesh")
	_check("block tint", m3 != null and m3.albedo_color.is_equal_approx(Color(0.2, 0.4, 0.8)),
		_color_of(m3.albedo_color) if m3 != null else "?")
	_check("two blocks of one colour SHARE a material (batching)", m3 != null and m3 == m4, "same instance")
	_check("a tint does not change the shared default material",
		_block_mat().albedo_color.is_equal_approx(Color(0.72, 0.68, 0.60)),
		_color_of(_block_mat().albedo_color))
	var mk5 := _tint_mat(objs.node_for("a:5"), ".")
	var mk6 := _tint_mat(objs.node_for("a:6"), ".")
	_check("marker tint", mk5 != null and mk5.albedo_color.is_equal_approx(Color(0, 1, 0)),
		_color_of(mk5.albedo_color) if mk5 != null else "?")
	_check("and the OTHER marker keeps its colour", mk6 != null and mk6.albedo_color.is_equal_approx(Color(1, 0.7, 0.2)),
		_color_of(mk6.albedo_color) if mk6 != null else "?")
	var tp: Node = objs.node_for("a:7")
	_check("teleporter destination", (tp.get("destination") as Vector3).is_equal_approx(Vector3(1, 2, 3)),
		str(tp.get("destination")))
	_check("teleporter label reaches the Label3D", String((tp.get_node("Label3D") as Label3D).text) == "Up top",
		String((tp.get_node("Label3D") as Label3D).text))
	objs.apply_journal(base)
	_check("undo puts the block back on the shared material",
		_tint_mat(objs.node_for("a:3"), "Mesh") == _block_mat(), "shared")
	_check("and the label back to the default", String((tp.get_node("Label3D") as Label3D).text) == "Teleport",
		String((tp.get_node("Label3D") as Label3D).text))
	# ---- the model's two new kinds ------------------------------------------
	var hs := PrefabCatalogue.prop_spec("model", "asset")
	_check("hash: 64 lower hex passes", PrefabCatalogue.sanitize(hs, "ab".repeat(32)) == "ab".repeat(32), "")
	_check("hash: upper-case / short / path refused",
		PrefabCatalogue.sanitize(hs, "AB".repeat(32)) == null and PrefabCatalogue.sanitize(hs, "abc") == null
		and PrefabCatalogue.sanitize(hs, "../../" + "a".repeat(58)) == null, "becomes a file name")
	var es := PrefabCatalogue.prop_spec("model", "collision")
	_check("enum: a listed option passes", PrefabCatalogue.sanitize(es, "trimesh") == "trimesh", "")
	_check("enum: anything else refused", PrefabCatalogue.sanitize(es, "rigid") == null
		and PrefabCatalogue.sanitize(es, 2) == null, "")
	_check("the asset is not offered in the panel", not bool(hs.get("panel", true)), "set by import only")

	var long_label := "x".repeat(500)
	objs.apply_journal(base + [_sp("a", 40, "a:7", {"label": long_label})])
	_check("a label is length-capped", String(tp.get("label_text")).length() <= 64,
		"%d chars" % String(tp.get("label_text")).length())