class_name KeetTileIndex
extends Node
## Which terrain tiles exist, who is making one, and whose bytes win.
##
## Spec: spec/tile-index-and-leases.md. Read that first — the rules here are
## consequences of one measured fact, not preferences.
##
## THE FACT: CPU and CUDA produce DIFFERENT BYTES for the same (seed, tx, ty) —
## worst 5.00 m, rmse ~1.0 m. Determinism holds PER DEVICE, so a content hash
## cannot verify somebody else's regeneration. Therefore the INDEX is
## authoritative and the first publisher DEFINES a tile; everyone else fetches.
## A hash is an ADDRESS, not a proof. Peers need no matching hardware, dtype or
## pipeline version to share a world.
##
## WHERE THINGS LIVE, and why they are split:
##
## LEASES -> StateBase (room metadata Autobase, via KeetGameState).
## Few at a time, transient, role-gated by room membership. Coordination
## is what StateBase is for, and its existing admin/mod/peer roles are
## already the "only generator peers may write" rule the spec wanted — no
## separate autobase writer admission needed.
##
## THE INDEX -> a manifest blob in hyper-overlay; StateBase holds only the
## POINTER (one key). A 900 km world is 14,161 tiles; writing that many
## keys into a room's metadata autobase, and polling them every 3 s, would
## abuse a store built for small coordinated state. One pointer costs one
## key and the manifest is ~2 MB of content-addressed bytes.
##
## PER ROOM BY CONSTRUCTION. The index hangs off room state, so two rooms are
## two worlds with no extra machinery — which is what makes "some rooms may want
## to generate different tiles" fall out for free rather than needing a design.
const KEY_MANIFEST := "tileindex" # -> {seed: oid} pointer map
const KEY_LEASE_PREFIX := "tilelease:" # -> "<peer>|<expires_unix>"
## How long a claim holds before anyone may take the tile. Deliberately short:
## a lease is ADVISORY. Losing one, duplicating one, or letting one expire early
## costs wasted CPU and nothing else, because duplicate generation is WASTE, NOT
## CONFLICT. That is what lets this layer stay simple and lossy.
const LEASE_SECONDS := 3600
signal manifest_changed(seed: int)
signal tile_published(seed: int, tx: int, ty: int, oid: String)
var state: Node ## KeetGameState
var _manifest: Dictionary = {} ## seed -> { "tx,ty": {oid, bytes, device, pipeline} }
var _manifest_oid: Dictionary = {} ## seed -> oid of the published manifest
func _key(tx: int, ty: int) -> String:
return "%d,%d" % [tx, ty]
## Has anyone published this tile? Returns the record or {}.
func lookup(seed: int, tx: int, ty: int) -> Dictionary:
var m: Dictionary = _manifest.get(seed, {})
return m.get(_key(tx, ty), {})
## THE CONFLICT RULE: lowest oid wins, deterministically.
##
## NOT earliest timestamp. Clocks are not trustworthy across peers, and a
## timestamp rule does not converge without coordination — two peers can each
## believe theirs was first. Comparing oids converges on every peer
## independently, with no messages and no clock.
func _wins(challenger: String, incumbent: String) -> bool:
return challenger < incumbent
## Merge a record in. Returns true if it changed our view.
func merge(seed: int, tx: int, ty: int, rec: Dictionary) -> bool:
if not rec.has("oid") or String(rec["oid"]).is_empty():
return false
if not _manifest.has(seed):
_manifest[seed] = {}
var m: Dictionary = _manifest[seed]
var k := _key(tx, ty)
if not m.has(k):
m[k] = rec
tile_published.emit(seed, tx, ty, String(rec["oid"]))
return true
var cur: String = String(m[k].get("oid", ""))
var inc: String = String(rec["oid"])
if inc == cur:
return false
if _wins(inc, cur):
m[k] = rec
tile_published.emit(seed, tx, ty, inc)
return true
return false
## The oid a peer should RENDER for this tile.
##
## Always the indexed one, even for a tile this peer generated itself. A peer
## that renders its own bytes sees its own terrain until the index converges and
## then watches the ground change under it. Returns "" when nothing is indexed,
## which is the only case where local bytes are the right answer.
func render_oid(seed: int, tx: int, ty: int) -> String:
return String(lookup(seed, tx, ty).get("oid", ""))
## Is this tile claimed by someone else right now?
func is_leased(seed: int, tx: int, ty: int, me: String, now_unix: int) -> bool:
if state == null:
return false
var raw: Variant = state.get_value(_lease_key(seed, tx, ty), null)
if raw == null:
return false
var parts := String(raw).split("|")
if parts.size() != 2:
return false # malformed lease is not a lease
if parts[0] == me:
return false # our own claim never blocks us
return int(parts[1]) > now_unix
func _lease_key(seed: int, tx: int, ty: int) -> String:
return "%s%d:%d:%d" % [KEY_LEASE_PREFIX, seed, tx, ty]
## Claim a tile. Best effort — see LEASE_SECONDS.
func claim(seed: int, tx: int, ty: int, me: String, now_unix: int) -> bool:
if state == null or not state.can_write():
return false
if is_leased(seed, tx, ty, me, now_unix):
return false
if not lookup(seed, tx, ty).is_empty():
return false # already published; nothing to generate
state.set_value(_lease_key(seed, tx, ty),
"%s|%d" % [me, now_unix + LEASE_SECONDS])
return true
func release(seed: int, tx: int, ty: int) -> void:
if state == null or not state.can_write():
return
state.erase_value(_lease_key(seed, tx, ty))
## Publish generated bytes. The caller has already put them in the overlay and
## holds the oid.
func publish(seed: int, tx: int, ty: int, oid: String, bytes: int,
device: String, pipeline: String) -> bool:
var changed := merge(seed, tx, ty, {
"oid": oid, "bytes": bytes,
# Recorded, NOT enforced. Provenance makes a mixed-device world legible
# — and lets a future re-bake find every tile from a given pipeline —
# without making it a correctness condition.
"device": device, "pipeline": pipeline,
})
release(seed, tx, ty)
if changed:
manifest_changed.emit(seed)
return changed
## Adopt a manifest fetched from the overlay (every entry merged by the rule
## above, so adopting someone else's manifest can never silently overwrite a
## tile whose oid wins).
func adopt_manifest(seed: int, entries: Dictionary) -> int:
var n := 0
for k in entries.keys():
var p := String(k).split(",")
if p.size() != 2:
continue
if merge(seed, int(p[0]), int(p[1]), entries[k]):
n += 1
if n > 0:
manifest_changed.emit(seed)
return n
func manifest_for(seed: int) -> Dictionary:
return _manifest.get(seed, {})
func tile_count(seed: int) -> int:
return manifest_for(seed).size()
## Next tile worth generating, nearest-first from a centre, skipping published
## and leased ones. Nearest-first because the tiles a player is about to reach
## are the ones worth spending CPU on.
##
## TIES BREAK BY SCAN ORDER and that is fine: two peers scanning the same region
## pick the same tile, the first to claim wins, and the second sees the lease and
## moves on. Distributing work by randomising the tie would only trade a cheap
## lease check for a harder-to-reproduce selection.
func next_unclaimed(seed: int, cx: int, cy: int, radius: int, me: String,
now_unix: int) -> Vector2i:
var best := Vector2i(0, 0)
var best_d := 1 << 30
var found := false
for dy in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
var tx := cx + dx
var ty := cy + dy
if not lookup(seed, tx, ty).is_empty():
continue
if is_leased(seed, tx, ty, me, now_unix):
continue
var d := dx * dx + dy * dy
if d < best_d:
best_d = d
best = Vector2i(tx, ty)
found = true
return best if found else Vector2i(1 << 30, 1 << 30)
# =============================================================================
# MANIFEST TRANSPORT — keet room files, not hyper-overlay
# =============================================================================
#
# ⚠ THE SPEC SAID hyper-overlay AND THAT WAS WRONG FOR RUNTIME. hyper-overlay is
# the GIT-TIME asset path: `.gitattributes` routes *.hblk through it so a 33 MB
# block costs git a 2.5 KB pointer, and `pgh overlay sync` populates a checkout.
# It is driven by pgh-sidecar and has no API the game can call while running.
#
# keet-daemon DOES have one — file.sendFromPath / file.path / file.download /
# file.list — over the same UDS client the rest of this addon uses, and it is
# per-ROOM, which is the same scope the index already has. So the manifest rides
# the room's own file store.
#
# THE INDIRECTION IS THE POINT. StateBase holds ONE key (a fileId); the manifest
# holds all 14,161 entries. Writing that many keys into a room's metadata
# autobase, and polling them every 3 s, would abuse a store built for small
# coordinated state.
const MANIFEST_VERSION := 1
var client: Node ## KeetUdsClient, for file.* RPCs
var room_id: String = ""
## Serialise for transport. JSON rather than var_to_str: the manifest crosses
## machines and may outlive this Godot version, and var_to_str is neither.
func serialise(seed: int) -> String:
return JSON.stringify({
"v": MANIFEST_VERSION,
"seed": seed,
"tiles": manifest_for(seed),
})
## Parse and merge. Returns entries adopted, or -1 if the text is unusable.
##
## Merged rather than replaced, so a manifest from a peer can never clobber a
## tile whose oid wins here — the convergence rule applies to bulk adoption
## exactly as it does to a single record.
func deserialise_into(text: String) -> int:
var parsed: Variant = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
return -1
var d: Dictionary = parsed
if int(d.get("v", 0)) != MANIFEST_VERSION:
return -1 # refuse rather than guess at an old shape
if not d.has("seed") or not d.has("tiles"):
return -1
if typeof(d["tiles"]) != TYPE_DICTIONARY:
return -1
return adopt_manifest(int(d["seed"]), d["tiles"])
func _manifest_pointer_key() -> String:
return KEY_MANIFEST
## Publish the manifest and point StateBase at it.
##
## ORDER MATTERS: the file is sent FIRST and the pointer updated only on
## success. The reverse would leave peers holding a fileId for bytes that were
## never sent — a dangling pointer is worse than a stale one, because a stale
## pointer still resolves to a valid older manifest.
func publish_manifest(seed: int, callback: Callable = Callable()) -> void:
if client == null or room_id.is_empty() or state == null:
if callback.is_valid(): callback.call(false, "no client/room/state")
return
if not state.can_write():
if callback.is_valid(): callback.call(false, "role cannot write")
return
var tmp := "user://tileindex_%d.json" % seed
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
if callback.is_valid(): callback.call(false, "cannot open " + tmp)
return
f.store_string(serialise(seed))
f.close()
client.call_rpc("file.sendFromPath", {
"roomId": room_id,
"filePath": ProjectSettings.globalize_path(tmp),
"fileName": "tileindex_%d.json" % seed,
"mimeType": "application/json",
}, func(result: Variant, err: Variant) -> void:
if err != null:
if callback.is_valid(): callback.call(false, str(err))
return
var file_id := _extract_file_id(result)
if file_id.is_empty():
if callback.is_valid(): callback.call(false, "no fileId in result")
return
var ptr: Dictionary = state.get_value(_manifest_pointer_key(), {})
if typeof(ptr) != TYPE_DICTIONARY:
ptr = {}
ptr[str(seed)] = file_id
_manifest_oid[seed] = file_id
state.set_value(_manifest_pointer_key(), ptr)
if callback.is_valid(): callback.call(true, file_id)
)
## keet-core's sendFile result shape is not pinned in the daemon's contract, so
## accept the plausible spellings rather than guessing one and failing silently.
func _extract_file_id(result: Variant) -> String:
if typeof(result) == TYPE_STRING:
return String(result)
if typeof(result) != TYPE_DICTIONARY:
return ""
var d: Dictionary = result
for k in ["fileId", "id", "key", "hash", "driveKey"]:
if d.has(k) and not String(d[k]).is_empty():
return String(d[k])
if d.has("file") and typeof(d["file"]) == TYPE_DICTIONARY:
return _extract_file_id(d["file"])
return ""
## Fetch whatever manifest StateBase points at and merge it in.
func load_manifest(seed: int, callback: Callable = Callable()) -> void:
if client == null or room_id.is_empty() or state == null:
if callback.is_valid(): callback.call(false, "no client/room/state")
return
var ptr: Variant = state.get_value(_manifest_pointer_key(), {})
if typeof(ptr) != TYPE_DICTIONARY or not ptr.has(str(seed)):
if callback.is_valid(): callback.call(false, "no manifest published")
return
var file_id := String(ptr[str(seed)])
client.call_rpc("file.path", {"roomId": room_id, "fileId": file_id},
func(result: Variant, err: Variant) -> void:
if err != null or typeof(result) != TYPE_DICTIONARY:
if callback.is_valid(): callback.call(false, "file.path: " + str(err))
return
var d: Dictionary = result
# `exists` false means the entry is known but the bytes are not here
# yet — that is a download, not an error.
if not bool(d.get("exists", false)):
client.call_rpc("file.download", {"roomId": room_id, "fileId": file_id},
func(_r: Variant, derr: Variant) -> void:
if derr != null:
if callback.is_valid(): callback.call(false, "download: " + str(derr))
return
_read_and_adopt(String(d.get("path", "")), callback)
)
return
_read_and_adopt(String(d.get("path", "")), callback)
)
func _read_and_adopt(path: String, callback: Callable) -> void:
if path.is_empty():
if callback.is_valid(): callback.call(false, "no path")
return
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
if callback.is_valid(): callback.call(false, "cannot read " + path)
return
var n := deserialise_into(f.get_as_text())
f.close()
if n < 0:
if callback.is_valid(): callback.call(false, "unusable manifest")
return
if callback.is_valid(): callback.call(true, n)