OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / game_state.gd

837 lines · gd ref:
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