extends Node ## FriendManager - Nakama friend list, DM channels, and lobby invites ## Autoload: FriendManager signal friends_updated(friends: Array) signal lobby_invite_received(from_user_id: String, from_name: String, match_id: String) signal dm_message_received(from_user_id: String, from_name: String, message: String) ## Notification codes (must match server-side RPC) const NOTIF_LOBBY_INVITE := 1001 const NOTIF_FRIEND_REQUEST := 1002 ## Friend state codes from Nakama const STATE_FRIEND := 0 const STATE_INVITE_OUT := 1 const STATE_INVITE_IN := 2 const STATE_BLOCKED := 3 var friends: Array = [] # [{user_id, username, state}] var _friend_ids: Dictionary = {} # user_id -> true (mutual friends only) var _dm_channels: Dictionary = {} # user_id -> NakamaChannel var _has_pending_requests: bool = false # set before UI is ready func _ready() -> void: NakamaManager.connected_to_nakama.connect(_on_nakama_connected) AuthManager.logged_out.connect(_on_logged_out) if NakamaManager.socket and NakamaManager.socket.is_connected_to_host(): _connect_socket_signals() func _on_logged_out() -> void: print("[FriendManager] Clearing state on logout") friends.clear() _friend_ids.clear() close_all_dm_channels() _has_pending_requests = false emit_signal("friends_updated", []) func _on_nakama_connected() -> void: _connect_socket_signals() await load_friends() _fetch_pending_notifications() func _fetch_pending_notifications() -> void: if not NakamaManager.session or not NakamaManager.client: return print("[FriendManager] _fetch_pending_notifications: fetching...") # Retrieve all persistent notifications queued while offline (up to 100) var result = await NakamaManager.client.list_notifications_async( NakamaManager.session, 100, null) if result.is_exception(): push_warning("[FriendManager] Failed to fetch notifications: " + result.get_exception().message) return print("[FriendManager] _fetch_pending_notifications: count=%d" % result.notifications.size()) var has_friend_request := false var ids_to_delete: PackedStringArray = PackedStringArray() for notif in result.notifications: print("[FriendManager] notification: code=%d sender=%s" % [notif.code, notif.sender_id]) ids_to_delete.append(notif.id) if notif.code == NOTIF_FRIEND_REQUEST: has_friend_request = true # Acknowledge all so they are not delivered again next login if ids_to_delete.size() > 0: NakamaManager.client.delete_notifications_async(NakamaManager.session, ids_to_delete) # If any were friend requests, reload the friends list to show them if has_friend_request: print("[FriendManager] _fetch_pending_notifications: reloading friends for pending request") load_friends() func _connect_socket_signals() -> void: var socket = NakamaManager.socket if not socket: return if not socket.received_notification.is_connected(_on_notification_received): socket.received_notification.connect(_on_notification_received) if not socket.received_channel_message.is_connected(_on_channel_message): socket.received_channel_message.connect(_on_channel_message) # ============================================================================= # Friend List # ============================================================================= func load_friends() -> void: if not NakamaManager.session: print("[FriendManager] load_friends: no session") return print("[FriendManager] load_friends: fetching...") var result = await NakamaManager.client.list_friends_async(NakamaManager.session, null, 100, null) if result.is_exception(): push_warning("[FriendManager] Failed to load friends: " + result.get_exception().message) return friends.clear() _friend_ids.clear() _has_pending_requests = false for f in result.friends: var u = f.user var entry := { "user_id": u.id, "username": u.display_name if u.display_name != "" else u.username, "state": f.state, } friends.append(entry) print("[FriendManager] friend: %s state=%d" % [u.username, f.state]) if f.state == STATE_FRIEND: _friend_ids[u.id] = true if f.state == STATE_INVITE_IN: _has_pending_requests = true print("[FriendManager] load_friends done: %d entries" % friends.size()) emit_signal("friends_updated", friends) func get_mutual_friends() -> Array: return friends.filter(func(f): return f.state == STATE_FRIEND) func is_friend(user_id: String) -> bool: return _friend_ids.has(user_id) func add_friend_by_id(user_id: String) -> bool: if not NakamaManager.session: return false print("[FriendManager] add_friend_by_id: adding %s" % user_id) # Step 1: Add the friend relationship via native Nakama client API var result = await NakamaManager.client.add_friends_async( NakamaManager.session, PackedStringArray([user_id]), null) if result.is_exception(): push_warning("[FriendManager] add_friend failed: " + result.get_exception().message) return false print("[FriendManager] add_friend_by_id: add_friends_async OK, sending notification RPC...") # Step 2: Notify the target via RPC var payload = JSON.stringify({"user_id": user_id}) var rpc_result = await BackendService.send_friend_request(user_id) if rpc_result.get("success", false) == false: push_error("[FriendManager] rpcSendFriendRequest failed: " + str(rpc_result.get("error", ""))) else: print("[FriendManager] rpcSendFriendRequest OK: " + str(rpc_result.payload)) load_friends() return true func add_friend_by_username(username: String) -> bool: if not NakamaManager.session: return false # Resolve username → user_id first via search, then use the RPC # Fall back to direct Nakama API (no notification sent to target) var result = await NakamaManager.client.add_friends_async( NakamaManager.session, null, PackedStringArray([username])) if result.is_exception(): push_warning("[FriendManager] add_friend_by_username failed: " + result.get_exception().message) return false load_friends() return true func remove_friend(user_id: String) -> bool: if not NakamaManager.session: return false var result = await NakamaManager.client.delete_friends_async( NakamaManager.session, PackedStringArray([user_id]), null) if result.is_exception(): return false load_friends() return true # ============================================================================= # Lobby Invites # ============================================================================= func send_lobby_invite(to_user_id: String, match_id: String) -> void: if not NakamaManager.session: return var result = await BackendService.send_lobby_invite(to_user_id, match_id) if result.get("success", false) == false: push_warning("[FriendManager] send_lobby_invite failed: " + str(result.get("error", ""))) func _on_notification_received(notification) -> void: print("[FriendManager] _on_notification_received: code=%d sender=%s" % [notification.code, notification.sender_id]) match notification.code: NOTIF_LOBBY_INVITE: var content = JSON.parse_string(notification.content) if content: var from_name: String = content.get("from_name", "Someone") var match_id: String = content.get("match_id", "") emit_signal("lobby_invite_received", notification.sender_id, from_name, match_id) NOTIF_FRIEND_REQUEST: # Refresh friends list so the incoming request appears immediately print("[FriendManager] friend request notification received, reloading friends...") load_friends() # ============================================================================= # Direct Messages # ============================================================================= func open_dm(user_id: String) -> Object: if _dm_channels.has(user_id): return _dm_channels[user_id] var socket = NakamaManager.socket if not socket: return null var channel = await socket.join_chat_async( user_id, NakamaSocket.ChannelType.DirectMessage, true, false) if channel.is_exception(): push_warning("[FriendManager] Failed to open DM with " + user_id) return null _dm_channels[user_id] = channel return channel func get_dm_history(user_id: String, limit: int = 50) -> Array: if not NakamaManager.session: return [] var channel = await open_dm(user_id) if not channel: return [] var result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, channel.id, limit, false) if result.is_exception(): push_warning("[FriendManager] Failed to fetch DM history: " + result.get_exception().message) return [] var history: Array = [] for msg in result.messages: var text: String = "" var parsed = JSON.parse_string(msg.content) if typeof(parsed) == TYPE_DICTIONARY: text = parsed.get("msg", msg.content) else: text = msg.content history.append({"from": msg.sender_id, "msg": text, "username": msg.username, "create_time": msg.create_time}) history.reverse() # API returns newest-first; flip to oldest-first so display is top=old, bottom=new return history func send_dm(user_id: String, message: String) -> bool: var channel = await open_dm(user_id) if not channel: return false var socket = NakamaManager.socket if not socket: return false var result = await socket.write_chat_message_async(channel.id, {"msg": message}) return not result.is_exception() func get_dm_channel_id(user_id: String) -> String: var ch = _dm_channels.get(user_id, null) return ch.id if ch else "" func _on_channel_message(message) -> void: # Ignore global chat if "social_global" in message.channel_id: return # Ignore messages sent by ourselves (we already add them locally instantly) if NakamaManager.session and message.sender_id == NakamaManager.session.user_id: return var text: String = "" var parsed = JSON.parse_string(message.content) if typeof(parsed) == TYPE_DICTIONARY: text = parsed.get("msg", message.content) else: text = message.content emit_signal("dm_message_received", message.sender_id, message.username, text) func close_all_dm_channels() -> void: var socket = NakamaManager.socket for user_id in _dm_channels: var ch = _dm_channels[user_id] if socket: socket.leave_chat_async(ch.id) _dm_channels.clear()