class_name KeetGameState
extends Node
## Durable game state manager for keet-daemon.
## Reads/writes game state via the room metadata Autobase (meta.get/meta.set).
## All game keys are prefixed with "game:" in the metadata namespace.
##
## Role-aware: checks local member role before allowing writes.
## Supports watching for remote changes via polling.
signal state_loaded(room_id: String, entries: Dictionary)
signal state_updated(room_id: String, key: String, value: Variant)
signal role_loaded(room_id: String, role: String)
signal error_occurred(message: String)
var client: Node # KeetUdsClient (duck-typed)
@export var auto_poll: bool = true
@export_range(1.0, 30.0) var poll_interval: float = 3.0
var current_room: String = ""
var current_role: String = "peer" # admin, mod, peer, read-only
var _poll_timer: Timer
var _state_cache: Dictionary = {} # key (without game: prefix) → value
var _known_keys: Dictionary = {} # key → true (for change detection)
func _ready() -> void:
_poll_timer = Timer.new()
_poll_timer.wait_time = poll_interval
_poll_timer.timeout.connect(_poll_state)
add_child(_poll_timer)
## Start watching a room's game state.
func watch(room_id: String) -> void:
current_room = room_id
_state_cache.clear()
_known_keys.clear()
if room_id.is_empty():
_poll_timer.stop()
return
_fetch_role(room_id)
_fetch_all(room_id)
if auto_poll:
_poll_timer.start()
## Stop watching.
func unwatch() -> void:
current_room = ""
current_role = "peer"
_state_cache.clear()
_known_keys.clear()
_poll_timer.stop()
## Get a value from the durable cache.
func get_value(key: String, default: Variant = null) -> Variant:
return _state_cache.get(key, default)
## Get all cached state.
func get_all() -> Dictionary:
return _state_cache.duplicate()
## Set a game state value (writes to Autobase via meta.set).
func set_value(key: String, value: Variant, callback: Callable = Callable()) -> void:
if current_room.is_empty():
error_occurred.emit("set_value: no room selected")
return
if not _can_write():
error_occurred.emit("set_value: role '%s' cannot write" % current_role)
return
_require_client()
var serialized: String = var_to_str(value)
# Router expects {roomId, metadata: {field: value}} — NOT {roomId, key, value}
client.call_rpc("meta.set", {
"roomId": current_room,
"metadata": {"game:" + key: serialized}
}, func(result: Variant, err: Variant) -> void:
if err != null:
error_occurred.emit("set_value(%s): %s" % [key, str(err)])
return
_state_cache[key] = value
_known_keys[key] = true
state_updated.emit(current_room, key, value)
if callback.is_valid():
callback.call(result)
)
## Delete a game state key.
func erase_value(key: String, callback: Callable = Callable()) -> void:
if current_room.is_empty():
return
if not _can_write():
error_occurred.emit("erase_value: role '%s' cannot write" % current_role)
return
_require_client()
client.call_rpc("meta.set", {
"roomId": current_room,
"metadata": {"game:" + key: ""}
}, func(result: Variant, err: Variant) -> void:
if err != null:
error_occurred.emit("erase_value(%s): %s" % [key, str(err)])
return
_state_cache.erase(key)
_known_keys.erase(key)
if callback.is_valid():
callback.call(result)
)
## Batch-set multiple values in a single meta.set RPC call.
## This avoids race conditions with field-level LWW in the Autobase.
func set_batch(entries: Dictionary, callback: Callable = Callable()) -> void:
if entries.is_empty():
if callback.is_valid():
callback.call(null)
return
if current_room.is_empty():
error_occurred.emit("set_batch: no room selected")
return
if not _can_write():
error_occurred.emit("set_batch: role '%s' cannot write" % current_role)
return
_require_client()
# Build a single metadata dict with all game: prefixed keys
var metadata: Dictionary = {}
for key: String in entries:
var serialized: String = var_to_str(entries[key])
metadata["game:" + key] = serialized
client.call_rpc("meta.set", {
"roomId": current_room,
"metadata": metadata
}, func(result: Variant, err: Variant) -> void:
if err != null:
error_occurred.emit("set_batch: %s" % str(err))
return
for key: String in entries:
_state_cache[key] = entries[key]
_known_keys[key] = true
state_updated.emit(current_room, key, entries[key])
if callback.is_valid():
callback.call(result)
)
## Check if local user can write game state.
func can_write() -> bool:
return _can_write()
# ---- Role Management ----
func _fetch_role(room_id: String) -> void:
_require_client()
# Get our own identity first
client.call_rpc("identity", {}, func(ident: Variant, err: Variant) -> void:
if err != null or not ident is Dictionary:
return
var my_member_id: String = ident.get("memberId", "")
if my_member_id.is_empty():
return
# Then fetch member list to find our role
client.call_rpc("members", {"roomId": room_id}, func(result: Variant, merr: Variant) -> void:
if merr != null or not result is Array:
return
for member: Variant in result:
if member is Dictionary:
var mid: String = member.get("memberId", "")
if mid == my_member_id:
current_role = member.get("role", "peer")
role_loaded.emit(room_id, current_role)
return
)
)
func _can_write() -> bool:
return current_role != "read-only"
# ---- State Fetching ----
func _fetch_all(room_id: String) -> void:
_require_client()
# meta.list returns {roomId: metadata, ...} — extract our room's game: keys
client.call_rpc("meta.list", {}, func(result: Variant, err: Variant) -> void:
if err != null:
return
if not result is Dictionary:
return
# Find our room's metadata
var room_meta: Variant = result.get(room_id, null)
if room_meta == null or not room_meta is Dictionary:
return
var entries: Dictionary = {}
for meta_key: String in room_meta.keys():
if meta_key.begins_with("game:"):
var game_key: String = meta_key.substr(5)
var raw_val: Variant = room_meta[meta_key]
var value: Variant = _deserialize(raw_val)
_state_cache[game_key] = value
_known_keys[game_key] = true
entries[game_key] = value
state_loaded.emit(room_id, entries)
)
func _poll_state() -> void:
if current_room.is_empty() or client == null or not client.is_connected:
return
_fetch_all(current_room)
func _deserialize(raw: Variant) -> Variant:
if raw is String and raw != "":
var parsed: Variant = str_to_var(raw)
if parsed != null:
return parsed
return raw
func _require_client() -> void:
if client == null:
push_error("KeetGameState: client not set. Assign a KeetUdsClient node.")