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)