OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_asset_sync.gd

170 lines · gd ref:
extends SceneTree
## GATE: does AssetSync apply the RIGHT registry rule to each asset lifecycle?
##
## Runs with NO daemon, NO room and NO mesh. Fakes stand in for KeetGameState and
## ContentOverlay, because the TRANSPORT is already built and verified (#14 344
## assertions, #19 arbitrary file types, #22) while the CATALOGUE rules are new
## and are the part that can silently diverge.
##
## THE RULE UNDER TEST (spec/asset-sync.md 2.1), and it is two rules:
##
##   world:assets:<kind>:<name>       MUTABLE   -> newest wins  (StateBase LWW)
##   world:assets:<kind>:<id>:<oid>   IMMUTABLE -> lowest oid   (read-side)
##
## Getting these backwards is a convergence bug, not a style choice: a tile keyed
## the mutable way lets a peer with a skewed clock flip which terrain everyone
## sees, because two devices produce DIFFERENT bytes for the same (tx,ty).
##
## EVERY CHECK HAS A CONTROL THAT MUST FAIL.
##
##   "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_asset_sync.gd


class FakeState extends Node:
	## KeetGameState's shape, plus LWW-by-ts exactly as state-base.js:578-582.
	signal state_changed(key: String, value: Variant, channel: int)
	var kv: Dictionary = {}
	var ts: Dictionary = {}
	var clock: int = 1000

	func set_state(k: String, v: Variant) -> void:
		# LWW: an older write must not clobber a newer value already in the view.
		if ts.has(k) and int(ts[k]) > clock:
			return
		kv[k] = v
		ts[k] = clock
		state_changed.emit(k, v, 0)

	func get_state(k: String, d: Variant = null) -> Variant:
		return kv.get(k, d)

	func keys_with_prefix(p: String) -> Array:
		var out: Array = []
		for k in kv.keys():
			if String(k).begins_with(p):
				out.append(k)
		out.sort()
		return out


class FakeOverlay extends Node:
	## ContentOverlay's shape. Hashes by CONTENT so two peers publishing the same
	## bytes agree, and different bytes disagree — the property the rules rest on.
	signal content_ready(content_hash: String, path: String)
	var bytes: Dictionary = {}          # oid -> content
	var local: Dictionary = {}          # oid -> path we hold
	var ready_flag: bool = true

	func is_ready() -> bool: return ready_flag

	func publish_bytes(content: String) -> String:
		var oid := content.sha256_text()
		bytes[oid] = content
		local[oid] = "user://content/%s.bin" % oid
		return oid

	func has(oid: String) -> bool: return local.has(oid)
	func path_of(oid: String) -> String: return String(local.get(oid, ""))

	func request(oid: String, _name: String) -> String:
		if local.has(oid):
			return String(local[oid])
		return ""


var _fails: int = 0


func ck(name: String, got: Variant, want: Variant) -> void:
	if str(got) == str(want):
		print("  ok    %-52s %s" % [name, str(got)])
	else:
		print("  FAIL  %-52s got %s want %s" % [name, str(got), str(want)])
		_fails += 1


