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