OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_tile_index.gd

164 lines · gd ref:
extends SceneTree
## GATE: do the tile-index rules behave as spec/tile-index-and-leases.md says?
##
## Runs with NO daemon and NO room — a fake state backend stands in for
## KeetGameState, so the RULES are testable on their own. That matters because
## the rules are the part that has to be right: the transport is already built
## (hyper-overlay is content-addressed and mirror-spool already makes a second
## peer a real source), and it is the convergence logic that is new.
##
## EVERY CHECK HAS A CONTROL that must FAIL. A gate that only asserts the happy
## path cannot tell a working rule from a stubbed one.
##
##   godot --headless --path . --script res://scenes/test/gate_tile_index.gd

const SEED := 1234


class FakeState extends Node:
	## Stands in for KeetGameState: a dictionary with the same four calls.
	var kv: Dictionary = {}
	var writable: bool = true
	func can_write() -> bool: return writable
	func get_value(k: String, d: Variant = null) -> Variant: return kv.get(k, d)
	func set_value(k: String, v: Variant, _cb: Callable = Callable()) -> void: kv[k] = v
	func erase_value(k: String, _cb: Callable = Callable()) -> void: kv.erase(k)


var _fails: int = 0


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


func _init() -> void:
	var idx := KeetTileIndex.new()
	var st := FakeState.new()
	idx.state = st
	var now := 1_000_000

	print("\n1. FIRST PUBLISHER DEFINES THE TILE")
	ck("publish is accepted", idx.publish(SEED, 0, 0, "bbb", 128, "cuda", "v1"), true)
	ck("lookup returns it", idx.render_oid(SEED, 0, 0), "bbb")

	print("\n2. CONFLICTS RESOLVE BY LOWEST OID, NOT ARRIVAL ORDER")
	# 'aaa' arrives SECOND and must still win — that is the whole point. A rule
	# keyed on arrival or timestamp would keep 'bbb' and diverge between peers
	# who saw them in the other order.
	ck("lower oid arriving later wins", idx.merge(SEED, 0, 0, {"oid": "aaa"}), true)
	ck("  -> index holds it", idx.render_oid(SEED, 0, 0), "aaa")
	ck("higher oid is rejected", idx.merge(SEED, 0, 0, {"oid": "zzz"}), false)
	ck("  -> index unchanged", idx.render_oid(SEED, 0, 0), "aaa")
	ck("re-merging the winner is a no-op", idx.merge(SEED, 0, 0, {"oid": "aaa"}), false)

	print("\n   CONTROL: order-dependence would show as a different outcome")
	var b := KeetTileIndex.new()
	b.state = st
	b.merge(SEED, 0, 0, {"oid": "aaa"})
	b.merge(SEED, 0, 0, {"oid": "bbb"})
	b.merge(SEED, 0, 0, {"oid": "zzz"})
	ck("reverse arrival order converges the same", b.render_oid(SEED, 0, 0), "aaa")

	print("\n3. A PEER RENDERS THE INDEX, NOT ITS OWN BYTES")
	idx.merge(SEED, 5, 5, {"oid": "mmm"})
	ck("indexed tile renders the indexed oid", idx.render_oid(SEED, 5, 5), "mmm")
	ck("unindexed tile renders nothing", idx.render_oid(SEED, 9, 9), "")

	print("\n4. LEASES BLOCK OTHERS, NOT YOURSELF, AND EXPIRE")
	ck("claim succeeds", idx.claim(SEED, 1, 0, "peerA", now), true)
	ck("blocks another peer", idx.is_leased(SEED, 1, 0, "peerB", now), true)
	ck("does not block the holder", idx.is_leased(SEED, 1, 0, "peerA", now), false)
	ck("expires", idx.is_leased(SEED, 1, 0, "peerB", now + idx.LEASE_SECONDS + 1), false)
	ck("cannot claim a published tile", idx.claim(SEED, 0, 0, "peerA", now), false)
	ck("publish releases the lease",
		idx.publish(SEED, 1, 0, "ccc", 128, "cpu", "v1") and not idx.is_leased(SEED, 1, 0, "peerB", now), true)

	print("\n   CONTROL: a read-only peer must not be able to claim")
	st.writable = false
	ck("claim refused without write role", idx.claim(SEED, 2, 0, "peerA", now), false)
	st.writable = true

	print("\n5. WORK SELECTION IS NEAREST-FIRST AND SKIPS TAKEN TILES")
	var fresh := KeetTileIndex.new()
	fresh.state = st
	st.kv.clear()
	fresh.merge(SEED, 0, 0, {"oid": "aaa"})          # published, skip
	fresh.claim(SEED, 1, 0, "other", now)            # leased by someone else
	# ASSERT THE PROPERTY, NOT A COORDINATE. (0,-1) and (0,1) are both at
	# distance 1, so demanding one of them tested the scan order rather than the
	# rule. This asks what actually matters: the pick is at the minimum
	# available distance, and is neither published nor leased.
	var pick: Vector2i = fresh.next_unclaimed(SEED, 0, 0, 2, "me", now)
	var d2: int = pick.x * pick.x + pick.y * pick.y
	ck("pick is at minimum available distance", d2, 1)
	ck("pick is not the published tile", pick == Vector2i(0, 0), false)
	ck("pick is not the leased tile", pick == Vector2i(1, 0), false)
	ck("exhausted region reports none",
		fresh.next_unclaimed(SEED, 0, 0, 0, "me", now).x, 1 << 30)

	print("\n6. ADOPTING A PEER'S MANIFEST CANNOT CLOBBER A WINNING OID")
	var t := KeetTileIndex.new()
	t.state = st
	t.merge(SEED, 3, 3, {"oid": "aaa"})
	var adopted := t.adopt_manifest(SEED, {"3,3": {"oid": "zzz"}, "4,4": {"oid": "kkk"}})
	ck("only the genuinely new entry is adopted", adopted, 1)
	ck("the winning oid survives adoption", t.render_oid(SEED, 3, 3), "aaa")
	ck("the new one lands", t.render_oid(SEED, 4, 4), "kkk")

	print("\n7. MANIFEST SERIALISE / DESERIALISE ROUND-TRIP")
	var m := KeetTileIndex.new()
	m.state = st
	m.merge(SEED, 0, 0, {"oid": "aaa", "bytes": 128, "device": "cpu", "pipeline": "v1"})
	m.merge(SEED, 7, 3, {"oid": "bbb", "bytes": 128, "device": "cuda", "pipeline": "v1"})
	var text := m.serialise(SEED)

	var r := KeetTileIndex.new()
	r.state = st
	ck("round-trip adopts both entries", r.deserialise_into(text), 2)
	ck("  oid survives", r.render_oid(SEED, 7, 3), "bbb")
	ck("  provenance survives", String(r.lookup(SEED, 0, 0).get("device", "")), "cpu")

	print("\n   CONTROL: unusable manifests must be REFUSED, not half-adopted")
	print("   (Godot logs a JSON parse error below — that is the point)")
	ck("garbage text", r.deserialise_into("not json at all"), -1)
	ck("wrong version", r.deserialise_into('{"v":99,"seed":1,"tiles":{}}'), -1)
	ck("missing tiles", r.deserialise_into('{"v":1,"seed":1}'), -1)
	ck("tiles not a dict", r.deserialise_into('{"v":1,"seed":1,"tiles":[]}'), -1)

	print("\n8. ADOPTING A MANIFEST STILL OBEYS THE CONFLICT RULE")
	# A peer's whole manifest must not be able to clobber a winning oid, exactly
	# as a single record cannot. Bulk is not a loophole.
	var w := KeetTileIndex.new()
	w.state = st
	w.merge(SEED, 7, 3, {"oid": "aaa"})            # ours wins on oid
	ck("bulk adoption cannot clobber", w.deserialise_into(text), 1)
	ck("  winner survives bulk adopt", w.render_oid(SEED, 7, 3), "aaa")
	ck("  the other entry still lands", w.render_oid(SEED, 0, 0), "aaa")

	print("\n9. MANIFEST PUBLISH REFUSES WITHOUT CLIENT/ROOM")
	# The transport calls are async and need a daemon, so what is testable here
	# is that the guards fire rather than the RPC being attempted with nulls.
	# ⚠ GDScript lambdas capture locals BY VALUE. Writing to a captured `var`
	# from inside the callback mutates the lambda's copy and the outer local
	# never changes — which made these two checks report a failure that was
	# entirely in the test. An Array is a reference, so it carries the result out.
	var pub := [false]
	m.publish_manifest(SEED, func(ok: bool, _why: Variant) -> void: pub[0] = not ok)
	ck("publish without a client is refused", pub[0], true)
	var lod := [false]
	m.load_manifest(SEED, func(ok: bool, _why: Variant) -> void: lod[0] = not ok)
	ck("load without a client is refused", lod[0], true)

	print("")
	if _fails > 0:
		print("gate_tile_index: %d FAILED" % _fails)
		quit(1)
	else:
		print("gate_tile_index: all checks passed")
		quit(0)