func _init() -> void:
	var st := FakeState.new()
	var ov := FakeOverlay.new()
	var a = load("res://autoloads/asset_sync.gd").new()   # not a global class — see the script header
	a.state = st
	a.overlay = ov

	print("\n1. MUTABLE NAMED ASSET — NEWEST WINS")
	var v1 := ov.publish_bytes("WAD version one")
	st.clock = 1000
	ck("publish v1", a.publish_asset("wad", "base.wad", v1, 15, "wad"), v1)
	ck("resolves to v1", a.resolve("wad", "base.wad"), v1)
	var v2 := ov.publish_bytes("WAD version two, re-exported")
	st.clock = 2000
	ck("publish v2 supersedes", a.publish_asset("wad", "base.wad", v2, 28, "wad"), v2)
	ck("resolves to v2", a.resolve("wad", "base.wad"), v2)
	ck("  v1 still fetchable by oid", ov.has(v1), true)

	print("\n   CONTROL: an OLDER write must NOT clobber the newer value")
	st.clock = 500
	a.publish_asset("wad", "base.wad", v1, 15, "wad")
	ck("still v2 after stale write", a.resolve("wad", "base.wad"), v2)

	print("\n2. IMMUTABLE CONTENT — LOWEST OID, REGARDLESS OF ARRIVAL ORDER")
	# Two devices, same identity, DIFFERENT bytes. This is the measured case:
	# CPU vs CUDA diverge up to 5.00 m for the same (seed,tx,ty).
	var hi := "ffff0000"
	var lo := "0000ffff"
	st.clock = 3000
	ck("publish high oid first", a.publish_immutable("tile", "0,0", hi, 128), true)
	ck("resolves to it", a.resolve_immutable("tile", "0,0"), hi)
	st.clock = 4000
	ck("lower oid arriving LATER", a.publish_immutable("tile", "0,0", lo, 128), true)
	ck("  -> lowest oid wins", a.resolve_immutable("tile", "0,0"), lo)
	ck("both entries coexist", a.list_immutable("tile", "0,0").size(), 2)

	print("\n   CONTROL: the MUTABLE rule would have kept the later one")
	st.clock = 5000
	a.publish_asset("tilewrong", "0,0", hi, 128, "bin")
	st.clock = 6000
	a.publish_asset("tilewrong", "0,0", lo, 128, "bin")
	ck("mutable shape keeps LAST not LOWEST", a.resolve("tilewrong", "0,0"), lo)
	st.clock = 7000
	a.publish_asset("tilewrong", "0,0", hi, 128, "bin")
	ck("  -> and flips with clock order", a.resolve("tilewrong", "0,0"), hi)
	ck("  -> immutable shape did NOT flip", a.resolve_immutable("tile", "0,0"), lo)

	print("\n3. ensure() RESOLVES BY NAME, NEVER BY HASH")
	ck("ensure returns a local path", a.ensure("wad", "base.wad") != "", true)
	ck("  path is oid-addressed", a.ensure("wad", "base.wad").contains(v2), true)

	print("\n   CONTROL: an unknown name must resolve to nothing")
	ck("unknown kind", a.ensure("nope", "nothing.wad"), "")
	ck("unknown name", a.ensure("wad", "absent.wad"), "")

	print("\n4. COLLISION — TWO ASSETS, SAME FILENAME, DIFFERENT KINDS")
	var other := ov.publish_bytes("a DIFFERENT base.wad from another kind")
	st.clock = 8000
	a.publish_asset("levelwad", "base.wad", other, 40, "wad")
	ck("kinds do not collide", a.resolve("levelwad", "base.wad"), other)
	ck("  original untouched", a.resolve("wad", "base.wad"), v2)
	ck("  distinct dest paths", a.ensure("levelwad", "base.wad") != a.ensure("wad", "base.wad"), true)

	print("\n5. resolve_path() — LOCAL WINS, OVERLAY IS THE FALLBACK")
	# The authoring machine has the file at an absolute path outside res://.
	# Every OTHER peer does not, and today that degrades SILENTLY to untextured.
	var here := ProjectSettings.globalize_path("res://scenes/test/gate_asset_sync.gd")
	ck("local file wins outright", a.resolve_path("wad", "base.wad", here), here)
	ck("  missing local falls back to the room",
			a.resolve_path("wad", "base.wad", "/nonexistent/nope.wad").contains(v2), true)
	ck("absent locally AND unknown to the room", a.resolve_path("wad", "ghost.wad", "/nope"), "")

	print("\n   CONTROL: a local path that does not exist must NOT be returned")
	ck("missing local is not passed through", a.resolve_path("x", "y", "/nonexistent/nope.wad"), "")

	print("\n6. NOT READY / NO OVERLAY IS NOT A CRASH")
	ov.ready_flag = false
	ck("publish declines cleanly", a.publish_asset("wad", "x.wad", v1, 1, "wad"), "")
	ck("ensure declines cleanly", a.ensure("wad", "base.wad"), "")
	ov.ready_flag = true

	print("\n%s  (%d failure%s)" % ["PASS" if _fails == 0 else "FAIL", _fails,
			"" if _fails == 1 else "s"])
	quit(1 if _fails > 0 else 0)