using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Plugin.Services; using Newtonsoft.Json; using QuestionableCompanion.Models; namespace QuestionableCompanion.Services; public class LANHelperClient : IDisposable { public class ChauffeurMessageEventArgs : EventArgs { public LANMessageType Type { get; } public LANChauffeurResponse Data { get; } public ChauffeurMessageEventArgs(LANMessageType type, LANChauffeurResponse data) { Type = type; Data = data; } } private readonly IPluginLog log; private readonly IClientState clientState; private readonly IFramework framework; private readonly Configuration config; private readonly Dictionary activeConnections = new Dictionary(); private readonly Dictionary discoveredHelpers = new Dictionary(); private CancellationTokenSource? cancellationTokenSource; private string cachedPlayerName = string.Empty; private ushort cachedWorldId; public IReadOnlyList DiscoveredHelpers => discoveredHelpers.Values.ToList(); public event EventHandler? OnChauffeurMessageReceived; public LANHelperClient(IPluginLog log, IClientState clientState, IFramework framework, Configuration config) { this.log = log; this.clientState = clientState; this.framework = framework; this.config = config; framework.Update += OnFrameworkUpdate; } private void OnFrameworkUpdate(IFramework fw) { IPlayerCharacter player = clientState.LocalPlayer; if (player != null) { cachedPlayerName = player.Name.ToString(); cachedWorldId = (ushort)player.HomeWorld.RowId; } } public async Task Initialize() { if (!config.EnableLANHelpers) { return; } cancellationTokenSource = new CancellationTokenSource(); log.Information("[LANClient] Initializing LAN Helper Client..."); foreach (string ip in config.LANHelperIPs) { await ConnectToHelperAsync(ip); } Task.Run(() => HeartbeatMonitorAsync(cancellationTokenSource.Token)); } private async Task HeartbeatMonitorAsync(CancellationToken cancellationToken) { log.Information("[LANClient] Heartbeat monitor started (30s interval)"); while (!cancellationToken.IsCancellationRequested) { try { await Task.Delay(30000, cancellationToken); foreach (string ip in config.LANHelperIPs.ToList()) { if (!activeConnections.ContainsKey(ip) || !activeConnections[ip].Connected) { log.Debug("[LANClient] Heartbeat: " + ip + " disconnected, reconnecting..."); await ConnectToHelperAsync(ip); continue; } LANHeartbeat heartbeatData = new LANHeartbeat { ClientName = (string.IsNullOrEmpty(cachedPlayerName) ? "Unknown" : cachedPlayerName), ClientWorldId = cachedWorldId, ClientRole = (config.IsQuester ? "Quester" : "Helper") }; await SendMessageAsync(ip, new LANMessage(LANMessageType.HEARTBEAT, heartbeatData)); log.Debug($"[LANClient] Heartbeat sent to {ip} (as {heartbeatData.ClientName}@{heartbeatData.ClientWorldId})"); } foreach (LANHelperInfo helper in discoveredHelpers.Values.ToList()) { if (!string.IsNullOrEmpty(helper.IPAddress)) { LANHeartbeat heartbeatData = new LANHeartbeat { ClientName = (string.IsNullOrEmpty(cachedPlayerName) ? "Unknown" : cachedPlayerName), ClientWorldId = cachedWorldId, ClientRole = (config.IsQuester ? "Quester" : "Helper") }; await SendMessageAsync(helper.IPAddress, new LANMessage(LANMessageType.HEARTBEAT, heartbeatData)); log.Information($"[LANClient] Heartbeat sent to discovered helper {helper.Name}@{helper.IPAddress} (as {heartbeatData.ClientName}, Role={heartbeatData.ClientRole})"); } } } catch (OperationCanceledException) { break; } catch (Exception ex2) { log.Error("[LANClient] Heartbeat monitor error: " + ex2.Message); } } log.Information("[LANClient] Heartbeat monitor stopped"); } public async Task ConnectToHelperAsync(string ipAddress) { if (activeConnections.ContainsKey(ipAddress)) { log.Debug("[LANClient] Already connected to " + ipAddress); return true; } try { log.Information($"[LANClient] Connecting to helper at {ipAddress}:{config.LANServerPort}..."); TcpClient client = new TcpClient(); await client.ConnectAsync(ipAddress, config.LANServerPort); activeConnections[ipAddress] = client; log.Information("[LANClient] ✓ Connected to " + ipAddress); LANHelperStatusResponse statusResponse = await RequestHelperStatusAsync(ipAddress); if (statusResponse != null) { discoveredHelpers[ipAddress] = new LANHelperInfo { Name = statusResponse.Name, WorldId = statusResponse.WorldId, IPAddress = ipAddress, Status = statusResponse.Status, LastSeen = DateTime.Now }; log.Information($"[LANClient] Helper discovered: {statusResponse.Name} ({statusResponse.Status})"); } Task.Run(() => ListenToHelperAsync(ipAddress, client), cancellationTokenSource.Token); return true; } catch (Exception ex) { log.Error("[LANClient] Failed to connect to " + ipAddress + ": " + ex.Message); return false; } } private async Task ListenToHelperAsync(string ipAddress, TcpClient client) { try { using NetworkStream stream = client.GetStream(); using StreamReader reader = new StreamReader(stream, Encoding.UTF8); while (client.Connected && !cancellationTokenSource.Token.IsCancellationRequested) { string line = await reader.ReadLineAsync(cancellationTokenSource.Token); if (string.IsNullOrEmpty(line)) { break; } try { LANMessage message = JsonConvert.DeserializeObject(line); if (message != null) { HandleHelperMessage(ipAddress, message); } } catch (JsonException ex) { log.Error("[LANClient] Invalid message from " + ipAddress + ": " + ex.Message); } } } catch (Exception ex2) { log.Error("[LANClient] Connection to " + ipAddress + " lost: " + ex2.Message); } finally { log.Information("[LANClient] Disconnected from " + ipAddress); activeConnections.Remove(ipAddress); client.Close(); } } private void HandleHelperMessage(string ipAddress, LANMessage message) { log.Debug($"[LANClient] Received {message.Type} from {ipAddress}"); switch (message.Type) { case LANMessageType.HELPER_STATUS: { LANHelperStatusResponse status = message.GetData(); if (status == null) { break; } if (!discoveredHelpers.ContainsKey(ipAddress)) { log.Information($"[LANClient] New helper discovered via status: {status.Name}@{status.WorldId} ({ipAddress})"); discoveredHelpers[ipAddress] = new LANHelperInfo { Name = status.Name, WorldId = status.WorldId, IPAddress = ipAddress, Status = status.Status, LastSeen = DateTime.Now }; } else { discoveredHelpers[ipAddress].Status = status.Status; discoveredHelpers[ipAddress].LastSeen = DateTime.Now; if (discoveredHelpers[ipAddress].Name != status.Name) { discoveredHelpers[ipAddress].Name = status.Name; discoveredHelpers[ipAddress].WorldId = status.WorldId; } } break; } case LANMessageType.INVITE_ACCEPTED: log.Information("[LANClient] ✓ Helper at " + ipAddress + " accepted invite"); break; case LANMessageType.FOLLOW_STARTED: log.Information("[LANClient] ✓ Helper at " + ipAddress + " started following"); break; case LANMessageType.FOLLOW_ARRIVED: log.Information("[LANClient] ✓ Helper at " + ipAddress + " arrived at destination"); break; case LANMessageType.HELPER_READY: log.Information("[LANClient] ✓ Helper at " + ipAddress + " is ready"); break; case LANMessageType.HELPER_IN_PARTY: log.Information("[LANClient] ✓ Helper at " + ipAddress + " joined party"); break; case LANMessageType.HELPER_IN_DUTY: log.Information("[LANClient] ✓ Helper at " + ipAddress + " entered duty"); break; case LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT: { LANChauffeurResponse readyData = message.GetData(); if (readyData != null) { log.Information($"[LANClient] Received Chauffeur Mount Ready from {readyData.QuesterName}@{readyData.QuesterWorldId}"); this.OnChauffeurMessageReceived?.Invoke(this, new ChauffeurMessageEventArgs(LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT, readyData)); } break; } case LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST: { LANChauffeurResponse arrivedData = message.GetData(); if (arrivedData != null) { log.Information($"[LANClient] Received Chauffeur Arrived from {arrivedData.QuesterName}@{arrivedData.QuesterWorldId}"); this.OnChauffeurMessageReceived?.Invoke(this, new ChauffeurMessageEventArgs(LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST, arrivedData)); } break; } case LANMessageType.INVITE_NOTIFICATION: case LANMessageType.DUTY_COMPLETE: case LANMessageType.FOLLOW_COMMAND: case LANMessageType.CHAUFFEUR_PICKUP_REQUEST: break; } } public async Task RequestHelperAsync(string ipAddress, string dutyName = "") { IPlayerCharacter player = clientState.LocalPlayer; if (player == null) { return false; } LANHelperRequest request = new LANHelperRequest { QuesterName = player.Name.ToString(), QuesterWorldId = (ushort)player.HomeWorld.RowId, DutyName = dutyName }; return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.REQUEST_HELPER, request)); } public async Task RequestHelperStatusAsync(string ipAddress) { if (!(await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.HELPER_STATUS)))) { return null; } await Task.Delay(500); if (discoveredHelpers.TryGetValue(ipAddress, out LANHelperInfo helper)) { return new LANHelperStatusResponse { Name = helper.Name, WorldId = helper.WorldId, Status = helper.Status }; } return null; } public async Task SendFollowCommandAsync(string ipAddress, float x, float y, float z, uint territoryId) { LANFollowCommand followCmd = new LANFollowCommand { X = x, Y = y, Z = z, TerritoryId = territoryId }; log.Information($"[LANClient] Sending follow command to {ipAddress}: ({x:F2}, {y:F2}, {z:F2})"); return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.FOLLOW_COMMAND, followCmd)); } public async Task NotifyInviteSentAsync(string ipAddress, string helperName) { log.Information("[LANClient] Notifying " + ipAddress + " of invite sent to " + helperName); return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.INVITE_NOTIFICATION, helperName)); } private async Task SendMessageAsync(string ipAddress, LANMessage message) { _ = 2; try { if (!activeConnections.ContainsKey(ipAddress) && !(await ConnectToHelperAsync(ipAddress))) { return false; } TcpClient client = activeConnections[ipAddress]; if (!client.Connected) { log.Warning("[LANClient] Not connected to " + ipAddress + ", reconnecting..."); activeConnections.Remove(ipAddress); if (!(await ConnectToHelperAsync(ipAddress))) { return false; } client = activeConnections[ipAddress]; } string json = JsonConvert.SerializeObject(message); byte[] bytes = Encoding.UTF8.GetBytes(json + "\n"); await client.GetStream().WriteAsync(bytes, 0, bytes.Length); log.Debug($"[LANClient] Sent {message.Type} to {ipAddress}"); return true; } catch (Exception ex) { log.Error("[LANClient] Failed to send message to " + ipAddress + ": " + ex.Message); return false; } } public async Task ScanNetworkAsync(int timeoutSeconds = 5) { log.Information($"[LANClient] \ud83d\udce1 Scanning network for helpers (timeout: {timeoutSeconds}s)..."); int foundCount = 0; try { using UdpClient udpClient = new UdpClient(47789); udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, optionValue: true); CancellationTokenSource cancellation = new CancellationTokenSource(); cancellation.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); while (!cancellation.Token.IsCancellationRequested) { try { UdpReceiveResult result = await udpClient.ReceiveAsync(cancellation.Token); dynamic announcement = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(result.Buffer)); if (announcement?.Type == "HELPER_ANNOUNCE") { string helperIP = result.RemoteEndPoint.Address.ToString(); string helperName = (string)announcement.Name; _ = (int)announcement.Port; log.Information("[LANClient] ✓ Found helper: " + helperName + " at " + helperIP); if (!config.LANHelperIPs.Contains(helperIP)) { config.LANHelperIPs.Add(helperIP); config.Save(); log.Information("[LANClient] → Added " + helperIP + " to configuration"); foundCount++; } await ConnectToHelperAsync(helperIP); } } catch (OperationCanceledException) { break; } catch (Exception ex2) { log.Debug("[LANClient] Scan error: " + ex2.Message); } } } catch (Exception ex3) { log.Error("[LANClient] Network scan failed: " + ex3.Message); } if (foundCount > 0) { log.Information($"[LANClient] ✓ Scan complete: Found {foundCount} new helper(s)"); } else { log.Information("[LANClient] Scan complete: No new helpers found"); } return foundCount; } public LANHelperInfo? GetFirstAvailableHelper() { return (from h in discoveredHelpers.Values where h.Status == LANHelperStatus.Available orderby h.LastSeen select h).FirstOrDefault(); } public async Task SendChauffeurSummonAsync(string ipAddress, LANChauffeurSummon summonData) { log.Information("[LANClient] *** SENDING CHAUFFEUR_PICKUP_REQUEST to " + ipAddress + " ***"); log.Information($"[LANClient] Summon data: Quester={summonData.QuesterName}@{summonData.QuesterWorldId}, Zone={summonData.ZoneId}"); LANMessage message = new LANMessage(LANMessageType.CHAUFFEUR_PICKUP_REQUEST, summonData); bool num = await SendMessageAsync(ipAddress, message); if (num) { log.Information("[LANClient] ✓ CHAUFFEUR_PICKUP_REQUEST sent successfully to " + ipAddress); } else { log.Error("[LANClient] ✗ FAILED to send CHAUFFEUR_PICKUP_REQUEST to " + ipAddress); } return num; } public void DisconnectAll() { log.Information("[LANClient] Disconnecting from all helpers..."); foreach (KeyValuePair kvp in activeConnections.ToList()) { try { SendMessageAsync(kvp.Key, new LANMessage(LANMessageType.DISCONNECT)).Wait(1000); kvp.Value.Close(); } catch { } } activeConnections.Clear(); discoveredHelpers.Clear(); } public void Dispose() { cancellationTokenSource?.Cancel(); DisconnectAll(); } }