qstbak/QuestionableCompanion/QuestionableCompanion.Services/LANHelperClient.cs
2025-12-07 10:54:53 +10:00

489 lines
15 KiB
C#

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<string, TcpClient> activeConnections = new Dictionary<string, TcpClient>();
private readonly Dictionary<string, LANHelperInfo> discoveredHelpers = new Dictionary<string, LANHelperInfo>();
private CancellationTokenSource? cancellationTokenSource;
private string cachedPlayerName = string.Empty;
private ushort cachedWorldId;
public IReadOnlyList<LANHelperInfo> DiscoveredHelpers => discoveredHelpers.Values.ToList();
public event EventHandler<ChauffeurMessageEventArgs>? 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<bool> 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<LANMessage>(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<LANHelperStatusResponse>();
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<LANChauffeurResponse>();
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<LANChauffeurResponse>();
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<bool> 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<LANHelperStatusResponse?> 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<bool> 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<bool> 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<bool> 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<int> 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<object>(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<bool> 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<string, TcpClient> 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();
}
}