489 lines
15 KiB
C#
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();
|
|
}
|
|
}
|