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)