577 lines
16 KiB
C#
577 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Numerics;
|
|
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 LANHelperServer : IDisposable
|
|
{
|
|
private readonly IPluginLog log;
|
|
|
|
private readonly IClientState clientState;
|
|
|
|
private readonly IFramework framework;
|
|
|
|
private readonly Configuration config;
|
|
|
|
private readonly PartyInviteAutoAccept partyInviteAutoAccept;
|
|
|
|
private readonly ICommandManager commandManager;
|
|
|
|
private readonly Plugin plugin;
|
|
|
|
private TcpListener? listener;
|
|
|
|
private CancellationTokenSource? cancellationTokenSource;
|
|
|
|
private readonly List<TcpClient> connectedClients = new List<TcpClient>();
|
|
|
|
private readonly Dictionary<string, TcpClient> activeConnections = new Dictionary<string, TcpClient>();
|
|
|
|
private readonly Dictionary<string, DateTime> knownQuesters = new Dictionary<string, DateTime>();
|
|
|
|
private bool isRunning;
|
|
|
|
private string? cachedPlayerName;
|
|
|
|
private ushort cachedWorldId;
|
|
|
|
private DateTime lastCacheRefresh = DateTime.MinValue;
|
|
|
|
private const int CACHE_REFRESH_SECONDS = 30;
|
|
|
|
public bool IsRunning => isRunning;
|
|
|
|
public int ConnectedClientCount => connectedClients.Count;
|
|
|
|
public List<string> GetConnectedClientNames()
|
|
{
|
|
DateTime now = DateTime.Now;
|
|
foreach (string s in (from kvp in knownQuesters
|
|
where (now - kvp.Value).TotalSeconds > 60.0
|
|
select kvp.Key).ToList())
|
|
{
|
|
knownQuesters.Remove(s);
|
|
}
|
|
return knownQuesters.Keys.ToList();
|
|
}
|
|
|
|
public LANHelperServer(IPluginLog log, IClientState clientState, IFramework framework, Configuration config, PartyInviteAutoAccept partyInviteAutoAccept, ICommandManager commandManager, Plugin plugin)
|
|
{
|
|
this.log = log;
|
|
this.clientState = clientState;
|
|
this.framework = framework;
|
|
this.config = config;
|
|
this.partyInviteAutoAccept = partyInviteAutoAccept;
|
|
this.commandManager = commandManager;
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
public void Start()
|
|
{
|
|
if (isRunning)
|
|
{
|
|
log.Warning("[LANServer] Server already running");
|
|
return;
|
|
}
|
|
Task.Run(async delegate
|
|
{
|
|
try
|
|
{
|
|
framework.Update += OnFrameworkUpdate;
|
|
int retries = 5;
|
|
while (retries > 0)
|
|
{
|
|
try
|
|
{
|
|
listener = new TcpListener(IPAddress.Any, config.LANServerPort);
|
|
listener.Start();
|
|
}
|
|
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
|
|
{
|
|
retries--;
|
|
if (retries == 0)
|
|
{
|
|
throw;
|
|
}
|
|
log.Warning($"[LANServer] Port {config.LANServerPort} in use, retrying in 1s... ({retries} retries left)");
|
|
await Task.Delay(1000);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
cancellationTokenSource = new CancellationTokenSource();
|
|
isRunning = true;
|
|
log.Information("[LANServer] ===== LAN HELPER SERVER STARTED (v2-DEBUG) =====");
|
|
log.Information($"[LANServer] Listening on port {config.LANServerPort}");
|
|
log.Information("[LANServer] Waiting for player info cache... (via framework update)");
|
|
Task.Run(() => AcceptClientsAsync(cancellationTokenSource.Token));
|
|
Task.Run(() => BroadcastPresenceAsync(cancellationTokenSource.Token));
|
|
}
|
|
catch (Exception ex2)
|
|
{
|
|
log.Error("[LANServer] Failed to start server: " + ex2.Message);
|
|
isRunning = false;
|
|
framework.Update -= OnFrameworkUpdate;
|
|
}
|
|
});
|
|
}
|
|
|
|
private void OnFrameworkUpdate(IFramework framework)
|
|
{
|
|
if (isRunning)
|
|
{
|
|
DateTime now = DateTime.Now;
|
|
if ((now - lastCacheRefresh).TotalSeconds >= 30.0)
|
|
{
|
|
log.Debug($"[LANServer] Framework.Update triggered cache refresh (last: {(now - lastCacheRefresh).TotalSeconds:F1}s ago)");
|
|
RefreshPlayerCache();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RefreshPlayerCache()
|
|
{
|
|
try
|
|
{
|
|
log.Debug("[LANServer] RefreshPlayerCache called");
|
|
IPlayerCharacter player = clientState.LocalPlayer;
|
|
if (player != null)
|
|
{
|
|
string newName = player.Name.ToString();
|
|
ushort newWorldId = (ushort)player.HomeWorld.RowId;
|
|
if (cachedPlayerName != newName || cachedWorldId != newWorldId)
|
|
{
|
|
if (cachedPlayerName == null)
|
|
{
|
|
log.Information($"[LANServer] ✓ Player info cached: {newName}@{newWorldId}");
|
|
}
|
|
else
|
|
{
|
|
log.Information($"[LANServer] Player info updated: {newName}@{newWorldId}");
|
|
}
|
|
cachedPlayerName = newName;
|
|
cachedWorldId = newWorldId;
|
|
}
|
|
lastCacheRefresh = DateTime.Now;
|
|
}
|
|
else
|
|
{
|
|
log.Warning("[LANServer] RefreshPlayerCache: LocalPlayer is NULL!");
|
|
}
|
|
lastCacheRefresh = DateTime.Now;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log.Error("[LANServer] RefreshPlayerCache ERROR: " + ex.Message);
|
|
log.Error("[LANServer] Stack: " + ex.StackTrace);
|
|
}
|
|
}
|
|
|
|
private async Task BroadcastPresenceAsync(CancellationToken cancellationToken)
|
|
{
|
|
_ = 3;
|
|
try
|
|
{
|
|
using UdpClient udpClient = new UdpClient();
|
|
udpClient.EnableBroadcast = true;
|
|
IPEndPoint broadcastEndpoint = new IPEndPoint(IPAddress.Broadcast, 47789);
|
|
if (cachedPlayerName == null)
|
|
{
|
|
return;
|
|
}
|
|
string json = JsonConvert.SerializeObject(new
|
|
{
|
|
Type = "HELPER_ANNOUNCE",
|
|
Name = cachedPlayerName,
|
|
WorldId = cachedWorldId,
|
|
Port = config.LANServerPort
|
|
});
|
|
byte[] bytes = Encoding.UTF8.GetBytes(json);
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
await udpClient.SendAsync(bytes, bytes.Length, broadcastEndpoint);
|
|
log.Information($"[LANServer] \ud83d\udce1 Broadcast announcement sent ({i + 1}/3)");
|
|
await Task.Delay(500, cancellationToken);
|
|
}
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
await Task.Delay(30000, cancellationToken);
|
|
await udpClient.SendAsync(bytes, bytes.Length, broadcastEndpoint);
|
|
log.Debug("[LANServer] Broadcast presence updated");
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
catch (Exception ex2)
|
|
{
|
|
log.Error("[LANServer] UDP broadcast error: " + ex2.Message);
|
|
}
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
if (!isRunning && listener == null)
|
|
{
|
|
return;
|
|
}
|
|
log.Information("[LANServer] Stopping server...");
|
|
isRunning = false;
|
|
cancellationTokenSource?.Cancel();
|
|
framework.Update -= OnFrameworkUpdate;
|
|
lock (connectedClients)
|
|
{
|
|
foreach (TcpClient client in connectedClients.ToList())
|
|
{
|
|
try
|
|
{
|
|
if (client.Connected)
|
|
{
|
|
try
|
|
{
|
|
NetworkStream stream = client.GetStream();
|
|
if (stream.CanWrite)
|
|
{
|
|
string json = JsonConvert.SerializeObject(new LANMessage(LANMessageType.DISCONNECT));
|
|
byte[] bytes = Encoding.UTF8.GetBytes(json + "\n");
|
|
stream.Write(bytes, 0, bytes.Length);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
client.Close();
|
|
client.Dispose();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
connectedClients.Clear();
|
|
}
|
|
try
|
|
{
|
|
listener?.Stop();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log.Warning("[LANServer] Error stopping listener: " + ex.Message);
|
|
}
|
|
listener = null;
|
|
log.Information("[LANServer] Server stopped");
|
|
}
|
|
|
|
private async Task AcceptClientsAsync(CancellationToken cancellationToken)
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested && isRunning)
|
|
{
|
|
try
|
|
{
|
|
TcpClient client = await listener.AcceptTcpClientAsync(cancellationToken);
|
|
string clientIP = ((IPEndPoint)client.Client.RemoteEndPoint).Address.ToString();
|
|
log.Information("[LANServer] Client connected from " + clientIP);
|
|
lock (connectedClients)
|
|
{
|
|
connectedClients.Add(client);
|
|
}
|
|
Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception ex2)
|
|
{
|
|
log.Error("[LANServer] Error accepting client: " + ex2.Message);
|
|
await Task.Delay(1000, cancellationToken);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
|
|
{
|
|
string clientIP = ((IPEndPoint)client.Client.RemoteEndPoint).Address.ToString();
|
|
try
|
|
{
|
|
using NetworkStream stream = client.GetStream();
|
|
using StreamReader reader = new StreamReader(stream, Encoding.UTF8);
|
|
while (!cancellationToken.IsCancellationRequested && client.Connected)
|
|
{
|
|
string line = await reader.ReadLineAsync(cancellationToken);
|
|
if (string.IsNullOrEmpty(line))
|
|
{
|
|
break;
|
|
}
|
|
try
|
|
{
|
|
LANMessage message = JsonConvert.DeserializeObject<LANMessage>(line);
|
|
if (message != null)
|
|
{
|
|
await HandleMessageAsync(client, message, clientIP);
|
|
}
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
log.Error("[LANServer] Invalid message from " + clientIP + ": " + ex.Message);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex2)
|
|
{
|
|
log.Error("[LANServer] Client " + clientIP + " error: " + ex2.Message);
|
|
}
|
|
finally
|
|
{
|
|
log.Information("[LANServer] Client " + clientIP + " disconnected");
|
|
lock (connectedClients)
|
|
{
|
|
connectedClients.Remove(client);
|
|
}
|
|
client.Close();
|
|
}
|
|
}
|
|
|
|
private async Task HandleMessageAsync(TcpClient client, LANMessage message, string clientIP)
|
|
{
|
|
log.Debug($"[LANServer] Received {message.Type} from {clientIP}");
|
|
switch (message.Type)
|
|
{
|
|
case LANMessageType.REQUEST_HELPER:
|
|
await HandleHelperRequest(client, message);
|
|
break;
|
|
case LANMessageType.HELPER_STATUS:
|
|
await HandleStatusRequest(client);
|
|
break;
|
|
case LANMessageType.INVITE_NOTIFICATION:
|
|
await HandleInviteNotification(client, message);
|
|
break;
|
|
case LANMessageType.FOLLOW_COMMAND:
|
|
await HandleFollowCommand(client, message);
|
|
break;
|
|
case LANMessageType.CHAUFFEUR_PICKUP_REQUEST:
|
|
await HandleChauffeurSummon(message);
|
|
break;
|
|
case LANMessageType.HEARTBEAT:
|
|
{
|
|
LANHeartbeat heartbeatData = message.GetData<LANHeartbeat>();
|
|
if (heartbeatData != null && heartbeatData.ClientRole == "Quester" && !string.IsNullOrEmpty(heartbeatData.ClientName))
|
|
{
|
|
string questerKey = $"{heartbeatData.ClientName}@{heartbeatData.ClientWorldId}";
|
|
knownQuesters[questerKey] = DateTime.Now;
|
|
}
|
|
SendMessage(client, new LANMessage(LANMessageType.HEARTBEAT));
|
|
break;
|
|
}
|
|
default:
|
|
log.Debug($"[LANServer] Unhandled message type: {message.Type}");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task HandleHelperRequest(TcpClient client, LANMessage message)
|
|
{
|
|
LANHelperRequest request = message.GetData<LANHelperRequest>();
|
|
if (request != null)
|
|
{
|
|
log.Information("[LANServer] Helper requested by " + request.QuesterName + " for duty: " + request.DutyName);
|
|
await SendCurrentStatus(client);
|
|
partyInviteAutoAccept.EnableForQuester(request.QuesterName);
|
|
log.Information("[LANServer] Auto-accept enabled for " + request.QuesterName);
|
|
}
|
|
}
|
|
|
|
private async Task HandleStatusRequest(TcpClient client)
|
|
{
|
|
await SendCurrentStatus(client);
|
|
}
|
|
|
|
private async Task SendCurrentStatus(TcpClient client)
|
|
{
|
|
try
|
|
{
|
|
log.Debug("[LANServer] SendCurrentStatus: Start");
|
|
if (cachedPlayerName == null)
|
|
{
|
|
log.Warning("[LANServer] SendCurrentStatus: Player info not cached! Sending NotReady status.");
|
|
LANHelperStatusResponse notReadyStatus = new LANHelperStatusResponse
|
|
{
|
|
Name = "Unknown",
|
|
WorldId = 0,
|
|
Status = LANHelperStatus.Offline,
|
|
CurrentActivity = "Waiting for character login..."
|
|
};
|
|
SendMessage(client, new LANMessage(LANMessageType.HELPER_STATUS, notReadyStatus));
|
|
return;
|
|
}
|
|
log.Debug($"[LANServer] SendCurrentStatus: Cached Name={cachedPlayerName}, World={cachedWorldId}");
|
|
LANHelperStatusResponse status = new LANHelperStatusResponse
|
|
{
|
|
Name = cachedPlayerName,
|
|
WorldId = cachedWorldId,
|
|
Status = LANHelperStatus.Available,
|
|
CurrentActivity = "Ready"
|
|
};
|
|
log.Debug("[LANServer] SendCurrentStatus: Status object created");
|
|
LANMessage msg = new LANMessage(LANMessageType.HELPER_STATUS, status);
|
|
log.Debug("[LANServer] SendCurrentStatus: LANMessage created");
|
|
SendMessage(client, msg);
|
|
log.Debug("[LANServer] SendCurrentStatus: Message sent");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log.Error("[LANServer] SendCurrentStatus CRASH: " + ex.Message);
|
|
log.Error("[LANServer] Stack: " + ex.StackTrace);
|
|
}
|
|
}
|
|
|
|
private async Task HandleInviteNotification(TcpClient client, LANMessage message)
|
|
{
|
|
string questerName = message.GetData<string>();
|
|
log.Information("[LANServer] Invite notification from " + questerName);
|
|
SendMessage(client, new LANMessage(LANMessageType.INVITE_ACCEPTED));
|
|
}
|
|
|
|
private async Task HandleFollowCommand(TcpClient client, LANMessage message)
|
|
{
|
|
LANFollowCommand followCmd = message.GetData<LANFollowCommand>();
|
|
if (followCmd != null)
|
|
{
|
|
ChauffeurModeService chauffeurSvc = plugin.GetChauffeurMode();
|
|
if (chauffeurSvc == null)
|
|
{
|
|
log.Warning("[LANServer] No ChauffeurModeService available for position update");
|
|
return;
|
|
}
|
|
if (chauffeurSvc.IsTransportingQuester)
|
|
{
|
|
log.Debug("[LANServer] Ignoring FOLLOW_COMMAND - Chauffeur Mode is actively transporting");
|
|
return;
|
|
}
|
|
string questerName = config.AssignedQuesterForFollowing ?? "LAN Quester";
|
|
chauffeurSvc.UpdateQuesterPositionFromLAN(followCmd.X, followCmd.Y, followCmd.Z, followCmd.TerritoryId, questerName);
|
|
log.Debug($"[LANServer] Updated quester position: ({followCmd.X:F2}, {followCmd.Y:F2}, {followCmd.Z:F2}) Zone={followCmd.TerritoryId}");
|
|
SendMessage(client, new LANMessage(LANMessageType.FOLLOW_STARTED));
|
|
}
|
|
}
|
|
|
|
private void SendMessage(TcpClient client, LANMessage message)
|
|
{
|
|
try
|
|
{
|
|
if (client.Connected)
|
|
{
|
|
string json = JsonConvert.SerializeObject(message);
|
|
byte[] bytes = Encoding.UTF8.GetBytes(json + "\n");
|
|
client.GetStream().Write(bytes, 0, bytes.Length);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log.Error("[LANServer] Failed to send message: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
public void BroadcastMessage(LANMessage message)
|
|
{
|
|
lock (connectedClients)
|
|
{
|
|
foreach (TcpClient client in connectedClients.ToList())
|
|
{
|
|
SendMessage(client, message);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task HandleChauffeurSummon(LANMessage message)
|
|
{
|
|
LANChauffeurSummon summonData = message.GetData<LANChauffeurSummon>();
|
|
if (summonData == null)
|
|
{
|
|
log.Error("[LANServer] HandleChauffeurSummon: Failed to deserialize summon data!");
|
|
return;
|
|
}
|
|
log.Information("[LANServer] =========================================");
|
|
log.Information("[LANServer] *** CHAUFFEUR PICKUP REQUEST RECEIVED ***");
|
|
log.Information("[LANServer] =========================================");
|
|
log.Information($"[LANServer] Quester: {summonData.QuesterName}@{summonData.QuesterWorldId}");
|
|
log.Information($"[LANServer] Zone: {summonData.ZoneId}");
|
|
log.Information($"[LANServer] Target: ({summonData.TargetX:F2}, {summonData.TargetY:F2}, {summonData.TargetZ:F2})");
|
|
log.Information($"[LANServer] AttuneAetheryte: {summonData.IsAttuneAetheryte}");
|
|
ChauffeurModeService chauffeur = plugin.GetChauffeurMode();
|
|
if (chauffeur != null)
|
|
{
|
|
Vector3 targetPos = new Vector3(summonData.TargetX, summonData.TargetY, summonData.TargetZ);
|
|
Vector3 questerPos = new Vector3(summonData.QuesterX, summonData.QuesterY, summonData.QuesterZ);
|
|
log.Information("[LANServer] Calling ChauffeurModeService.StartHelperWorkflow...");
|
|
await framework.RunOnFrameworkThread(delegate
|
|
{
|
|
chauffeur.StartHelperWorkflow(summonData.QuesterName, summonData.QuesterWorldId, summonData.ZoneId, targetPos, questerPos, summonData.IsAttuneAetheryte);
|
|
});
|
|
log.Information("[LANServer] StartHelperWorkflow dispatched to framework thread");
|
|
}
|
|
else
|
|
{
|
|
log.Error("[LANServer] ChauffeurModeService is null! Cannot start helper workflow.");
|
|
}
|
|
}
|
|
|
|
public void SendChauffeurMountReady(string questerName, ushort questerWorldId)
|
|
{
|
|
LANChauffeurResponse response = new LANChauffeurResponse
|
|
{
|
|
QuesterName = questerName,
|
|
QuesterWorldId = questerWorldId
|
|
};
|
|
LANMessage message = new LANMessage(LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT, response);
|
|
log.Information($"[LANServer] Sending Chauffeur Mount Ready to connected clients for {questerName}@{questerWorldId}");
|
|
lock (connectedClients)
|
|
{
|
|
foreach (TcpClient client in connectedClients.ToList())
|
|
{
|
|
if (client.Connected)
|
|
{
|
|
SendMessage(client, message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void SendChauffeurArrived(string questerName, ushort questerWorldId)
|
|
{
|
|
LANChauffeurResponse response = new LANChauffeurResponse
|
|
{
|
|
QuesterName = questerName,
|
|
QuesterWorldId = questerWorldId
|
|
};
|
|
LANMessage message = new LANMessage(LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST, response);
|
|
log.Information($"[LANServer] Sending Chauffeur Arrived to connected clients for {questerName}@{questerWorldId}");
|
|
lock (connectedClients)
|
|
{
|
|
foreach (TcpClient client in connectedClients.ToList())
|
|
{
|
|
if (client.Connected)
|
|
{
|
|
SendMessage(client, message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Stop();
|
|
}
|
|
}
|