OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / keet_service.gd

238 lines · gd ref:
extends Node
## Global singleton owning the UDS client and all keet managers.
## Daemon lifecycle is handled by the launcher (index.js).
## Godot just connects to the socket with auto-reconnect.

signal room_selected(room_id: String, title: String)
signal daemon_status_changed(status: String)  # "connecting", "running", "disconnected"
signal role_changed(room_id: String, role: String)

var client: KeetUdsClient
var room_mgr: KeetRoom
var chat: KeetChat
var identity: KeetIdentity
var feed: KeetFeed
var game_state: KeetGameState
## Which keet member is behind each mesh peer, proven per connection (#89 §3.3).
## A child named PeerIdentity, so its RPC path is the same on every peer.
var peer_identity: Node

const PeerIdentity := preload("res://addons/keet_social/keet_peer_identity.gd")
const KeetNames := preload("res://addons/keet_social/keet_names.gd")

var current_room_id: String = ""
var current_role: String = "peer"  # admin, mod, peer, read-only
var current_member_id: String = ""  # my stable Keet memberId — author key for per-peer content
## This identity's HOME room id, from keet-daemon home.get ("" until known). Set by
## the main menu; cleared on an identity swap (each identity has its own home).
## docs/home-world-design.md.
var home_room_id: String = ""

var _room_cache: Dictionary = {}  # room_id -> title
var _member_cache: Array = []  # cached member list for current room
var _pk_role_map: Dictionary = {}  # pk_hex -> role (from RPC announcements)


func _ready() -> void:
	client = KeetUdsClient.new()
	client.name = "Client"
	client.auto_connect = true
	add_child(client)

	room_mgr = KeetRoom.new()
	room_mgr.name = "RoomMgr"
	room_mgr.client = client
	add_child(room_mgr)

	chat = KeetChat.new()
	chat.name = "Chat"
	chat.client = client
	add_child(chat)

	identity = KeetIdentity.new()
	identity.name = "Identity"
	identity.client = client
	add_child(identity)

	feed = KeetFeed.new()
	feed.name = "Feed"
	feed.client = client
	add_child(feed)

	game_state = KeetGameState.new()
	game_state.name = "GameState"
	game_state.client = client
	add_child(game_state)

	peer_identity = PeerIdentity.new()
	peer_identity.name = "PeerIdentity"
	peer_identity.client = client
	add_child(peer_identity)
	peer_identity.roster_requested.connect(_on_roster_requested)

	game_state.role_loaded.connect(_on_role_loaded)
	identity.identity_loaded.connect(_on_identity_loaded)

	client.connected.connect(_on_connected)
	client.disconnected.connect(_on_disconnected)
	client.event_received.connect(_on_event)
	room_mgr.rooms_loaded.connect(_on_rooms_loaded)
	room_mgr.members_loaded.connect(_on_members_loaded)

	daemon_status_changed.emit("connecting")


## Select a room for gaming — updates chat, game state, and triggers mesh discovery.
func select_room(room_id: String) -> void:
	var title: String = _room_cache.get(room_id, "(untitled)")
	# Switching AWAY from a different room? Each room is its own world — drop the
	# previous room's durable state + baked terrain before the new room loads, or
	# the old world (map, terrain edits) persists into the new room.
	# NO-ROOM COUNTS AS A DIFFERENT WORLD. This used to require
	# `current_room_id != ""`, on the assumption that there is nothing to drop
	# before you have joined anything. There is: the game boots into the SAVED
	# map and is fully editable with room= empty, so brush deletions, carves and
	# terrain edits accumulate in GameState._durable with no room behind them.
	# Joining your first room then skipped the reset and adopted all of it as
	# that room's state — so edits made offline, or in whatever map was loaded at
	# launch, silently became newtron's.
	#
	# Each room is its own world, and so is no-room. Reset whenever the room
	# identity changes at all.
	var switching: bool = current_room_id != room_id
	current_room_id = room_id
	if switching:
		var gs := get_node_or_null("/root/GameState")
		if gs and gs.has_method("reset_for_room_switch"):
			gs.reset_for_room_switch()

	# Switch chat to the selected room
	chat.watch(room_id)

	# Switch game state to the selected room (fetches role + durable state)
	game_state.watch(room_id)

	# Fetch room members for role lookup
	room_mgr.list_members(room_id)

	room_selected.emit(room_id, title)


