OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / integration / scripts / keet_game_state.gd

234 lines · gd ref:
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.")