OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / integration / scripts / game_state.gd

246 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.
##
## 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