using System; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Convai.Scripts.Runtime.LoggerSystem; using UnityEngine; namespace Convai.Scripts.Runtime.Multiplayer { /// /// UDP Peer Discovery - Finds other Quest Pro on the same hotspot network /// Solves AP isolation problem by discovering peer IP and switching to direct unicast /// public class UDPPeerDiscovery : MonoBehaviour { [Header("Player Configuration")] [SerializeField] private byte localPlayerID = 1; // 1 or 2 - must be unique per Quest Pro [SerializeField] private bool enableDebugLogging = true; [Header("Discovery Settings")] [SerializeField] private float discoveryBroadcastInterval = 2.0f; // Broadcast every 2 seconds [SerializeField] private float heartbeatInterval = 5.0f; // Heartbeat every 5 seconds [SerializeField] private float peerTimeoutSeconds = 15.0f; // Consider peer lost after 15s // Events public event Action OnPeerDiscovered; // Fires when peer IP is found public event Action OnPeerLost; // Fires when peer connection is lost public event Action OnConnectionStateChanged; // Network components private UdpClient _udpClient; private bool _isRunning = false; private int _listenPort; private CancellationTokenSource _cancellationTokenSource; // Peer state private string _peerIP = ""; private byte _peerPlayerID = 0; private DateTime _lastPeerPacketTime; private ConnectionState _connectionState = ConnectionState.Disconnected; // Discovery protocol constants private const uint DISCOVERY_MAGIC = 0x44495343; // "DISC" in hex private const byte PACKET_TYPE_REQUEST = 0x01; private const byte PACKET_TYPE_RESPONSE = 0x02; private const byte PACKET_TYPE_HEARTBEAT = 0x03; // Singleton for easy access private static UDPPeerDiscovery _instance; public static UDPPeerDiscovery Instance => _instance; public enum ConnectionState { Disconnected, Discovering, Connected, Lost } // Public properties public string PeerIP => _peerIP; public byte LocalPlayerID => localPlayerID; public byte PeerPlayerID => _peerPlayerID; public ConnectionState CurrentState => _connectionState; public bool IsConnected => _connectionState == ConnectionState.Connected; private void Awake() { // Singleton pattern if (_instance != null && _instance != this) { Destroy(gameObject); return; } _instance = this; } private void Start() { // Get network config var cfg = NetworkConfig.Instance; if (cfg != null) { _listenPort = cfg.port; } else { ConvaiLogger.Error("NetworkConfig not found! Using default port 1221", ConvaiLogger.LogCategory.Character); _listenPort = 1221; } _cancellationTokenSource = new CancellationTokenSource(); StartDiscovery(); } private void OnDestroy() { StopDiscovery(); _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); if (_instance == this) _instance = null; } private void Update() { // Check for peer timeout if (_connectionState == ConnectionState.Connected) { TimeSpan timeSinceLastPacket = DateTime.UtcNow - _lastPeerPacketTime; if (timeSinceLastPacket.TotalSeconds > peerTimeoutSeconds) { HandlePeerLost(); } } } private void StartDiscovery() { if (_isRunning) return; try { // Create UDP client with port reuse for shared port _udpClient = new UdpClient(); _udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, _listenPort)); _udpClient.EnableBroadcast = true; _isRunning = true; SetConnectionState(ConnectionState.Discovering); ConvaiLogger.Info($"🔍 Peer Discovery started - Player {localPlayerID} on port {_listenPort}", ConvaiLogger.LogCategory.Character); // Start listening for discovery packets _ = ListenForDiscoveryPackets(_cancellationTokenSource.Token); // Start broadcasting discovery requests _ = BroadcastDiscoveryRequests(_cancellationTokenSource.Token); // Start heartbeat when connected _ = SendHeartbeats(_cancellationTokenSource.Token); } catch (Exception ex) { ConvaiLogger.Error($"Failed to start peer discovery: {ex.Message}", ConvaiLogger.LogCategory.Character); } } private void StopDiscovery() { if (!_isRunning) return; _isRunning = false; _udpClient?.Close(); _udpClient?.Dispose(); _udpClient = null; SetConnectionState(ConnectionState.Disconnected); ConvaiLogger.Info("Peer Discovery stopped", ConvaiLogger.LogCategory.Character); } private async Task ListenForDiscoveryPackets(CancellationToken cancellationToken) { while (_isRunning && !cancellationToken.IsCancellationRequested) { try { var result = await _udpClient.ReceiveAsync(); await ProcessDiscoveryPacket(result.Buffer, result.RemoteEndPoint); } catch (ObjectDisposedException) { // Normal when stopping break; } catch (Exception ex) { if (_isRunning) { ConvaiLogger.Error($"Error receiving discovery packet: {ex.Message}", ConvaiLogger.LogCategory.Character); } } } } private async Task BroadcastDiscoveryRequests(CancellationToken cancellationToken) { while (_isRunning && !cancellationToken.IsCancellationRequested) { try { // Only broadcast if we're still discovering if (_connectionState == ConnectionState.Discovering || _connectionState == ConnectionState.Lost) { await SendDiscoveryRequest(); } await Task.Delay((int)(discoveryBroadcastInterval * 1000), cancellationToken); } catch (OperationCanceledException) { break; } catch (Exception ex) { ConvaiLogger.Error($"Error broadcasting discovery: {ex.Message}", ConvaiLogger.LogCategory.Character); } } } private async Task SendHeartbeats(CancellationToken cancellationToken) { while (_isRunning && !cancellationToken.IsCancellationRequested) { try { await Task.Delay((int)(heartbeatInterval * 1000), cancellationToken); // Only send heartbeat if connected if (_connectionState == ConnectionState.Connected && !string.IsNullOrEmpty(_peerIP)) { await SendHeartbeat(); } } catch (OperationCanceledException) { break; } catch (Exception ex) { ConvaiLogger.Error($"Error sending heartbeat: {ex.Message}", ConvaiLogger.LogCategory.Character); } } } private async Task SendDiscoveryRequest() { try { byte[] packet = CreateDiscoveryPacket(PACKET_TYPE_REQUEST); // Broadcast to subnet (will be blocked by AP isolation but we try anyway) var broadcastEndPoint = new IPEndPoint(IPAddress.Broadcast, _listenPort); await _udpClient.SendAsync(packet, packet.Length, broadcastEndPoint); if (enableDebugLogging && UnityEngine.Random.value < 0.1f) // Log 10% of broadcasts to reduce spam { ConvaiLogger.DebugLog($"🔍 Broadcasting discovery request (Player {localPlayerID})", ConvaiLogger.LogCategory.Character); } } catch (Exception ex) { ConvaiLogger.Error($"Failed to send discovery request: {ex.Message}", ConvaiLogger.LogCategory.Character); } } private async Task SendDiscoveryResponse(IPEndPoint targetEndPoint) { try { byte[] packet = CreateDiscoveryPacket(PACKET_TYPE_RESPONSE); await _udpClient.SendAsync(packet, packet.Length, targetEndPoint); if (enableDebugLogging) { ConvaiLogger.DebugLog($"📤 Sent discovery response to {targetEndPoint.Address}", ConvaiLogger.LogCategory.Character); } } catch (Exception ex) { ConvaiLogger.Error($"Failed to send discovery response: {ex.Message}", ConvaiLogger.LogCategory.Character); } } private async Task SendHeartbeat() { try { if (string.IsNullOrEmpty(_peerIP)) return; byte[] packet = CreateDiscoveryPacket(PACKET_TYPE_HEARTBEAT); var peerEndPoint = new IPEndPoint(IPAddress.Parse(_peerIP), _listenPort); await _udpClient.SendAsync(packet, packet.Length, peerEndPoint); } catch (Exception ex) { ConvaiLogger.Warn($"Failed to send heartbeat: {ex.Message}", ConvaiLogger.LogCategory.Character); } } private byte[] CreateDiscoveryPacket(byte packetType) { byte[] packet = new byte[14]; int offset = 0; // Magic number BitConverter.GetBytes(DISCOVERY_MAGIC).CopyTo(packet, offset); offset += 4; // Packet type packet[offset] = packetType; offset += 1; // Player ID packet[offset] = localPlayerID; offset += 1; // Timestamp long timestamp = DateTime.UtcNow.Ticks; BitConverter.GetBytes(timestamp).CopyTo(packet, offset); return packet; } private async Task ProcessDiscoveryPacket(byte[] data, IPEndPoint senderEndPoint) { try { // Check if this is a discovery packet if (data.Length < 14) return; // Verify magic number uint magic = BitConverter.ToUInt32(data, 0); if (magic != DISCOVERY_MAGIC) return; // Parse packet byte packetType = data[4]; byte senderPlayerID = data[5]; long timestamp = BitConverter.ToInt64(data, 6); // Ignore packets from ourselves if (senderPlayerID == localPlayerID) return; string senderIP = senderEndPoint.Address.ToString(); if (enableDebugLogging) { string typeStr = packetType switch { PACKET_TYPE_REQUEST => "REQUEST", PACKET_TYPE_RESPONSE => "RESPONSE", PACKET_TYPE_HEARTBEAT => "HEARTBEAT", _ => "UNKNOWN" }; ConvaiLogger.DebugLog($"📥 Received {typeStr} from Player {senderPlayerID} at {senderIP}", ConvaiLogger.LogCategory.Character); } // Update peer info _lastPeerPacketTime = DateTime.UtcNow; switch (packetType) { case PACKET_TYPE_REQUEST: // Respond to discovery request await SendDiscoveryResponse(senderEndPoint); // If we weren't connected before, this is a new peer if (_connectionState != ConnectionState.Connected) { HandlePeerDiscovered(senderIP, senderPlayerID); } break; case PACKET_TYPE_RESPONSE: // Discovery response received - we found our peer! if (_connectionState != ConnectionState.Connected) { HandlePeerDiscovered(senderIP, senderPlayerID); } break; case PACKET_TYPE_HEARTBEAT: // Heartbeat keeps connection alive if (_connectionState == ConnectionState.Connected && _peerIP == senderIP) { // Connection still alive } else if (_connectionState != ConnectionState.Connected) { // Reconnected HandlePeerDiscovered(senderIP, senderPlayerID); } break; } } catch (Exception ex) { ConvaiLogger.Error($"Error processing discovery packet: {ex.Message}", ConvaiLogger.LogCategory.Character); } } private void HandlePeerDiscovered(string peerIP, byte peerPlayerID) { _peerIP = peerIP; _peerPlayerID = peerPlayerID; _lastPeerPacketTime = DateTime.UtcNow; SetConnectionState(ConnectionState.Connected); ConvaiLogger.Info($"✅ Peer discovered! Player {peerPlayerID} at {peerIP}", ConvaiLogger.LogCategory.Character); // Notify listeners OnPeerDiscovered?.Invoke(peerIP); } private void HandlePeerLost() { ConvaiLogger.Warn($"⚠️ Peer connection lost (Player {_peerPlayerID} at {_peerIP})", ConvaiLogger.LogCategory.Character); string lostPeerIP = _peerIP; _peerIP = ""; _peerPlayerID = 0; SetConnectionState(ConnectionState.Lost); // Notify listeners OnPeerLost?.Invoke(); // Restart discovery SetConnectionState(ConnectionState.Discovering); } private void SetConnectionState(ConnectionState newState) { if (_connectionState != newState) { _connectionState = newState; OnConnectionStateChanged?.Invoke(newState); if (enableDebugLogging) { ConvaiLogger.Info($"🔄 Connection state changed to: {newState}", ConvaiLogger.LogCategory.Character); } } } // Public methods for manual control public void RestartDiscovery() { ConvaiLogger.Info("Manually restarting peer discovery", ConvaiLogger.LogCategory.Character); _peerIP = ""; _peerPlayerID = 0; SetConnectionState(ConnectionState.Discovering); } public void ShowStatus() { ConvaiLogger.Info($"=== Peer Discovery Status ===", ConvaiLogger.LogCategory.Character); ConvaiLogger.Info($"Local Player ID: {localPlayerID}", ConvaiLogger.LogCategory.Character); ConvaiLogger.Info($"Connection State: {_connectionState}", ConvaiLogger.LogCategory.Character); ConvaiLogger.Info($"Peer IP: {(_peerIP ?? "None")}", ConvaiLogger.LogCategory.Character); ConvaiLogger.Info($"Peer Player ID: {_peerPlayerID}", ConvaiLogger.LogCategory.Character); ConvaiLogger.Info($"Listen Port: {_listenPort}", ConvaiLogger.LogCategory.Character); } } }