qstcompanion v1.0.6
This commit is contained in:
parent
5e1e1decc5
commit
ada27cf05b
30 changed files with 3403 additions and 426 deletions
|
|
@ -0,0 +1,489 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue