extends Node
## Tunable game state router — sends state through REALTIME (UDP/RPC) or
## DURABLE (keet-daemon Autobase) channels based on a per-key route table.
## Each route can require a minimum Keet room role for writes.
##
## Usage:
## GameState.route("player:pos:*", Channel.REALTIME)
## GameState.route("world:terrain:*", Channel.DURABLE, "mod")
## GameState.route("world:settings:*", Channel.DURABLE, "admin")
## GameState.set_state("player:pos:3", Vector3(1,2,3))
## GameState.set_state("world:terrain:edit:1", {...})
## GameState.promote("building:preview") # REALTIME → DURABLE
## State change notification — fires for both channels.
signal state_changed(key: String, value: Variant, channel: int)
## Fired when a durable write is rejected (wrong role / not connected).
signal write_rejected(key: String, reason: String)
## Fired when the active map changes (cold-join restore OR teleport). Subsystems
## (terrain, player position, creatures) reset their per-map caches here; the
## subsequent reload re-emits state_changed so they re-apply for the new world.
signal map_changing(map_id: String)
## The ROOM is being left, which is a strictly bigger event than a map change.
## reset_for_room_switch also emits map_changing (subsystems that clear per-map
## state must still clear it), so without this a listener cannot tell "the map
## changed" from "the whole world changed" -- and a room-scoped entity would be
## torn down by every ordinary map change. Needed by BotRegistry's `room` binding.
signal room_changing(room_id: String)
enum Channel { REALTIME = 0, DURABLE = 1, BOTH = 2 }
const HomeStarter := preload("res://scenes/maps/home_starter.gd")
## Route entry: {channel: int, role: String}
## role = minimum role required to write ("peer", "mod", "admin")
## For REALTIME-only routes, role is ignored (no persistence = no gating).
var _routes: Dictionary = {}
## Cached resolved routes (full key → {channel: int, role: String}).
var _route_cache: Dictionary = {}
## In-memory state mirrors.
var _realtime: Dictionary = {}
var _durable: Dictionary = {}
## True once the first durable room-state load has arrived (state.list returned).
## game.gd awaits this before spawning so it can restore the world (map + saved
## position) instead of spawning into the default level at the default point.
var room_state_loaded: bool = false
## Per-map state namespacing (see spec/world-system.md). Durable keys for per-map
## CONTENT are transparently stored under `map:<current_map_id>:<key>` so each
## level is a contained world (maps = pages); consumers keep using the plain key.
## current_map_id = the map scene basename (e.g. "map_dc_terrain"); set by game.gd.
var current_map_id: String = ""
## ---- THE STATE TABLE — one declaration per prefix, four decisions each ----
##
## A key's behaviour used to be spelled out in three places: fifteen route() calls
## in game.gd's _ready, and these two prefix lists. Nothing checked they agreed, and
## the failure mode of disagreeing is SILENT in both directions — an unrouted key
## resolves to _DEFAULT_ROUTE {REALTIME, "peer"}, so it propagates to whoever is in
## the mesh and is then lost forever, and a key missing from AUTHOR_SCOPED_PREFIXES
## reads as "shared, anyone may write". world:level:* had BOTH bugs at once, and
## adding world:sky: meant remembering all three lists.
##
## So: one row per prefix, and a row must decide all four questions.
##
## prefix the key prefix. The route is registered as `<prefix>*`.
## channel DURABLE (replicated + persisted) or REALTIME (mesh only, lost).
## role minimum Keet room role for a durable write; "" = default_write_role.
## map_scope "" = room-global; otherwise the prefix under which keys are stored
## as `map:<map_id>:<key>`, so each map is a contained world. Usually
## the row's own prefix — world:creatures: is the exception, covering
## two routes with one scope, which is why this is a field and not a
## flag.
## author prefixes whose next segment is the author's memberId. [] = shared
## content, gated by role alone. Finer than the route on purpose:
## world:terrain:* is routed, but only world:terrain:edit: is owned.
##
## state_routes_gate asserts every row is complete, that the derived lists still
## contain exactly what they contained when they were written by hand, and that a
## key of each kind scopes and attributes the way its row says.
const STATE_ROUTES: Array[Dictionary] = [
# Admin: world settings (map, gravity, day/night, spawn points).
{"prefix": "world:settings:", "channel": Channel.DURABLE, "role": "admin",
"map_scope": "", "author": []},
# Terrain: a peer writes its OWN edits; overriding another's requires mod+.
{"prefix": "world:terrain:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "world:terrain:", "author": ["world:terrain:edit:"]},
# ATMOSPHERE. PER ROOM AND PER MAP. It was room-global at first, on the
# reasoning that the Sky3DExtended NODE hangs off Game rather than off a map;
# that confuses the node with the setting, and it meant setting dusk in one map
# silently re-lit every other map in the room. A Quake level lit at noon and a
# procedural overworld at dusk are two worlds, not one world someone keeps
# re-grading.
#
# "peer", not the "admin" that world:settings: carries — these are editor
# controls every builder uses, not server policy. NOT author-scoped: unlike
# brushes there is one sky, and last write wins.
{"prefix": "world:sky:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "world:sky:", "author": []},
{"prefix": "world:objects:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "world:objects:", "author": ["world:objects:"]},
# THE ASSET LIBRARY (AssetSync's MUTABLE pointers world:assets:<kind>:<name> ->
# {oid, size, ext}: models, PBR textures, scripts). It had no row, so it fell
# through to {REALTIME, peer}: a model added to the library reached whoever was
# meshed and was gone on reload. Room-global, as asset_sync.gd says (a WAD is
# not part of one map). NOT author-scoped: newest publish wins (StateBase LWW),
# and anyone in the room may publish. world:tiles: (the IMMUTABLE kind) stays
# unrouted: nothing in the game publishes tiles yet.
{"prefix": "world:assets:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "", "author": []},
# HOME STARTER marker (docs/home-world-design.md §6): set once a release's starter
# map has been copied into this map at Home, so it is never copied again. Per map;
# admin, which at Home is you.
{"prefix": "world:starter:", "channel": Channel.DURABLE, "role": "admin",
"map_scope": "world:starter:", "author": []},
# Level brushes, carves and the level-source hash. Map-scoped or a level's edits
# leak into every other map in the room; author-scoped or any peer may overwrite
# any other peer's geometry. DURABLE was MISSING until 0.843.0, and its absence
# was silent in the worst way: the REALTIME branch never calls _send_durable, so
# every carve propagated to the mesh and was lost on reload — and can_write is
# consulted only in the durable branch, so the authorship gate could not engage
# either. Both of this row's other two columns were dead while that one was wrong.
{"prefix": "world:level:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "world:level:", "author": ["world:level:"]},
# Creature spawn registry: privileged world authoring, no per-peer owner. Both
# creature rows share ONE map scope, which is why map_scope is a prefix.
# `authority`: the elected game authority (Game.is_multiplayer_authority) may write it
# whatever its Keet role — it is the peer that SIMULATES creatures, and a room whose
# elected host is a plain peer otherwise ran creatures nobody else could see (every
# registry write refused, silently). Other writers still need `role`. User 2026-10-04.
# ⚠ RETIRED (#114 phase 5: creatures are bots, world:bots:). Nothing writes or reads
# these now; the rows stay routed so a room's old rows still load map-scoped (kept,
# not deleted). The authority exception moved to the bot registry.
{"prefix": "world:creatures:registry:", "channel": Channel.DURABLE, "role": "mod",
"map_scope": "world:creatures:", "author": []},
{"prefix": "world:creatures:pos:", "channel": Channel.REALTIME, "role": "",
"map_scope": "world:creatures:", "author": []},
# BOTS. The same two-row shape creatures use, and for the same reason: identity
# is durable and low-rate, motion is realtime and never durable. A bot's
# position must NOT go on the durable plane -- that is an autobase KV with LWW
# whose remote changes arrive on keet_game_state.gd's 3 s poll, so movement
# would both lag by seconds and grow the log without bound.
#
# BOTH ROWS ARE MAP-SCOPED, which is what makes containment automatic: the
# registry row is dropped with the map's durable state, and the pos row is now
# evicted by _evict_map_scoped_realtime(). Before that eviction existed this
# pair would have leaked exactly as world:creatures:pos: did.
#
# role "mod" on the registry matches world:creatures:registry:: spawning
# something the room then simulates is privileged world authoring. pos carries
# no role for the same reason player:pos: does not -- it is ephemeral chatter,
# and gating it would cost a role lookup per movement write.
# `authority` as on the creature registry (creatures → bots, #114 phase 3): the
# elected host runs a spawner object's bots, so it writes their rows whatever its role.
{"prefix": "world:bots:registry:", "channel": Channel.DURABLE, "role": "mod",
"map_scope": "world:bots:", "author": [], "authority": true},
{"prefix": "world:bots:pos:", "channel": Channel.REALTIME, "role": "",
"map_scope": "world:bots:", "author": []},
# The SAME two rows, room-global, for a bot bound to the room or the session
# rather than to a map. world:bots: is map-scoped unconditionally, so a
# room-bound bot registered under it had its node survive a map change while
# the row announcing it was dropped with the map's durable state — binding and
# namespace disagreeing for exactly the non-map bindings. BotRegistry picks the
# pair from the bot's narrowest binding, which is what spec/bot-testbed.md (e2)
# specifies under "The three derivations" and what the implementation did for
# the node parent but not for the key.
#
# No map_scope, deliberately: that is the whole difference. It also keeps these
# out of _evict_map_scoped_realtime, so a room-bound body's position is not
# dropped on a map change the way a map-bound one's should be.
{"prefix": "room:bots:registry:", "channel": Channel.DURABLE, "role": "mod",
"map_scope": "", "author": [], "authority": true},
{"prefix": "room:bots:pos:", "channel": Channel.REALTIME, "role": "",
"map_scope": "", "author": []},
{"prefix": "player:pos:", "channel": Channel.REALTIME, "role": "",
"map_scope": "", "author": []},
# Durable per-peer last-known position (memberId-keyed). player:pos: stays
# realtime for live movement; PlayerPosition snapshots this so a rejoining peer
# restores where it left off instead of the spawn point.
{"prefix": "world:players:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "world:players:", "author": ["world:players:"]},
{"prefix": "player:state:", "channel": Channel.REALTIME, "role": "",
"map_scope": "", "author": []},
# WHICH MAP THIS PEER IS IN. Realtime and per-peer, like the two rows above:
# it is live presence, not durable world state, and a peer that goes away
# should take it with them.
#
# ⚠ NOT map-scoped, and that is not an oversight — the VALUE is a map id, so
# scoping the key to a map would be circular: you could only read where a peer
# is if you were already in the map they are in, which is exactly the question.
#
# Player nodes hang off Game rather than off the map, so without this every
# peer's body existed in every map. That was harmless only while change_map's
# assumption held — that the whole room moves together — and it does not:
# world:settings:current_map is read once at game start and nothing subscribes
# to it, so a live peer never follows someone else's map change.
{"prefix": "player:map:", "channel": Channel.REALTIME, "role": "",
"map_scope": "", "author": []},
# Presence — DURABLE so peers NOT in the multiplayer mesh (e.g. the --dedicated
# TUI before /start) can subscribe and see live joins/leaves. Any room member
# writes their own entry; admin role NOT required.
{"prefix": "presence:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "", "author": []},
# Admin-controllable state mirror — every successful AdminFacade set also writes
# "state:<admin_path>.<prop>" so new joiners can replay the canonical world state
# without the historical command stream. Route role "peer": the route gate only
# decides whether the DURABLE channel fires at all, and per-write authorization
# is finer-grained downstream at AdminFacade.set_prop.
{"prefix": "state:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "", "author": []},
# AUTHOR-SCOPED (admin:cmd:<memberId>:<seq>): an honest client can only write its
# own commands, and AdminFacade takes the requester's role from that author's room
# membership instead of a role the command claims. Interim — see the #92 follow-up.
{"prefix": "admin:cmd:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "", "author": ["admin:cmd:"]},
{"prefix": "admin:result:", "channel": Channel.DURABLE, "role": "peer",
"map_scope": "", "author": []},
]
## Per-map content prefixes, DERIVED from the table above. Keys under these are
## stored as `map:<current_map_id>:<key>` so each level is a contained world
## (spec/world-system.md Step 1) — a Quake brush belongs to exactly one of them.
## Room isolation comes free (the StateBase is per room); map isolation does not.
var MAP_SCOPED_PREFIXES: Array[String] = []
## Per-peer authorship (spec/world-system.md Step 2), DERIVED from the table above.
## These prefixes embed the author's memberId as the segment right after the prefix:
## world:terrain:edit:<memberId>:<seq>
## world:objects:<memberId>:<id>
## world:players:<memberId>
## A peer freely writes content it authored; overriding ANOTHER peer's content
## requires mod+. Shared content has no embedded author and is gated by role alone.
## See can_write().
var AUTHOR_SCOPED_PREFIXES: Array[String] = []
## Derived at construction, not in _ready: _is_map_scoped and _author_of are
## reachable from any subsystem's own _ready, and an empty list there would read as
## "room-global, shared" — which is the silent-wrong answer, not an error.
func _init() -> void:
for row: Dictionary in STATE_ROUTES:
var scope: String = row.get("map_scope", "")
if scope != "" and not MAP_SCOPED_PREFIXES.has(scope):
MAP_SCOPED_PREFIXES.append(scope)
for a: String in (row.get("author", []) as Array):
if not AUTHOR_SCOPED_PREFIXES.has(a):
AUTHOR_SCOPED_PREFIXES.append(a)
## Register every row in the table. Called once by game.gd, in place of the fifteen
## route() calls that used to live there — route() itself stays public for tests and
## for anything that genuinely needs a one-off.
func install_routes() -> void:
for row: Dictionary in STATE_ROUTES:
route(String(row["prefix"]) + "*", int(row["channel"]), String(row.get("role", "")))
## Fallback role for routes that don't specify one. Default: "peer".
var default_write_role: String = "peer"
func _ready() -> void:
# Listen for durable state updates from KeetGameState manager
_connect_deferred.call_deferred()
func _connect_deferred() -> void:
if KeetService and KeetService.game_state:
KeetService.game_state.state_loaded.connect(_on_durable_loaded)
KeetService.game_state.state_updated.connect(_on_durable_updated)
# ---- Route Configuration ----
## Register a route. key_pattern can be exact ("player:hp") or prefix ("player:pos:*").
## required_role gates writes: "peer" (default, anyone), "mod", or "admin".
func route(key_pattern: String, channel: int, required_role: String = "") -> void:
var role: String = required_role if required_role != "" else default_write_role
_routes[key_pattern] = {"channel": channel, "role": role}
_route_cache.clear()
## Bulk-set routes from a dictionary. Values can be:
## int (channel, uses default_write_role)
## Dictionary {"channel": int, "role": String}
func route_table(table: Dictionary) -> void:
for key: String in table:
var val: Variant = table[key]
if val is int:
_routes[key] = {"channel": val, "role": default_write_role}
elif val is Dictionary:
_routes[key] = val
_route_cache.clear()
## Promote a key from REALTIME to DURABLE (e.g., confirmed building placement).
## Preserves the route's required role.
func promote(key: String) -> void:
var value: Variant = _realtime.get(key)
if value == null:
return
var resolved: Dictionary = _resolve(key)
_routes[key] = {"channel": Channel.DURABLE, "role": resolved.role}
_route_cache.erase(key)
_realtime.erase(key)
_durable[key] = value
_send_durable(key, value)
state_changed.emit(key, value, Channel.DURABLE)
## Demote a key from DURABLE to REALTIME (e.g., picked up a world object).
func demote(key: String) -> void:
var value: Variant = _durable.get(key)
if value == null:
return
var resolved: Dictionary = _resolve(key)
_routes[key] = {"channel": Channel.REALTIME, "role": resolved.role}
_route_cache.erase(key)
_durable.erase(key)
_realtime[key] = value
state_changed.emit(key, value, Channel.REALTIME)
# ---- State Access ----
## Write state — automatically routed to the correct channel.
## Role is checked per-route for DURABLE writes.
func set_state(key: String, value: Variant) -> void:
var resolved: Dictionary = _resolve(key)
var ch: int = resolved.channel
if ch == Channel.REALTIME or ch == Channel.BOTH:
_realtime[key] = value
_send_realtime(key, value)
if ch == Channel.DURABLE or ch == Channel.BOTH:
# Per-peer authorship gate (role + ownership), not just role.
if not can_write(key):
write_rejected.emit(key, "Not allowed to write %s (role/authorship)" % key)
return
_durable[key] = value
_send_durable(key, value)
state_changed.emit(key, value, ch)
## Read state — durable is source of truth when routed there.
func get_state(key: String, default: Variant = null) -> Variant:
var ch: int = _resolve(key).channel
if ch == Channel.DURABLE or ch == Channel.BOTH:
return _durable.get(key, default)
return _realtime.get(key, default)
## Check if a key exists in either store.
func has_state(key: String) -> bool:
return _realtime.has(key) or _durable.has(key)
## Erase a key from both stores. Emits state_changed with null value.
##
## A DURABLE key is deleted on the daemon too — hyper-games #88 Tier 1 #3. This
## used to clear the two mirrors only, so the key was still in the room's
## StateBase and came back on the next load: erased creatures re-spawned, terrain
## undo did not survive a reload, a retired bot's declaration lived on. Gated by
## can_write exactly as set_state is — erasing another peer's content needs mod+.
func erase_state(key: String) -> void:
var had: bool = _realtime.has(key) or _durable.has(key)
var ch: int = _resolve(key).channel
if _durable.has(key) and (ch == Channel.DURABLE or ch == Channel.BOTH):
if can_write(key):
_send_durable_erase(key)
else:
write_rejected.emit(key, "Not allowed to erase %s (role/authorship)" % key)
_realtime.erase(key)
_durable.erase(key)
if had:
state_changed.emit(key, null, ch)
## Get all keys matching a prefix.
func keys_with_prefix(prefix: String) -> Array[String]:
var result: Array[String] = []
# LINEAR. This deduplicated with `k not in result` — a linear search of an Array per
# key, quadratic in the journal size (1.9 / 11.3 / 42 ms at 500 / 1000 / 2000 keys),
# and every delete scans the journal several times (gate_keys_with_prefix). The
# realtime mirror is checked directly instead: same keys, same order.
for k: String in _realtime:
if k.begins_with(prefix):
result.append(k)
for k: String in _durable:
if k.begins_with(prefix) and not _realtime.has(k):
result.append(k)
return result
## Every DURABLE key of the current map (map-scoped, by plain name) — what a Home reset
## backs up and erases (home_reset.gd). Realtime keys are excluded: they are not content.
func map_scoped_keys() -> Array[String]:
var out: Array[String] = []
for k: String in _durable:
if _is_map_scoped(k):
out.append(k)
return out
## Clear all state (e.g., on room switch).
func clear() -> void:
_realtime.clear()
_durable.clear()
# ---- Transport: Realtime (UDP/RPC) ----
## Broadcast a realtime state update to all connected peers via Godot RPC.
func _send_realtime(key: String, value: Variant) -> void:
if multiplayer.multiplayer_peer == null:
return
if multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
return
if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return
# Broadcast to all peers — unreliable for positions, reliable for important state
rpc("_recv_realtime", key, var_to_bytes(value))
@rpc("any_peer", "call_remote", "unreliable")
func _recv_realtime(key: String, data: PackedByteArray) -> void:
var value: Variant = bytes_to_var(data)
_realtime[key] = value
state_changed.emit(key, value, Channel.REALTIME)
# ---- Transport: Durable (keet-daemon Autobase via meta.set/meta.get) ----
func _send_durable(key: String, value: Variant) -> void:
if not KeetService or not KeetService.game_state:
write_rejected.emit(key, "Game state manager not available")
return
# Per-map namespacing (Step 1, spec/world-system.md): per-map content keys are
# stored under map:<current_map_id>:<key> so each level is a contained world.
KeetService.game_state.set_value(_scoped(key), value, func(r: Variant) -> void:
if r is Dictionary and r.has("error") and r["error"] != null:
write_rejected.emit(key, str(r["error"]))
)
func _send_durable_erase(key: String) -> void:
if not KeetService or not KeetService.game_state:
write_rejected.emit(key, "Game state manager not available")
return
KeetService.game_state.erase_value(_scoped(key))
## ---- Per-map namespacing helpers (see spec/world-system.md) ----
## mapId from a map scene path, e.g. res://scenes/maps/map_dc_terrain.tscn -> map_dc_terrain
static func map_id_from_path(p: String) -> String:
return p.get_file().get_basename()
## True if `key` is per-map CONTENT (vs room-global like presence:/world:settings:).
func _is_map_scoped(key: String) -> bool:
for prefix: String in MAP_SCOPED_PREFIXES:
if key.begins_with(prefix):
return true
return false
## Plain key -> stored key. Per-map keys get the `map:<id>:` namespace.
func _scoped(key: String) -> String:
if current_map_id != "" and _is_map_scoped(key):
return "map:%s:%s" % [current_map_id, key]
return key
## Stored key -> plain key for THIS map. Returns "" for another map's keys (skip).
func _unscope(stored_key: String) -> String:
if not stored_key.begins_with("map:"):
# Legacy un-namespaced per-map key (written before Step 1) → ignore it
# (fresh start per map). Room-global keys (settings/presence/state) load.
if _is_map_scoped(stored_key):
return ""
return stored_key
var rest: String = stored_key.substr(4) # after "map:"
var sep: int = rest.find(":")
if sep < 0:
return stored_key
var mid: String = rest.substr(0, sep)
if mid != current_map_id:
return "" # belongs to a different map
return rest.substr(sep + 1)
## Enter a map (cold-join restore OR teleport): the unified enter-map lifecycle.
## Sets the per-map namespace, drops the previous map's local mirror, tells
## subsystems to reset (map_changing), then reloads this map's scoped state so
## state_changed re-emits and subsystems re-apply cleanly. This is what makes a
## teleport behave like re-entering a contained world. See spec/world-system.md.
## True once a map has been ENTERED (enter_map); a room load then never moves the
## scope (see _on_durable_loaded pass 1). Cleared on a room switch.
var _map_entered := false
func enter_map(map_id: String) -> void:
current_map_id = map_id
_map_entered = true
_durable.clear()
_evict_map_scoped_realtime()
room_state_loaded = false
map_changing.emit(map_id)
load_room_state()
## Drop the outgoing map's REALTIME state on a map change.
##
## WHY THIS WAS MISSING AND WHY IT MATTERED. enter_map cleared `_durable` and
## nothing else -- only reset_for_room_switch cleared `_realtime` -- so every
## map-scoped realtime key outlived the map it belonged to. `world:creatures:pos:`
## IS correctly map-scoped, which is what made this confusing to find: map-scoping
## controls the key NAME and says nothing about EVICTION, so the data was filed
## correctly and simply never thrown away. Ghost creature positions crossed into
## the next map and a fresh listener had no handle on them.
##
## ⛔ THE IN-MEMORY MIRROR IS KEYED BY THE *PLAIN* KEY. An earlier version of this
## tested `k.begins_with("map:")`, reasoning that _scoped() names a map-scoped key
## "map:<id>:<key>". That is true of the WIRE key and false of the mirror:
## set_state does `_realtime[key] = value` with the plain key and scopes only on
## the way out (_send_realtime / _send_durable), with _unscope() on the way back.
## So the "map:" test matched nothing and the gate stayed red with the fix in
## place. _is_map_scoped() is the correct test and reads STATE_ROUTES directly, so
## it is the single source rather than a second copy of the prefix list.
##
## Keys that carry no map scope are left exactly alone -- measured from the route
## table (15 rows), the REALTIME rows are:
##
## world:creatures:pos: map_scope world:creatures: -> EVICT
## player:pos: (none) -> KEEP
## player:state: (none) -> KEEP
##
## A live peer's position must cross a map change, so a blanket _realtime.clear()
## would be wrong here even though it is right in reset_for_room_switch, where the
## whole room is being left.
##
## Everything map-scoped goes, not just the outgoing map's: realtime state is
## ephemeral and re-broadcast by whoever owns it, so anything still live comes
## back on its own. That is the same reasoning as _durable.clear() above, which
## drops every map's durable keys and lets load_room_state() refill them.
func _evict_map_scoped_realtime() -> void:
var stale: Array[String] = []
for k: String in _realtime.keys():
if _is_map_scoped(k):
stale.append(k)
for k: String in stale:
_realtime.erase(k)
if not stale.is_empty():
print("[GameState] map change: evicted %d map-scoped realtime key(s)" % stale.size())
## Reset the world when SWITCHING to a different room. Each room is a separate
## world, so the previous room's durable state + baked terrain must be dropped
## before the new room's state loads. Called by KeetService.select_room on a real
## switch. map_changing tells subsystems (terrain, etc.) to clear + re-apply for
## the incoming room; room_state_loaded is re-armed so the world-restore flow
## (game.gd:_await_room_state) actually waits for the NEW room's state.
func reset_for_room_switch() -> void:
_durable.clear()
_realtime.clear()
_map_entered = false # a new room: its saved map may set the scope (cold join)
room_state_loaded = false
room_changing.emit(_get_current_room())
map_changing.emit(current_map_id)
## Load all durable game state for the current room.
func load_room_state(room_id: String = "") -> void:
if room_id.is_empty():
room_id = _get_current_room()
if room_id.is_empty():
return
if KeetService and KeetService.game_state:
KeetService.game_state.watch(room_id)
## Bulk-load from KeetGameState when room state is fetched.
func _on_durable_loaded(_room_id: String, entries: Dictionary) -> void:
# Pass 1: pick up the room's current map (room-global key) so per-map keys
# below un-scope for the RIGHT map even before change_map runs.
# ONLY BEFORE A MAP HAS BEEN ENTERED (a cold join). change_map -> enter_map
# starts this very fetch, and writes the new world:settings:current_map only
# after the map has loaded — so the answer carries the OLD map, and adopting it
# flipped the scope back to map_test while map_mountain was loaded: every
# map-scoped write after that (sky, terrain edits, objects) went into map_test's
# namespace (gate_map_scope_race; a user's settings dump showed map_scene
# map_mountain.tscn with map_id "map_test").
if not _map_entered and entries.has("world:settings:current_map"):
var ms: Variant = entries["world:settings:current_map"]
if ms is Dictionary and ms.has("path"):
current_map_id = map_id_from_path(str(ms["path"]))
# Pass 2: STORE EVERYTHING, un-scoping per-map keys to the current map; legacy
# un-namespaced per-map keys and other maps' keys are skipped (fresh start).
var has_player: bool = false
var loaded: Array[String] = []
for key: String in entries:
var local_key: String = _unscope(key)
if local_key == "":
continue
_durable[local_key] = entries[key]
loaded.append(local_key)
if local_key.begins_with("world:players:"):
has_player = true
var applied: int = loaded.size()
# room_state_loaded BEFORE the emits: a handler that asks "is the room loaded
# yet" during its own notification is asking about the snapshot it is being
# handed, and the honest answer is yes.
room_state_loaded = true
# Pass 3: EMIT, now that the mirror is whole.
#
# ⛔ STORE-AND-EMIT IN ONE PASS IS A BUG, NOT A STYLE. It handed every handler a
# half-filled mirror whose contents depended on Dictionary order, which is
# invisible until a handler consults ANOTHER key to decide what to do. That is
# exactly what AdminFacade does: it skips a command whose admin:result:<seq>
# marker is already present. "admin:cmd:" sorts before "admin:result:", so the
# marker was never stored yet when the command was emitted, the skip never
# fired, and EVERY command in the room re-executed on every cold load.
#
# That is hyper-games #77 item 5 -- ashatron replaying stortron's commands. The
# marker fix was right all along and the fill order was defeating it. Measured
# in AI Testing Town 2026-09-15: admin:result:pw-1789521468 EXISTS and the
# pathwalker registration re-dispatched anyway, registering map-bound bots into
# whatever map happened to be current at boot -- which then killed them all when
# the saved map loaded a moment later.
#
# A bulk load is ATOMIC from a handler's point of view. gate_durable_load_order.
for local_key: String in loaded:
state_changed.emit(local_key, _durable[local_key], Channel.DURABLE)
if OS.has_environment("HTPS_VERBOSE"):
printerr("HTPSDIAG [game-state] durable_loaded %d/%d entries for map=%s (has_player=%s)" % [applied, entries.size(), current_map_id, str(has_player)])
# AFTER the emits: the mirror is whole, so "does Home already have this key" is
# answered against everything the room holds, not a half-filled mirror.
seed_home_starter(_room_id, HomeStarter.load_starter(current_map_id))
## A release's STARTER MAP, copied into this identity's HOME the first time a map is
## entered there (docs/home-world-design.md §6). Only at Home, only once per map
## (world:starter:applied), only keys the home does not have. Other rooms are never
## seeded. Returns HomeStarter.seed's result, or {} when nothing applies.
func seed_home_starter(room_id: String, starter: Dictionary) -> Dictionary:
if starter.is_empty() or KeetService == null or not KeetService.is_home(room_id):
return {}
if String(starter.get("map_id", "")) != current_map_id:
return {}
if has_state(HomeStarter.MARKER):
return {"skipped": true}
# WAIT FOR THE ROLE. The starter's records are the DEV's (their memberId), so
# writing them needs admin -- which at Home is you, but KeetGameState fetches the
# role in a separate RPC racing the state load. Seeding as "peer" would have
# every dev-authored record refused by can_write.
if not _has_role("admin"):
_pending_starter = {"room": room_id, "starter": starter}
if not KeetService.role_changed.is_connected(_on_role_for_starter):
KeetService.role_changed.connect(_on_role_for_starter)
return {"deferred": true}
_pending_starter = {}
HomeStarter.install_content(starter, "user://content", get_node_or_null(^"/root/ContentOverlay"))
var r: Dictionary = HomeStarter.seed(starter, self)
print("[GameState] home starter for %s: %s" % [current_map_id, str(r)])
return r
var _pending_starter: Dictionary = {}
func _on_role_for_starter(room_id: String, _role: String) -> void:
if _pending_starter.is_empty() or room_id != String(_pending_starter.get("room", "")):
return
seed_home_starter(room_id, _pending_starter.get("starter", {}))
## Reset the loaded flag (on room switch) so the next room awaits its own load.
func reset_loaded() -> void:
room_state_loaded = false
## Single-key update from KeetGameState (remote peer wrote, or our own write confirmed).
func _on_durable_updated(_room_id: String, key: String, value: Variant) -> void:
var local_key: String = _unscope(key)
if local_key == "":
return # another map's / legacy key
_durable[local_key] = value
state_changed.emit(local_key, value, Channel.DURABLE)
# ---- Role Check ----
## Check if local user meets the required role level.
## Role hierarchy: admin > mod > peer > read-only
func _has_role(required: String) -> bool:
if required == "peer":
return true
var role: String = KeetService.get("current_role") if KeetService else "peer"
if role == "":
role = "peer"
match required:
"admin":
return role == "admin"
"mod":
return role == "admin" or role == "mod"
return true
## Our stable Keet memberId (author key), or "" if identity not yet loaded.
func _my_member_id() -> String:
var id: String = KeetService.current_member_id if KeetService else ""
# THE SAME FALLBACK THE AUTHORS USE, and it has to be, because this is the
# other half of comparing them.
#
# LevelPersistence._author() and TerrainEditManager._generate_edit_id() both
# key their records "p<peerId>" when there is no member id yet. This did not,
# so with keet unavailable a record was authored as "p1" while can_write asked
# "is p1 me?", got "", and fell through to requiring MOD — which an offline
# session does not have. Every durable write was refused:
#
# set_state -> can_write false -> write_rejected.emit -> return
#
# and it returns BEFORE `_durable[key] = value`, so the record did not even
# exist locally. Deleting a brush then applied to DCLevel, wrote no journal
# record, and left undo with nothing — while the message said "Z to bring it
# back". Silent, destructive, and only offline, which is precisely how you run
# the editor from source (no bare index.js, so no keet-daemon).
#
# Not a widened permission: it lets you write records YOU authored under the
# same name you authored them with. When a member id exists it wins.
return id if id != "" else "p%d" % multiplayer.get_unique_id()
## Extract the author memberId embedded in an author-scoped key (the segment
## right after the prefix), or "" if the key is shared content (no author).
func _author_of(key: String) -> String:
for prefix: String in AUTHOR_SCOPED_PREFIXES:
if key.begins_with(prefix):
var rest: String = key.substr(prefix.length())
var sep: int = rest.find(":")
return rest.substr(0, sep) if sep >= 0 else rest
return ""
## Per-peer authorship gate for DURABLE writes (spec/world-system.md Step 2).
## Allowed when the writer meets the route's role AND either:
## - the content is shared (no embedded author), or
## - the writer authored it (author == my memberId), or
## - the writer is mod+ (may override anyone's content).
## This is the optimistic client-side gate; the daemon apply-contract (Step 4)
## will be the authoritative check.
func can_write(key: String) -> bool:
if _authority_may_write(key):
return true
if not _has_role(_resolve(key).role):
return false
var author: String = _author_of(key)
if author == "":
return true
var me: String = _my_member_id()
if me != "" and author == me:
return true
return _has_role("mod")
## True if `key` is on a route row marked `authority` and this peer is the elected game
## authority (the Game node's multiplayer authority — the same election BotRegistry
## and spawner objects use). Such rows carry no author segment, so this is the whole check.
func _authority_may_write(key: String) -> bool:
var row_hit := false
for row: Dictionary in STATE_ROUTES:
if bool(row.get("authority", false)) and key.begins_with(String(row["prefix"])):
row_hit = true
break
if not row_hit:
return false
var game: Node = get_node_or_null(^"/root/Game")
return game != null and game.is_multiplayer_authority()
## Check if a specific peer (by DHT public key hex) meets a role level.
func peer_has_role(pk_hex: String, required: String) -> bool:
if required == "peer":
return true
var role: String = KeetService.get_member_role(pk_hex) if KeetService else "peer"
match required:
"admin":
return role == "admin"
"mod":
return role == "admin" or role == "mod"
return true
func _get_current_room() -> String:
if KeetService and KeetService.has_method("get_current_room"):
return KeetService.get_current_room()
if KeetService:
return KeetService.get("current_room_id") if KeetService.get("current_room_id") else ""
return ""
# ---- Route Resolution ----
const _DEFAULT_ROUTE: Dictionary = {"channel": Channel.REALTIME, "role": "peer"}
## Resolve a key to {channel: int, role: String}.
## Checks exact match first, then longest prefix pattern.
func _resolve(key: String) -> Dictionary:
if _route_cache.has(key):
return _route_cache[key]
# Exact match
if _routes.has(key):
_route_cache[key] = _routes[key]
return _routes[key]
# Prefix match (patterns ending with *)
var best_len: int = 0
var best: Dictionary = _DEFAULT_ROUTE
for pattern: String in _routes:
if pattern.ends_with("*"):
var prefix: String = pattern.left(pattern.length() - 1)
if key.begins_with(prefix) and prefix.length() > best_len:
best_len = prefix.length()
best = _routes[pattern]
_route_cache[key] = best
return best
## Get the required role for writing a specific key.
func get_required_role(key: String) -> String:
return _resolve(key).role