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.
##
## Usage:
## GameState.route("player:pos:*", Channel.REALTIME)
## GameState.route("player:inventory", Channel.DURABLE)
## GameState.set_state("player:pos:3", Vector3(1,2,3))
## GameState.set_state("player:inventory", {"sword": 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)
enum Channel { REALTIME = 0, DURABLE = 1, BOTH = 2 }
## Route table: key pattern → Channel. Patterns ending with * are prefix-matched.
var _routes: Dictionary = {}
## Cached resolved routes (full key → Channel) to avoid re-matching every frame.
var _route_cache: Dictionary = {}
## In-memory state mirrors.
var _realtime: Dictionary = {}
var _durable: Dictionary = {}
## Minimum role required to write durable state. Default: "peer" (any member).
## Values: "admin", "mod", "peer".
var min_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:*").
func route(key_pattern: String, channel: int) -> void:
_routes[key_pattern] = channel
# Invalidate cache entries that could match this pattern
_route_cache.clear()
## Bulk-set routes from a dictionary.
func route_table(table: Dictionary) -> void:
for key: String in table:
_routes[key] = table[key]
_route_cache.clear()
## Promote a key from REALTIME to DURABLE (e.g., confirmed building placement).
func promote(key: String) -> void:
var value: Variant = _realtime.get(key)
if value == null:
return
_routes[key] = Channel.DURABLE
_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
_routes[key] = Channel.REALTIME
_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.
func set_state(key: String, value: Variant) -> void:
var ch := _resolve(key)
if ch == Channel.REALTIME or ch == Channel.BOTH:
_realtime[key] = value
_send_realtime(key, value)
if ch == Channel.DURABLE or ch == Channel.BOTH:
if not _can_write_durable():
write_rejected.emit(key, "Insufficient role (need %s)" % min_write_role)
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 := _resolve(key)
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.
func erase_state(key: String) -> void:
_realtime.erase(key)
_durable.erase(key)
## Get all keys matching a prefix.
func keys_with_prefix(prefix: String) -> Array[String]:
var result: Array[String] = []
for k: String in _realtime:
if k.begins_with(prefix):
result.append(k)
for k: String in _durable:
if k.begins_with(prefix) and k not in result:
result.append(k)
return result
## 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
KeetService.game_state.set_value(key, value, func(_r: Variant) -> void:
pass
)
## 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:
for key: String in entries:
_durable[key] = entries[key]
state_changed.emit(key, entries[key], Channel.DURABLE)
## 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:
_durable[key] = value
state_changed.emit(key, value, Channel.DURABLE)
# ---- Role Check ----
func _can_write_durable() -> bool:
if min_write_role == "peer":
return true # Any member can write
var role: String = KeetService.get("current_role") if KeetService else "peer"
if role == "":
role = "peer"
match min_write_role:
"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 ----
## Resolve a key to its channel. Checks exact match first, then prefix patterns.
func _resolve(key: String) -> int:
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_ch: int = Channel.REALTIME
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_ch = _routes[pattern]
_route_cache[key] = best_ch
return best_ch