## Deselect room.
func deselect_room() -> void:
	# THE SAME LEAK AS select_room, in the other direction. Unwatching stops new
	# state arriving; it does not drop what already arrived. Without this you
	# leave a room and keep editing ITS world — its map, its brush deletions, its
	# terrain edits — while `room=` reads empty, and those edits then belong to
	# nothing. Leaving a room is a change of world too.
	var had_room: bool = current_room_id != ""
	current_room_id = ""
	current_role = "peer"
	_member_cache.clear()
	peer_identity.set_roster([])
	chat.unwatch()
	game_state.unwatch()
	if had_room:
		var gs := get_node_or_null("/root/GameState")
		if gs and gs.has_method("reset_for_room_switch"):
			gs.reset_for_room_switch()
	room_selected.emit("", "")


## Get the currently selected room ID.
func get_current_room() -> String:
	return current_room_id


## True when `room_id` is this identity's home room.
func is_home(room_id: String) -> bool:
	return room_id != "" and room_id == home_room_id


## Get cached room list.
func get_rooms() -> Dictionary:
	return _room_cache


func _on_connected() -> void:
	daemon_status_changed.emit("running")
	room_mgr.list_rooms()
	identity.get_identity()


func _on_disconnected() -> void:
	daemon_status_changed.emit("disconnected")


func _on_event(event_name: String, _data: Dictionary) -> void:
	if event_name == "core_swapped":
		# Identity was switched — re-fetch everything
		identity.get_identity()
		room_mgr.list_rooms()


func _on_role_loaded(room_id: String, role: String) -> void:
	current_role = role
	role_changed.emit(room_id, role)


## Cache our stable Keet memberId so per-peer content can be author-keyed and the
## authorship gate (GameState.can_write) can recognise our own writes.
func _on_identity_loaded(id: Dictionary) -> void:
	current_member_id = str(id.get("memberId", ""))
	var my_name := KeetNames.clean(id.get("displayName"))
	if my_name.is_empty():
		my_name = KeetNames.clean(id.get("username"))
	peer_identity.my_name = my_name if not my_name.is_empty() else KeetNames.display(null, current_member_id)
	peer_identity.on_identity_loaded(id)


## A verified peer's member may be missing from (or renamed in) the cached roster.
func _on_roster_requested() -> void:
	if current_room_id != "":
		room_mgr.list_members(current_room_id)


## Register a peer's announced role (from Godot RPC exchange).
## This maps a DHT public key hex to a Keet role.
func register_peer_role(pk_hex: String, role: String) -> void:
	_pk_role_map[pk_hex] = role


## Look up a peer's Keet role by their DHT public key hex.
## Returns the RPC-announced role, or "peer" if unknown.
func get_member_role(pk_hex: String) -> String:
	return _pk_role_map.get(pk_hex, "peer")


## A room member's role from KEET'S OWN MEMBERSHIP ("admin" / "mod" / "peer" /
## "read-only"), by memberId — the roster keet-core replicates, not a role a peer
## announces about itself (that is _pk_role_map). "" when the id is not a member of
## the current room (or the roster has not loaded).
func role_of_member(member_id: String) -> String:
	for m: Variant in _member_cache:
		if m is Dictionary and String((m as Dictionary).get("memberId", "")) == member_id:
			return String((m as Dictionary).get("role", "peer"))
	return ""


## Get all cached members for the current room.
func get_room_members() -> Array:
	return _member_cache


func _on_members_loaded(room_id: String, members: Array) -> void:
	if room_id == current_room_id:
		_member_cache = members
		peer_identity.set_roster(members)


func _on_rooms_loaded(rooms: Array) -> void:
	_room_cache.clear()
	for room: Variant in rooms:
		if room is Dictionary:
			var rid: String = room.get("roomId", "")
			var title: String = room.get("title", "(untitled)")
			if rid != "":
				_room_cache[rid] = title