qstcompanion v1.0.6

This commit is contained in:
alydev 2025-12-07 10:54:53 +10:00
parent 5e1e1decc5
commit ada27cf05b
30 changed files with 3403 additions and 426 deletions

View file

@ -21,6 +21,7 @@ using Lumina.Excel;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using QuestionableCompanion; using QuestionableCompanion;
using QuestionableCompanion.Models;
using QuestionableCompanion.Services; using QuestionableCompanion.Services;
public class ChauffeurModeService : IDisposable public class ChauffeurModeService : IDisposable
@ -81,6 +82,8 @@ public class ChauffeurModeService : IDisposable
private DateTime? lastZoneChangeTime; private DateTime? lastZoneChangeTime;
private DateTime lastDutyExitTime = DateTime.MinValue;
private bool isFollowingQuester; private bool isFollowingQuester;
private DateTime lastFollowCheck = DateTime.MinValue; private DateTime lastFollowCheck = DateTime.MinValue;
@ -109,6 +112,14 @@ public class ChauffeurModeService : IDisposable
public bool IsTransportingQuester => isTransportingQuester; public bool IsTransportingQuester => isTransportingQuester;
public void UpdateQuesterPositionFromLAN(float x, float y, float z, uint zoneId, string questerName)
{
lastQuesterPosition = new Vector3(x, y, z);
lastQuesterZone = zoneId;
followingQuesterName = questerName;
discoveredQuesters[questerName] = DateTime.Now;
}
public string? GetHelperStatus(string helperKey) public string? GetHelperStatus(string helperKey)
{ {
if (!helperStatuses.TryGetValue(helperKey, out string status)) if (!helperStatuses.TryGetValue(helperKey, out string status))
@ -118,6 +129,18 @@ public class ChauffeurModeService : IDisposable
return status; return status;
} }
public void StartHelperStatusBroadcast()
{
if (config.IsHighLevelHelper)
{
log.Information("[ChauffeurMode] Starting periodic helper status broadcast (Helper mode enabled)");
framework.RunOnTick(delegate
{
BroadcastHelperStatusPeriodically();
}, TimeSpan.FromSeconds(1L));
}
}
public List<string> GetDiscoveredQuesters() public List<string> GetDiscoveredQuesters()
{ {
DateTime now = DateTime.Now; DateTime now = DateTime.Now;
@ -127,7 +150,19 @@ public class ChauffeurModeService : IDisposable
{ {
discoveredQuesters.Remove(stale); discoveredQuesters.Remove(stale);
} }
return discoveredQuesters.Keys.ToList(); List<string> result = discoveredQuesters.Keys.ToList();
LANHelperServer lanServer = Plugin.Instance?.GetLANHelperServer();
if (lanServer != null)
{
foreach (string client in lanServer.GetConnectedClientNames())
{
if (!result.Contains(client))
{
result.Add(client);
}
}
}
return result;
} }
public ChauffeurModeService(Configuration config, IPluginLog log, IClientState clientState, ICondition condition, IFramework framework, ICommandManager commandManager, IDataManager dataManager, IPartyList partyList, IObjectTable objectTable, QuestionableIPC questionableIPC, CrossProcessIPC crossProcessIPC, PartyInviteService partyInviteService, PartyInviteAutoAccept partyInviteAutoAccept, IDalamudPluginInterface pluginInterface, MemoryHelper memoryHelper, MovementMonitorService? movementMonitor = null) public ChauffeurModeService(Configuration config, IPluginLog log, IClientState clientState, ICondition condition, IFramework framework, ICommandManager commandManager, IDataManager dataManager, IPartyList partyList, IObjectTable objectTable, QuestionableIPC questionableIPC, CrossProcessIPC crossProcessIPC, PartyInviteService partyInviteService, PartyInviteAutoAccept partyInviteAutoAccept, IDalamudPluginInterface pluginInterface, MemoryHelper memoryHelper, MovementMonitorService? movementMonitor = null)
@ -158,6 +193,7 @@ public class ChauffeurModeService : IDisposable
crossProcessIPC.OnHelperStatusUpdate += OnHelperStatusUpdate; crossProcessIPC.OnHelperStatusUpdate += OnHelperStatusUpdate;
crossProcessIPC.OnQuesterPositionUpdate += OnQuesterPositionUpdate; crossProcessIPC.OnQuesterPositionUpdate += OnQuesterPositionUpdate;
clientState.TerritoryChanged += OnTerritoryChanged; clientState.TerritoryChanged += OnTerritoryChanged;
condition.ConditionChange += OnConditionChanged;
if (config.IsHighLevelHelper) if (config.IsHighLevelHelper)
{ {
framework.RunOnTick(delegate framework.RunOnTick(delegate
@ -170,6 +206,15 @@ public class ChauffeurModeService : IDisposable
log.Information("[ChauffeurMode] Service initialized"); log.Information("[ChauffeurMode] Service initialized");
} }
private void OnConditionChanged(ConditionFlag flag, bool value)
{
if (flag == ConditionFlag.BoundByDuty && !value)
{
lastDutyExitTime = DateTime.Now;
log.Information("[ChauffeurMode] Left duty - starting 10s grace period for zone checks");
}
}
private void OnFrameworkUpdate(IFramework framework) private void OnFrameworkUpdate(IFramework framework)
{ {
if (config.IsHighLevelHelper && config.EnableHelperFollowing && (DateTime.Now - lastFollowCheck).TotalSeconds >= (double)config.HelperFollowCheckInterval) if (config.IsHighLevelHelper && config.EnableHelperFollowing && (DateTime.Now - lastFollowCheck).TotalSeconds >= (double)config.HelperFollowCheckInterval)
@ -202,6 +247,16 @@ public class ChauffeurModeService : IDisposable
return; return;
} }
} }
double timeSinceDutyExit = (DateTime.Now - lastDutyExitTime).TotalSeconds;
if (timeSinceDutyExit < 10.0)
{
if (timeSinceDutyExit < 1.0 || timeSinceDutyExit > 9.0)
{
log.Debug($"[WaitTerritory] Duty Grace Period: Waiting for stabilization after duty exit (elapsed: {timeSinceDutyExit:F1}s / 10.0s)");
}
}
else
{
if (objectTable.LocalPlayer == null) if (objectTable.LocalPlayer == null)
{ {
return; return;
@ -259,6 +314,7 @@ public class ChauffeurModeService : IDisposable
log.Error("[WaitTerritory] Error checking Wait(territory) task: " + ex.Message); log.Error("[WaitTerritory] Error checking Wait(territory) task: " + ex.Message);
} }
} }
}
public void CheckTaskDistance() public void CheckTaskDistance()
{ {
@ -280,6 +336,10 @@ public class ChauffeurModeService : IDisposable
return; return;
} }
} }
if ((DateTime.Now - lastDutyExitTime).TotalSeconds < 10.0)
{
return;
}
ushort currentZoneId = clientState.TerritoryType; ushort currentZoneId = clientState.TerritoryType;
if (BLACKLISTED_ZONES.Contains(currentZoneId)) if (BLACKLISTED_ZONES.Contains(currentZoneId))
{ {
@ -587,6 +647,14 @@ public class ChauffeurModeService : IDisposable
log.Information("[ChauffeurMode] ========================================"); log.Information("[ChauffeurMode] ========================================");
log.Information("[ChauffeurMode] === SUMMONING HELPER ==="); log.Information("[ChauffeurMode] === SUMMONING HELPER ===");
log.Information("[ChauffeurMode] ========================================"); log.Information("[ChauffeurMode] ========================================");
if (config.HelperSelection == HelperSelectionMode.ManualInput)
{
log.Warning("[ChauffeurMode] [QUESTER] Manual Input mode is selected!");
log.Warning("[ChauffeurMode] [QUESTER] Chauffeur Mode requires IPC communication and cannot work with Manual Input.");
log.Warning("[ChauffeurMode] [QUESTER] Please switch to 'Auto' or 'Dropdown' mode to use Chauffeur.");
log.Warning("[ChauffeurMode] [QUESTER] Walking to destination instead.");
return;
}
if (!string.IsNullOrEmpty(config.PreferredHelper)) if (!string.IsNullOrEmpty(config.PreferredHelper))
{ {
string preferredHelper = config.PreferredHelper; string preferredHelper = config.PreferredHelper;
@ -660,8 +728,89 @@ public class ChauffeurModeService : IDisposable
log.Information($"[ChauffeurMode] Quester Position: ({questerPos.X:F2}, {questerPos.Y:F2}, {questerPos.Z:F2})"); log.Information($"[ChauffeurMode] Quester Position: ({questerPos.X:F2}, {questerPos.Y:F2}, {questerPos.Z:F2})");
log.Information($"[ChauffeurMode] Target: ({targetPos.X:F2}, {targetPos.Y:F2}, {targetPos.Z:F2})"); log.Information($"[ChauffeurMode] Target: ({targetPos.X:F2}, {targetPos.Y:F2}, {targetPos.Z:F2})");
log.Information($"[ChauffeurMode] AttuneAetheryte: {isAttuneAetheryte}"); log.Information($"[ChauffeurMode] AttuneAetheryte: {isAttuneAetheryte}");
bool isLanHelper = false;
string lanIp = null;
log.Information("[ChauffeurMode] Checking if preferred helper '" + config.PreferredHelper + "' is a LAN helper...");
LANHelperClient lanClient = Plugin.Instance?.GetLANHelperClient();
if (lanClient != null)
{
IReadOnlyList<LANHelperInfo> lanHelpers = lanClient.DiscoveredHelpers;
log.Information($"[ChauffeurMode] Found {lanHelpers.Count} LAN helpers in discovery list");
if (!string.IsNullOrEmpty(config.PreferredHelper))
{
foreach (LANHelperInfo helper in lanHelpers)
{
string helperKey = $"{helper.Name}@{helper.WorldId}";
log.Information("[ChauffeurMode] Checking LAN helper: " + helperKey + " at " + helper.IPAddress);
if (helperKey == config.PreferredHelper)
{
isLanHelper = true;
lanIp = helper.IPAddress;
log.Information("[ChauffeurMode] ✓ MATCHED! This is a LAN helper at " + lanIp);
break;
}
}
if (!isLanHelper)
{
log.Information("[ChauffeurMode] No match found - PreferredHelper '" + config.PreferredHelper + "' not in LAN list");
}
}
else if (lanHelpers.Any((LANHelperInfo h) => h.Status == LANHelperStatus.Available))
{
LANHelperInfo firstAvailable = lanHelpers.FirstOrDefault((LANHelperInfo h) => h.Status == LANHelperStatus.Available);
if (firstAvailable != null)
{
isLanHelper = true;
lanIp = firstAvailable.IPAddress;
string autoSelectedKey = $"{firstAvailable.Name}@{firstAvailable.WorldId}";
log.Information("[ChauffeurMode] AUTO-SELECTED LAN helper: " + autoSelectedKey + " at " + lanIp);
}
}
else if (lanHelpers.Count > 0)
{
LANHelperInfo firstHelper = lanHelpers.First();
isLanHelper = true;
lanIp = firstHelper.IPAddress;
string autoSelectedKey2 = $"{firstHelper.Name}@{firstHelper.WorldId}";
log.Information("[ChauffeurMode] AUTO-SELECTED first LAN helper (no Available status): " + autoSelectedKey2 + " at " + lanIp);
}
else
{
log.Information("[ChauffeurMode] No PreferredHelper configured and no LAN helpers available - using local IPC");
}
}
else
{
log.Warning("[ChauffeurMode] LANHelperClient is null!");
log.Information("[ChauffeurMode] Falling back to local IPC");
}
if (isLanHelper && !string.IsNullOrEmpty(lanIp))
{
log.Information("[ChauffeurMode] Selected helper is on LAN (" + lanIp + ") - Sending LAN Summon Request");
if (lanClient != null)
{
LANChauffeurSummon summonData = new LANChauffeurSummon
{
QuesterName = questerName,
QuesterWorldId = questerWorld,
ZoneId = zoneId,
TargetX = targetPos.X,
TargetY = targetPos.Y,
TargetZ = targetPos.Z,
QuesterX = questerPos.X,
QuesterY = questerPos.Y,
QuesterZ = questerPos.Z,
IsAttuneAetheryte = isAttuneAetheryte
};
lanClient.SendChauffeurSummonAsync(lanIp, summonData);
}
}
else
{
log.Information("[ChauffeurMode] Sending local IPC Summon Request");
crossProcessIPC.SendChauffeurSummonRequest(questerName, questerWorld, zoneId, targetPos, questerPos, isAttuneAetheryte); crossProcessIPC.SendChauffeurSummonRequest(questerName, questerWorld, zoneId, targetPos, questerPos, isAttuneAetheryte);
} }
}
public bool IsRestrictedZone(uint zoneId) public bool IsRestrictedZone(uint zoneId)
{ {
@ -756,15 +905,25 @@ public class ChauffeurModeService : IDisposable
} }
} }
} }
log.Debug($"[ChauffeurMode] Found {mounts.Count} multi-seater mounts");
} }
catch (Exception ex) catch (Exception)
{ {
log.Error("[ChauffeurMode] Error loading multi-seater mounts: " + ex.Message);
} }
return mounts; return mounts;
} }
public void StartHelperWorkflow(string questerName, ushort questerWorld, uint zoneId, Vector3 targetPos, Vector3 questerPos, bool isAttuneAetheryte)
{
log.Information("[ChauffeurMode] =========================================");
log.Information("[ChauffeurMode] *** StartHelperWorkflow CALLED ***");
log.Information("[ChauffeurMode] =========================================");
log.Information($"[ChauffeurMode] Quester: {questerName}@{questerWorld}");
log.Information($"[ChauffeurMode] Zone: {zoneId}");
log.Information($"[ChauffeurMode] Target: ({targetPos.X:F2}, {targetPos.Y:F2}, {targetPos.Z:F2})");
log.Information($"[ChauffeurMode] AttuneAetheryte: {isAttuneAetheryte}");
OnChauffeurSummonRequest(questerName, questerWorld, zoneId, targetPos, questerPos, isAttuneAetheryte);
}
private void OnChauffeurSummonRequest(string questerName, ushort questerWorld, uint zoneId, Vector3 targetPos, Vector3 questerPos, bool isAttuneAetheryte) private void OnChauffeurSummonRequest(string questerName, ushort questerWorld, uint zoneId, Vector3 targetPos, Vector3 questerPos, bool isAttuneAetheryte)
{ {
if (!config.ChauffeurModeEnabled) if (!config.ChauffeurModeEnabled)
@ -1274,6 +1433,12 @@ public class ChauffeurModeService : IDisposable
log.Information($"[ChauffeurMode] [HELPER] Helper position: ({objectTable.LocalPlayer?.Position.X:F2}, {objectTable.LocalPlayer?.Position.Y:F2}, {objectTable.LocalPlayer?.Position.Z:F2})"); log.Information($"[ChauffeurMode] [HELPER] Helper position: ({objectTable.LocalPlayer?.Position.X:F2}, {objectTable.LocalPlayer?.Position.Y:F2}, {objectTable.LocalPlayer?.Position.Z:F2})");
crossProcessIPC.SendChauffeurMountReady(questerName, questerWorld); crossProcessIPC.SendChauffeurMountReady(questerName, questerWorld);
log.Information("[ChauffeurMode] [HELPER] Mount ready signal sent via IPC"); log.Information("[ChauffeurMode] [HELPER] Mount ready signal sent via IPC");
LANHelperServer lanServer = Plugin.Instance?.GetLANHelperServer();
if (lanServer != null)
{
log.Information("[ChauffeurMode] [HELPER] Also sending mount ready via LAN to connected clients");
lanServer.SendChauffeurMountReady(questerName, questerWorld);
}
log.Information("[ChauffeurMode] [WORKFLOW] Waiting 8 seconds for quester to mount..."); log.Information("[ChauffeurMode] [WORKFLOW] Waiting 8 seconds for quester to mount...");
await Task.Delay(8000); await Task.Delay(8000);
log.Information($"[ChauffeurMode] [WORKFLOW] Step 6: Transporting to target ({finalTargetPos.X:F2}, {finalTargetPos.Y:F2}, {finalTargetPos.Z:F2})"); log.Information($"[ChauffeurMode] [WORKFLOW] Step 6: Transporting to target ({finalTargetPos.X:F2}, {finalTargetPos.Y:F2}, {finalTargetPos.Z:F2})");
@ -1333,6 +1498,12 @@ public class ChauffeurModeService : IDisposable
log.Information("[ChauffeurMode] [HELPER] Transport complete - FLAGS RESET + STATUS AVAILABLE (before notification)"); log.Information("[ChauffeurMode] [HELPER] Transport complete - FLAGS RESET + STATUS AVAILABLE (before notification)");
log.Information($"[ChauffeurMode] [HELPER] Notifying Quester of arrival: {questerName}@{questerWorld}"); log.Information($"[ChauffeurMode] [HELPER] Notifying Quester of arrival: {questerName}@{questerWorld}");
crossProcessIPC.SendChauffeurArrived(questerName, questerWorld); crossProcessIPC.SendChauffeurArrived(questerName, questerWorld);
LANHelperServer lanServerArrival = Plugin.Instance?.GetLANHelperServer();
if (lanServerArrival != null)
{
log.Information("[ChauffeurMode] [HELPER] Also sending arrival via LAN to connected clients");
lanServerArrival.SendChauffeurArrived(questerName, questerWorld);
}
log.Information("[ChauffeurMode] [HELPER] Waiting for quester to restart Questionable and checking for AttuneAetheryte task..."); log.Information("[ChauffeurMode] [HELPER] Waiting for quester to restart Questionable and checking for AttuneAetheryte task...");
await Task.Delay(3000); await Task.Delay(3000);
bool isAttuneAetheryteTask = false; bool isAttuneAetheryteTask = false;
@ -1954,7 +2125,7 @@ public class ChauffeurModeService : IDisposable
} }
} }
private unsafe void OnChauffeurMountReady(string questerName, ushort questerWorld) public unsafe void OnChauffeurMountReady(string questerName, ushort questerWorld)
{ {
if (!config.ChauffeurModeEnabled || !config.IsQuester || !isWaitingForHelper) if (!config.ChauffeurModeEnabled || !config.IsQuester || !isWaitingForHelper)
{ {
@ -2306,7 +2477,7 @@ public class ChauffeurModeService : IDisposable
} }
} }
private void OnChauffeurArrived(string questerName, ushort questerWorld) public void OnChauffeurArrived(string questerName, ushort questerWorld)
{ {
if (!config.ChauffeurModeEnabled || !config.IsQuester || !isWaitingForHelper) if (!config.ChauffeurModeEnabled || !config.IsQuester || !isWaitingForHelper)
{ {
@ -2747,13 +2918,24 @@ public class ChauffeurModeService : IDisposable
lastZoneChangeTime = null; lastZoneChangeTime = null;
} }
IPlayerCharacter localPlayer = objectTable.LocalPlayer; IPlayerCharacter localPlayer = objectTable.LocalPlayer;
if (localPlayer != null) if (localPlayer == null)
{ {
return;
}
string questerName = localPlayer.Name.ToString(); string questerName = localPlayer.Name.ToString();
ushort questerWorld = (ushort)localPlayer.HomeWorld.RowId; ushort questerWorld = (ushort)localPlayer.HomeWorld.RowId;
ushort currentZone = clientState.TerritoryType; ushort currentZone = clientState.TerritoryType;
Vector3 position = localPlayer.Position; Vector3 position = localPlayer.Position;
crossProcessIPC.BroadcastQuesterPosition(questerName, questerWorld, currentZone, position); crossProcessIPC.BroadcastQuesterPosition(questerName, questerWorld, currentZone, position);
LANHelperClient lanClient = Plugin.Instance?.GetLANHelperClient();
if (lanClient != null)
{
IReadOnlyList<LANHelperInfo> lanHelpers = lanClient.DiscoveredHelpers;
if (lanHelpers.Count > 0)
{
LANHelperInfo firstHelper = lanHelpers.First();
lanClient.SendFollowCommandAsync(firstHelper.IPAddress, position.X, position.Y, position.Z, currentZone);
}
} }
} }

View file

@ -0,0 +1,8 @@
namespace QuestionableCompanion.Models;
public class LANChauffeurResponse
{
public string QuesterName { get; set; } = string.Empty;
public ushort QuesterWorldId { get; set; }
}

View file

@ -0,0 +1,24 @@
namespace QuestionableCompanion.Models;
public class LANChauffeurSummon
{
public string QuesterName { get; set; } = string.Empty;
public ushort QuesterWorldId { get; set; }
public uint ZoneId { get; set; }
public float TargetX { get; set; }
public float TargetY { get; set; }
public float TargetZ { get; set; }
public float QuesterX { get; set; }
public float QuesterY { get; set; }
public float QuesterZ { get; set; }
public bool IsAttuneAetheryte { get; set; }
}

View file

@ -0,0 +1,12 @@
namespace QuestionableCompanion.Models;
public class LANFollowCommand
{
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public uint TerritoryId { get; set; }
}

View file

@ -0,0 +1,10 @@
namespace QuestionableCompanion.Models;
public class LANHeartbeat
{
public string ClientName { get; set; } = string.Empty;
public ushort ClientWorldId { get; set; }
public string ClientRole { get; set; } = string.Empty;
}

View file

@ -0,0 +1,16 @@
using System;
namespace QuestionableCompanion.Models;
public class LANHelperInfo
{
public string Name { get; set; } = string.Empty;
public ushort WorldId { get; set; }
public string IPAddress { get; set; } = string.Empty;
public LANHelperStatus Status { get; set; }
public DateTime LastSeen { get; set; } = DateTime.Now;
}

View file

@ -0,0 +1,10 @@
namespace QuestionableCompanion.Models;
public class LANHelperRequest
{
public string QuesterName { get; set; } = string.Empty;
public ushort QuesterWorldId { get; set; }
public string DutyName { get; set; } = string.Empty;
}

View file

@ -0,0 +1,12 @@
namespace QuestionableCompanion.Models;
public enum LANHelperStatus
{
Available,
Busy,
InParty,
InDuty,
Transporting,
Offline,
Error
}

View file

@ -0,0 +1,12 @@
namespace QuestionableCompanion.Models;
public class LANHelperStatusResponse
{
public string Name { get; set; } = string.Empty;
public ushort WorldId { get; set; }
public LANHelperStatus Status { get; set; }
public string? CurrentActivity { get; set; }
}

View file

@ -0,0 +1,35 @@
using System;
using Newtonsoft.Json;
namespace QuestionableCompanion.Models;
public class LANMessage
{
public LANMessageType Type { get; set; }
public DateTime Timestamp { get; set; } = DateTime.Now;
public string? Data { get; set; }
public LANMessage()
{
}
public LANMessage(LANMessageType type, object? data = null)
{
Type = type;
if (data != null)
{
Data = JsonConvert.SerializeObject(data);
}
}
public T? GetData<T>()
{
if (string.IsNullOrEmpty(Data))
{
return default(T);
}
return JsonConvert.DeserializeObject<T>(Data);
}
}

View file

@ -0,0 +1,24 @@
namespace QuestionableCompanion.Models;
public enum LANMessageType
{
DISCOVER_REQUEST,
DISCOVER_RESPONSE,
REQUEST_HELPER,
HELPER_STATUS,
INVITE_NOTIFICATION,
INVITE_ACCEPTED,
HELPER_IN_PARTY,
HELPER_READY,
HELPER_IN_DUTY,
DUTY_COMPLETE,
FOLLOW_COMMAND,
FOLLOW_STARTED,
FOLLOW_ARRIVED,
CHAUFFEUR_PICKUP_REQUEST,
CHAUFFEUR_HELPER_READY_FOR_MOUNT,
CHAUFFEUR_HELPER_ARRIVED_DEST,
ERROR,
DISCONNECT,
HEARTBEAT
}

View file

@ -0,0 +1,393 @@
using System;
using System.Threading.Tasks;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.Text;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
namespace QuestionableCompanion.Services;
public class ARRTrialAutomationService : IDisposable
{
private readonly IPluginLog log;
private readonly IFramework framework;
private readonly ICommandManager commandManager;
private readonly IChatGui chatGui;
private readonly Configuration config;
private readonly QuestionableIPC questionableIPC;
private readonly SubmarineManager submarineManager;
private readonly HelperManager helperManager;
private readonly IPartyList partyList;
private readonly ICondition condition;
private readonly MemoryHelper memoryHelper;
private bool isInDuty;
private static readonly (uint QuestId, uint TrialId, string ADCommand, string Name)[] Trials = new(uint, uint, string, string)[3]
{
(1048u, 20004u, "/ad run trial 292 1", "Ifrit HM"),
(1157u, 20006u, "/ad run trial 294 1", "Garuda HM"),
(1158u, 20005u, "/ad run trial 293 1", "Titan HM")
};
private const uint TRIGGER_QUEST = 89u;
private const uint TARGET_QUEST = 363u;
private bool isProcessing;
private int currentTrialIndex = -1;
private bool waitingForQuest;
private bool waitingForParty;
private bool waitingForTrial;
private DateTime lastCheckTime = DateTime.MinValue;
public ARRTrialAutomationService(IPluginLog log, IFramework framework, ICommandManager commandManager, IChatGui chatGui, Configuration config, QuestionableIPC questionableIPC, SubmarineManager submarineManager, HelperManager helperManager, IPartyList partyList, ICondition condition, MemoryHelper memoryHelper)
{
this.log = log;
this.framework = framework;
this.commandManager = commandManager;
this.chatGui = chatGui;
this.config = config;
this.questionableIPC = questionableIPC;
this.submarineManager = submarineManager;
this.helperManager = helperManager;
this.partyList = partyList;
this.condition = condition;
this.memoryHelper = memoryHelper;
framework.Update += OnFrameworkUpdate;
condition.ConditionChange += OnConditionChanged;
log.Information("[ARRTrials] Service initialized");
}
private void OnFrameworkUpdate(IFramework framework)
{
if (!isProcessing)
{
return;
}
if (waitingForParty && partyList != null && partyList.Length > 1)
{
if (!((DateTime.Now - lastCheckTime).TotalSeconds < 1.0))
{
lastCheckTime = DateTime.Now;
log.Information($"[ARRTrials] Party join detected (Size: {partyList.Length}) - Triggering trial...");
waitingForParty = false;
TriggerCurrentTrial();
}
}
else if (waitingForQuest && currentTrialIndex >= 0 && currentTrialIndex < Trials.Length && !((DateTime.Now - lastCheckTime).TotalSeconds < 2.0))
{
lastCheckTime = DateTime.Now;
(uint QuestId, uint TrialId, string ADCommand, string Name) tuple = Trials[currentTrialIndex];
uint trialId = tuple.TrialId;
string name = tuple.Name;
bool unlocked = IsTrialUnlocked(trialId);
log.Debug($"[ARRTrials] Polling {name} ({trialId}) Unlocked: {unlocked}");
if (unlocked)
{
log.Information("[ARRTrials] Polling detected " + name + " unlocked - Proceeding...");
waitingForQuest = false;
helperManager.InviteHelpers();
waitingForParty = true;
}
}
}
public bool IsTrialComplete(uint instanceId)
{
return UIState.IsInstanceContentCompleted(instanceId);
}
public bool IsTrialUnlocked(uint instanceId)
{
return UIState.IsInstanceContentUnlocked(instanceId);
}
public bool IsTargetQuestAvailableOrComplete()
{
if (QuestManager.IsQuestComplete(363u))
{
return true;
}
if (questionableIPC.IsReadyToAcceptQuest(363u.ToString()))
{
return true;
}
return false;
}
public void OnTriggerQuestComplete()
{
if (!config.EnableARRPrimalCheck)
{
log.Debug("[ARRTrials] Feature disabled, skipping check");
return;
}
log.Information("[ARRTrials] Quest 89 complete, starting ARR Primal check...");
StartTrialChain();
}
public void StartTrialChain()
{
if (isProcessing)
{
log.Debug("[ARRTrials] Already processing trial chain");
return;
}
isProcessing = true;
submarineManager.SetExternalPause(paused: true);
int startIndex = -1;
for (int i = Trials.Length - 1; i >= 0; i--)
{
if (!IsTrialComplete(Trials[i].TrialId))
{
for (int j = 0; j <= i; j++)
{
if (!IsTrialComplete(Trials[j].TrialId))
{
startIndex = j;
break;
}
}
break;
}
}
if (startIndex == -1)
{
log.Information("[ARRTrials] All trials already complete!");
isProcessing = false;
submarineManager.SetExternalPause(paused: false);
return;
}
currentTrialIndex = startIndex;
log.Information($"[ARRTrials] Starting from trial index {startIndex}: {Trials[startIndex].Name}");
ProcessCurrentTrial();
}
private void ProcessCurrentTrial()
{
if (currentTrialIndex < 0 || currentTrialIndex >= Trials.Length)
{
log.Information("[ARRTrials] Trial chain complete!");
isProcessing = false;
submarineManager.SetExternalPause(paused: false);
return;
}
var (questId, trialId, _, name) = Trials[currentTrialIndex];
if (IsTrialComplete(trialId))
{
log.Information("[ARRTrials] " + name + " already complete, moving to next");
currentTrialIndex++;
ProcessCurrentTrial();
}
else if (!QuestManager.IsQuestComplete(questId))
{
log.Information($"[ARRTrials] Queueing unlock quest {questId} for {name}");
questionableIPC.AddQuestPriority(questId.ToString());
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst start");
});
waitingForQuest = true;
}
else
{
log.Information("[ARRTrials] " + name + " unlocked, inviting helper and triggering trial...");
helperManager.InviteHelpers();
waitingForParty = true;
}
}
public void OnQuestComplete(uint questId)
{
if (!isProcessing || !waitingForQuest)
{
return;
}
for (int i = currentTrialIndex; i < Trials.Length; i++)
{
if (Trials[i].QuestId == questId)
{
log.Information($"[ARRTrials] Unlock quest {questId} completed, triggering trial");
waitingForQuest = false;
helperManager.InviteHelpers();
waitingForParty = true;
break;
}
}
}
public void OnPartyReady()
{
if (isProcessing && waitingForParty)
{
waitingForParty = false;
TriggerCurrentTrial();
}
}
private void TriggerCurrentTrial()
{
if (currentTrialIndex >= 0 && currentTrialIndex < Trials.Length)
{
(uint, uint, string, string) tuple = Trials[currentTrialIndex];
string adCommand = tuple.Item3;
string name = tuple.Item4;
log.Information("[ARRTrials] Triggering " + name + " via AD command");
framework.RunOnFrameworkThread(delegate
{
chatGui.Print(new XivChatEntry
{
Message = "[QSTCompanion] Triggering " + name + "...",
Type = XivChatType.Echo
});
commandManager.ProcessCommand("/ad cfg Unsynced true");
commandManager.ProcessCommand(adCommand);
});
waitingForTrial = true;
}
}
public void OnDutyComplete()
{
if (!isProcessing || !waitingForTrial)
{
return;
}
(uint QuestId, uint TrialId, string ADCommand, string Name) tuple = Trials[currentTrialIndex];
uint trialId = tuple.TrialId;
string name = tuple.Name;
if (IsTrialComplete(trialId))
{
log.Information("[ARRTrials] " + name + " completed successfully!");
waitingForTrial = false;
currentTrialIndex++;
framework.RunOnFrameworkThread(delegate
{
ProcessCurrentTrial();
});
}
else
{
log.Warning("[ARRTrials] " + name + " NOT complete after verification. Retrying current step...");
waitingForTrial = false;
framework.RunOnFrameworkThread(delegate
{
ProcessCurrentTrial();
});
}
}
public string GetStatus()
{
if (!isProcessing)
{
return "Idle";
}
if (currentTrialIndex >= 0 && currentTrialIndex < Trials.Length)
{
string name = Trials[currentTrialIndex].Name;
if (waitingForQuest)
{
return "Waiting for " + name + " unlock quest";
}
if (waitingForParty)
{
return "Waiting for party (" + name + ")";
}
if (waitingForTrial)
{
return "In " + name;
}
return "Processing " + name;
}
return "Processing...";
}
public void Reset()
{
isProcessing = false;
currentTrialIndex = -1;
waitingForQuest = false;
waitingForParty = false;
waitingForTrial = false;
submarineManager.SetExternalPause(paused: false);
}
public void Dispose()
{
framework.Update -= OnFrameworkUpdate;
condition.ConditionChange -= OnConditionChanged;
log.Information("[ARRTrials] Service disposed");
}
private void OnConditionChanged(ConditionFlag flag, bool value)
{
if (flag == ConditionFlag.BoundByDuty)
{
if (value && !isInDuty)
{
isInDuty = true;
log.Debug("[ARRTrials] Entered duty");
}
else if (!value && isInDuty)
{
isInDuty = false;
OnDutyExited();
}
}
}
private void OnDutyExited()
{
if (!isProcessing || !waitingForTrial)
{
return;
}
log.Information("[ARRTrials] Exited duty - stopping AD and disbanding...");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/ad stop");
});
Task.Run(async delegate
{
await Task.Delay(2000);
framework.RunOnFrameworkThread(delegate
{
memoryHelper.SendChatMessage("/leave");
commandManager.ProcessCommand("/ad stop");
log.Information("[ARRTrials] /leave and safety /ad stop sent");
});
log.Information("[ARRTrials] Waiting for completion state check...");
await Task.Delay(1000);
(uint, uint, string, string) tuple = Trials[currentTrialIndex];
uint trialId = tuple.Item2;
for (int i = 0; i < 10; i++)
{
if (IsTrialComplete(trialId))
{
log.Information($"[ARRTrials] Completion verified on attempt {i + 1}");
break;
}
await Task.Delay(1000);
}
OnDutyComplete();
});
}
}

View file

@ -7,6 +7,13 @@ namespace QuestionableCompanion.Services;
public class CombatDutyDetectionService : IDisposable public class CombatDutyDetectionService : IDisposable
{ {
private enum MultiClientRole
{
None,
Quester,
Helper
}
private readonly ICondition condition; private readonly ICondition condition;
private readonly IPluginLog log; private readonly IPluginLog log;
@ -143,7 +150,7 @@ public class CombatDutyDetectionService : IDisposable
if (player != null) if (player != null)
{ {
float hpPercent = (float)player.CurrentHp / (float)player.MaxHp * 100f; float hpPercent = (float)player.CurrentHp / (float)player.MaxHp * 100f;
if (hpPercent <= (float)config.CombatHPThreshold) if (hpPercent <= (float)config.CombatHPThreshold && CanExecuteCombatAutomation())
{ {
log.Warning($"[CombatDuty] HP at {hpPercent:F1}% (threshold: {config.CombatHPThreshold}%) - enabling combat commands"); log.Warning($"[CombatDuty] HP at {hpPercent:F1}% (threshold: {config.CombatHPThreshold}%) - enabling combat commands");
EnableCombatCommands(); EnableCombatCommands();
@ -231,6 +238,19 @@ public class CombatDutyDetectionService : IDisposable
{ {
return; return;
} }
if (!CanExecuteCombatAutomation())
{
switch (GetCurrentMultiClientRole())
{
case MultiClientRole.None:
log.Debug("[CombatDuty] Combat blocked: Role is 'None' (Config not Helper/Quester)");
break;
case MultiClientRole.Quester:
log.Debug("[CombatDuty] Combat blocked: Quester outside Solo Duty (Let D.Automation handle invalid content)");
break;
}
return;
}
try try
{ {
log.Information("[CombatDuty] ========================================"); log.Information("[CombatDuty] ========================================");
@ -358,6 +378,67 @@ public class CombatDutyDetectionService : IDisposable
log.Information("[CombatDuty] State reset"); log.Information("[CombatDuty] State reset");
} }
private MultiClientRole GetCurrentMultiClientRole()
{
if (config.IsHighLevelHelper)
{
return MultiClientRole.Helper;
}
if (config.IsQuester)
{
return MultiClientRole.Quester;
}
return MultiClientRole.None;
}
private bool IsInSoloDuty()
{
if (condition[ConditionFlag.BoundByDuty95])
{
return true;
}
if (IsInDuty && !isInAutoDutyDungeon)
{
return true;
}
return false;
}
private bool CanExecuteCombatAutomation()
{
switch (GetCurrentMultiClientRole())
{
case MultiClientRole.None:
if (!IsInDuty)
{
return true;
}
return false;
case MultiClientRole.Helper:
return true;
case MultiClientRole.Quester:
if (!IsInDuty)
{
return true;
}
if (IsInSoloDuty())
{
return true;
}
if (currentQuestId == 811)
{
return true;
}
if (currentQuestId == 4591)
{
return true;
}
return false;
default:
return false;
}
}
public void Dispose() public void Dispose()
{ {
if (combatCommandsActive) if (combatCommandsActive)

View file

@ -48,16 +48,6 @@ public class DCTravelService : IDisposable
log.Information("[DCTravel] Config.DCTravelWorld: '" + config.DCTravelWorld + "'"); log.Information("[DCTravel] Config.DCTravelWorld: '" + config.DCTravelWorld + "'");
log.Information($"[DCTravel] State.dcTravelCompleted: {dcTravelCompleted}"); log.Information($"[DCTravel] State.dcTravelCompleted: {dcTravelCompleted}");
log.Information($"[DCTravel] State.dcTravelInProgress: {dcTravelInProgress}"); log.Information($"[DCTravel] State.dcTravelInProgress: {dcTravelInProgress}");
if (dcTravelCompleted)
{
log.Warning("[DCTravel] SKIP: Already completed for this character");
return false;
}
if (dcTravelInProgress)
{
log.Warning("[DCTravel] SKIP: Travel already in progress");
return false;
}
if (!config.EnableDCTravel) if (!config.EnableDCTravel)
{ {
log.Warning("[DCTravel] SKIP: DC Travel is DISABLED in config"); log.Warning("[DCTravel] SKIP: DC Travel is DISABLED in config");
@ -78,9 +68,25 @@ public class DCTravelService : IDisposable
log.Information("[DCTravel] Target World: '" + config.DCTravelWorld + "'"); log.Information("[DCTravel] Target World: '" + config.DCTravelWorld + "'");
if (currentWorld.Equals(config.DCTravelWorld, StringComparison.OrdinalIgnoreCase)) if (currentWorld.Equals(config.DCTravelWorld, StringComparison.OrdinalIgnoreCase))
{ {
if (!dcTravelCompleted)
{
log.Information("[DCTravel] Character is already on target world - marking as completed");
dcTravelCompleted = true;
}
log.Warning("[DCTravel] SKIP: Already on target world '" + config.DCTravelWorld + "'"); log.Warning("[DCTravel] SKIP: Already on target world '" + config.DCTravelWorld + "'");
return false; return false;
} }
if (dcTravelCompleted)
{
log.Warning("[DCTravel] State says completed but character is NOT on target world!");
log.Warning("[DCTravel] Resetting state - will perform DC Travel");
dcTravelCompleted = false;
}
if (dcTravelInProgress)
{
log.Warning("[DCTravel] SKIP: Travel already in progress");
return false;
}
log.Information("[DCTravel] ========================================"); log.Information("[DCTravel] ========================================");
log.Information("[DCTravel] DC TRAVEL WILL BE PERFORMED!"); log.Information("[DCTravel] DC TRAVEL WILL BE PERFORMED!");
log.Information("[DCTravel] ========================================"); log.Information("[DCTravel] ========================================");

View file

@ -29,6 +29,10 @@ public class DungeonAutomationService : IDisposable
private readonly QuestionableIPC questionableIPC; private readonly QuestionableIPC questionableIPC;
private readonly CrossProcessIPC crossProcessIPC;
private readonly MultiClientIPC multiClientIPC;
private bool isWaitingForParty; private bool isWaitingForParty;
private DateTime partyInviteTime = DateTime.MinValue; private DateTime partyInviteTime = DateTime.MinValue;
@ -55,6 +59,10 @@ public class DungeonAutomationService : IDisposable
private bool isAutomationActive; private bool isAutomationActive;
private int originalDutyMode;
private Func<bool>? isRotationActiveChecker;
private bool hasSentAtY; private bool hasSentAtY;
public bool IsWaitingForParty => isWaitingForParty; public bool IsWaitingForParty => isWaitingForParty;
@ -63,7 +71,30 @@ public class DungeonAutomationService : IDisposable
public bool IsInAutoDutyDungeon => isAutomationActive; public bool IsInAutoDutyDungeon => isAutomationActive;
public DungeonAutomationService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, IGameGui gameGui, Configuration config, HelperManager helperManager, MemoryHelper memoryHelper, QuestionableIPC questionableIPC) public void SetRotationActiveChecker(Func<bool> checker)
{
isRotationActiveChecker = checker;
}
private bool CanExecuteAutomation()
{
if (config.IsHighLevelHelper)
{
return true;
}
if (config.IsQuester)
{
Func<bool>? func = isRotationActiveChecker;
if (func == null || !func())
{
return false;
}
return true;
}
return false;
}
public DungeonAutomationService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, IGameGui gameGui, Configuration config, HelperManager helperManager, MemoryHelper memoryHelper, QuestionableIPC questionableIPC, CrossProcessIPC crossProcessIPC, MultiClientIPC multiClientIPC)
{ {
this.condition = condition; this.condition = condition;
this.log = log; this.log = log;
@ -75,6 +106,8 @@ public class DungeonAutomationService : IDisposable
this.helperManager = helperManager; this.helperManager = helperManager;
this.memoryHelper = memoryHelper; this.memoryHelper = memoryHelper;
this.questionableIPC = questionableIPC; this.questionableIPC = questionableIPC;
this.crossProcessIPC = crossProcessIPC;
this.multiClientIPC = multiClientIPC;
condition.ConditionChange += OnConditionChanged; condition.ConditionChange += OnConditionChanged;
log.Information("[DungeonAutomation] Service initialized with ConditionChange event"); log.Information("[DungeonAutomation] Service initialized with ConditionChange event");
log.Information($"[DungeonAutomation] Config - Required Party Size: {config.AutoDutyPartySize}"); log.Information($"[DungeonAutomation] Config - Required Party Size: {config.AutoDutyPartySize}");
@ -87,6 +120,11 @@ public class DungeonAutomationService : IDisposable
{ {
if (!isAutomationActive) if (!isAutomationActive)
{ {
if (!CanExecuteAutomation())
{
log.Information("[DungeonAutomation] Start request ignored - validation failed (Check Role/Rotation)");
return;
}
log.Information("[DungeonAutomation] ========================================"); log.Information("[DungeonAutomation] ========================================");
log.Information("[DungeonAutomation] === STARTING DUNGEON AUTOMATION ==="); log.Information("[DungeonAutomation] === STARTING DUNGEON AUTOMATION ===");
log.Information("[DungeonAutomation] ========================================"); log.Information("[DungeonAutomation] ========================================");
@ -148,6 +186,10 @@ public class DungeonAutomationService : IDisposable
public void Update() public void Update()
{ {
if (!CanExecuteAutomation() && !isAutomationActive)
{
return;
}
if (config.EnableAutoDutyUnsynced && !isAutomationActive) if (config.EnableAutoDutyUnsynced && !isAutomationActive)
{ {
CheckWaitForPartyTask(); CheckWaitForPartyTask();
@ -268,8 +310,12 @@ public class DungeonAutomationService : IDisposable
return; return;
} }
lastDutyEntryTime = DateTime.Now; lastDutyEntryTime = DateTime.Now;
log.Information("[DungeonAutomation] Entered duty"); log.Debug("[DungeonAutomation] Entered duty");
if (expectingDutyEntry) if (!CanExecuteAutomation())
{
log.Debug("[DungeonAutomation] OnDutyEntered ignored - validation failed");
}
else if (expectingDutyEntry)
{ {
log.Information("[DungeonAutomation] Duty started by DungeonAutomation - enabling automation commands"); log.Information("[DungeonAutomation] Duty started by DungeonAutomation - enabling automation commands");
expectingDutyEntry = false; expectingDutyEntry = false;
@ -297,7 +343,11 @@ public class DungeonAutomationService : IDisposable
} }
lastDutyExitTime = DateTime.Now; lastDutyExitTime = DateTime.Now;
log.Information("[DungeonAutomation] Exited duty"); log.Information("[DungeonAutomation] Exited duty");
if (isAutomationActive) if (!CanExecuteAutomation() && !isAutomationActive)
{
log.Information("[DungeonAutomation] OnDutyExited ignored - validation failed");
}
else if (isAutomationActive)
{ {
commandManager.ProcessCommand("/at n"); commandManager.ProcessCommand("/at n");
log.Information("[DungeonAutomation] Sent /at n (duty exited)"); log.Information("[DungeonAutomation] Sent /at n (duty exited)");
@ -343,6 +393,11 @@ public class DungeonAutomationService : IDisposable
{ {
try try
{ {
if (!CanExecuteAutomation())
{
log.Information("[DungeonAutomation] DisbandParty ignored - validation failed");
return;
}
log.Information("[DungeonAutomation] Disbanding party"); log.Information("[DungeonAutomation] Disbanding party");
framework.RunOnFrameworkThread(delegate framework.RunOnFrameworkThread(delegate
{ {

View file

@ -0,0 +1,197 @@
using System;
using System.Runtime.InteropServices;
using Dalamud.Game.NativeWrapper;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace QuestionableCompanion.Services;
public class ErrorRecoveryService : IDisposable
{
private delegate char LobbyErrorHandlerDelegate(long a1, long a2, long a3);
private readonly IPluginLog log;
private readonly IGameInteropProvider hookProvider;
private readonly IClientState clientState;
private readonly IFramework framework;
private readonly IGameGui gameGui;
private readonly AutoRetainerIPC? autoRetainerIPC;
private Hook<LobbyErrorHandlerDelegate>? lobbyErrorHandlerHook;
private DateTime lastDialogClickTime = DateTime.MinValue;
public bool IsErrorDisconnect { get; private set; }
public string? LastDisconnectedCharacter { get; private set; }
public ErrorRecoveryService(IPluginLog log, IGameInteropProvider hookProvider, IClientState clientState, IFramework framework, IGameGui gameGui, AutoRetainerIPC? autoRetainerIPC = null)
{
this.log = log;
this.hookProvider = hookProvider;
this.clientState = clientState;
this.framework = framework;
this.gameGui = gameGui;
this.autoRetainerIPC = autoRetainerIPC;
framework.Update += OnFrameworkUpdate;
InitializeHook();
}
private void InitializeHook()
{
try
{
lobbyErrorHandlerHook = hookProvider.HookFromSignature<LobbyErrorHandlerDelegate>("40 53 48 83 EC 30 48 8B D9 49 8B C8 E8 ?? ?? ?? ?? 8B D0", LobbyErrorHandlerDetour);
if (lobbyErrorHandlerHook != null && lobbyErrorHandlerHook.Address != IntPtr.Zero)
{
lobbyErrorHandlerHook.Enable();
}
}
catch (Exception)
{
}
}
private char LobbyErrorHandlerDetour(long a1, long a2, long a3)
{
try
{
nint p3 = new IntPtr(a3);
byte t1 = Marshal.ReadByte(p3);
int num = (((t1 & 0xF) > 0) ? Marshal.ReadInt32(p3 + 8) : 0);
_ = 0;
if (num != 0)
{
try
{
if (autoRetainerIPC != null)
{
string currentChar = autoRetainerIPC.GetCurrentCharacter();
if (!string.IsNullOrEmpty(currentChar))
{
LastDisconnectedCharacter = currentChar;
}
}
}
catch (Exception)
{
}
Marshal.WriteInt64(p3 + 8, 16000L);
IsErrorDisconnect = true;
if ((t1 & 0xF) > 0)
{
Marshal.ReadInt32(p3 + 8);
}
else
_ = 0;
}
}
catch (Exception)
{
}
return lobbyErrorHandlerHook.Original(a1, a2, a3);
}
private unsafe void OnFrameworkUpdate(IFramework framework)
{
try
{
AtkUnitBasePtr dialoguePtr = gameGui.GetAddonByName("Dialogue");
if (dialoguePtr == IntPtr.Zero)
{
return;
}
AtkUnitBase* dialogueAddon = (AtkUnitBase*)(nint)dialoguePtr;
if (dialogueAddon == null || !dialogueAddon->IsVisible || (DateTime.Now - lastDialogClickTime).TotalMilliseconds < 1000.0)
{
return;
}
AtkTextNode* textNode = dialogueAddon->GetTextNodeById(3u);
if (textNode == null)
{
return;
}
string text = textNode->NodeText.ToString();
if (string.IsNullOrEmpty(text) || (!text.Contains("server", StringComparison.OrdinalIgnoreCase) && !text.Contains("connection", StringComparison.OrdinalIgnoreCase) && !text.Contains("error", StringComparison.OrdinalIgnoreCase) && !text.Contains("lost", StringComparison.OrdinalIgnoreCase)))
{
return;
}
IsErrorDisconnect = true;
try
{
if (autoRetainerIPC != null)
{
string currentChar = autoRetainerIPC.GetCurrentCharacter();
if (!string.IsNullOrEmpty(currentChar))
{
LastDisconnectedCharacter = currentChar;
}
}
}
catch
{
}
try
{
AtkComponentButton* button = dialogueAddon->GetComponentButtonById(4u);
if (button != null)
{
AtkResNode btnRes = button->AtkComponentBase.OwnerNode->AtkResNode;
AtkEvent* evt = btnRes.AtkEventManager.Event;
dialogueAddon->ReceiveEvent(evt->State.EventType, (int)evt->Param, btnRes.AtkEventManager.Event, null);
lastDialogClickTime = DateTime.Now;
}
}
catch (Exception)
{
}
}
catch (Exception)
{
}
}
public void Reset()
{
IsErrorDisconnect = false;
LastDisconnectedCharacter = null;
}
public bool RequestRelog()
{
if (autoRetainerIPC == null || !autoRetainerIPC.IsAvailable)
{
log.Warning("[ErrorRecovery] AutoRetainer IPC not available - cannot relog");
return false;
}
if (string.IsNullOrEmpty(LastDisconnectedCharacter))
{
log.Warning("[ErrorRecovery] No character to relog to");
return false;
}
log.Information("[ErrorRecovery] Requesting AutoRetainer relog to: " + LastDisconnectedCharacter);
return autoRetainerIPC.SwitchCharacter(LastDisconnectedCharacter);
}
public void Dispose()
{
try
{
framework.Update -= OnFrameworkUpdate;
if (lobbyErrorHandlerHook != null)
{
lobbyErrorHandlerHook.Disable();
lobbyErrorHandlerHook.Dispose();
}
}
catch (Exception)
{
}
}
}

View file

@ -552,12 +552,19 @@ public class ExecutionService : IDisposable
preCheckService.LogCompletedQuestsBeforeLogout(); preCheckService.LogCompletedQuestsBeforeLogout();
} }
if (dcTravelService != null && dcTravelService.IsDCTravelCompleted()) if (dcTravelService != null && dcTravelService.IsDCTravelCompleted())
{
if (config.ReturnToHomeworldOnStopQuest)
{ {
AddLog(LogLevel.Info, "[DCTravel] Returning to homeworld before character switch..."); AddLog(LogLevel.Info, "[DCTravel] Returning to homeworld before character switch...");
dcTravelService.ReturnToHomeworld(); dcTravelService.ReturnToHomeworld();
Thread.Sleep(2000); Thread.Sleep(2000);
AddLog(LogLevel.Info, "[DCTravel] Returned to homeworld"); AddLog(LogLevel.Info, "[DCTravel] Returned to homeworld");
} }
else
{
AddLog(LogLevel.Info, "[DCTravel] Skipping return to homeworld (setting disabled)");
}
}
if (config.EnableSafeWaitBeforeCharacterSwitch && safeWaitService != null) if (config.EnableSafeWaitBeforeCharacterSwitch && safeWaitService != null)
{ {
AddLog(LogLevel.Info, "[SafeWait] Stabilizing character before switch..."); AddLog(LogLevel.Info, "[SafeWait] Stabilizing character before switch...");

View file

@ -4,8 +4,12 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Party;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Group; using FFXIVClientStructs.FFXIV.Client.Game.Group;
using Lumina.Excel;
using Lumina.Excel.Sheets;
using QuestionableCompanion.Models;
namespace QuestionableCompanion.Services; namespace QuestionableCompanion.Services;
@ -33,13 +37,17 @@ public class HelperManager : IDisposable
private readonly MemoryHelper memoryHelper; private readonly MemoryHelper memoryHelper;
private readonly LANHelperClient? lanHelperClient;
private readonly IPartyList partyList;
private bool isInDuty; private bool isInDuty;
private List<(string Name, ushort WorldId)> availableHelpers = new List<(string, ushort)>(); private List<(string Name, ushort WorldId)> availableHelpers = new List<(string, ushort)>();
private Dictionary<(string, ushort), bool> helperReadyStatus = new Dictionary<(string, ushort), bool>(); private Dictionary<(string, ushort), bool> helperReadyStatus = new Dictionary<(string, ushort), bool>();
public HelperManager(Configuration configuration, IPluginLog log, ICommandManager commandManager, ICondition condition, IClientState clientState, IFramework framework, PartyInviteService partyInviteService, MultiClientIPC multiClientIPC, CrossProcessIPC crossProcessIPC, PartyInviteAutoAccept partyInviteAutoAccept, MemoryHelper memoryHelper) public HelperManager(Configuration configuration, IPluginLog log, ICommandManager commandManager, ICondition condition, IClientState clientState, IFramework framework, PartyInviteService partyInviteService, MultiClientIPC multiClientIPC, CrossProcessIPC crossProcessIPC, PartyInviteAutoAccept partyInviteAutoAccept, MemoryHelper memoryHelper, LANHelperClient? lanHelperClient, IPartyList partyList)
{ {
this.configuration = configuration; this.configuration = configuration;
this.log = log; this.log = log;
@ -51,7 +59,9 @@ public class HelperManager : IDisposable
this.multiClientIPC = multiClientIPC; this.multiClientIPC = multiClientIPC;
this.crossProcessIPC = crossProcessIPC; this.crossProcessIPC = crossProcessIPC;
this.memoryHelper = memoryHelper; this.memoryHelper = memoryHelper;
this.lanHelperClient = lanHelperClient;
this.partyInviteAutoAccept = partyInviteAutoAccept; this.partyInviteAutoAccept = partyInviteAutoAccept;
this.partyList = partyList;
condition.ConditionChange += OnConditionChanged; condition.ConditionChange += OnConditionChanged;
multiClientIPC.OnHelperRequested += OnHelperRequested; multiClientIPC.OnHelperRequested += OnHelperRequested;
multiClientIPC.OnHelperDismissed += OnHelperDismissed; multiClientIPC.OnHelperDismissed += OnHelperDismissed;
@ -95,22 +105,203 @@ public class HelperManager : IDisposable
log.Debug("[HelperManager] Not a Quester, skipping helper invites"); log.Debug("[HelperManager] Not a Quester, skipping helper invites");
return; return;
} }
if (configuration.HelperSelection == HelperSelectionMode.ManualInput)
{
if (string.IsNullOrEmpty(configuration.ManualHelperName))
{
log.Warning("[HelperManager] Manual Input mode selected but no helper name configured!");
return;
}
Task.Run(async delegate
{
log.Information("[HelperManager] Manual Input mode: Inviting " + configuration.ManualHelperName);
string[] parts = configuration.ManualHelperName.Split('@');
if (parts.Length != 2)
{
log.Error("[HelperManager] Invalid manual helper format: " + configuration.ManualHelperName + " (expected: CharacterName@WorldName)");
}
else
{
string helperName = parts[0].Trim();
string worldName = parts[1].Trim();
ushort worldId = 0;
ExcelSheet<World> worldSheet = Plugin.DataManager.GetExcelSheet<World>();
if (worldSheet != null)
{
foreach (World world in worldSheet)
{
if (world.Name.ExtractText().Equals(worldName, StringComparison.OrdinalIgnoreCase))
{
worldId = (ushort)world.RowId;
break;
}
}
}
if (worldId == 0)
{
log.Error("[HelperManager] Could not find world ID for: " + worldName);
}
else
{
log.Information($"[HelperManager] Resolved helper: {helperName}@{worldId} ({worldName})");
bool alreadyInParty = false;
if (partyList != null)
{
foreach (IPartyMember member in partyList)
{
if (member.Name.ToString() == helperName && member.World.RowId == worldId)
{
alreadyInParty = true;
break;
}
}
}
if (alreadyInParty)
{
log.Information("[HelperManager] helper " + helperName + " is ALREADY in party! Skipping disband/invite.");
}
else
{
DisbandParty();
await Task.Delay(500);
log.Information("[HelperManager] Sending direct invite to " + helperName + " (Manual Input - no IPC wait)");
if (partyInviteService.InviteToParty(helperName, worldId))
{
log.Information("[HelperManager] Successfully invited " + helperName);
}
else
{
log.Error("[HelperManager] Failed to invite " + helperName);
}
}
}
}
});
return;
}
log.Information("[HelperManager] Requesting helper announcements..."); log.Information("[HelperManager] Requesting helper announcements...");
RequestHelperAnnouncements(); RequestHelperAnnouncements();
Task.Run(async delegate Task.Run(async delegate
{ {
await Task.Delay(1000); await Task.Delay(1000);
List<(string Name, ushort WorldId)> helpersToInvite = new List<(string, ushort)>();
if (configuration.HelperSelection == HelperSelectionMode.Auto)
{
if (availableHelpers.Count == 0) if (availableHelpers.Count == 0)
{ {
log.Warning("[HelperManager] No helpers available via IPC!"); log.Warning("[HelperManager] No helpers available via IPC!");
if (lanHelperClient != null)
{
log.Information("[HelperManager] Checking for LAN helpers...");
LANHelperInfo lanHelper = lanHelperClient.GetFirstAvailableHelper();
if (lanHelper != null)
{
log.Information("[HelperManager] Found LAN helper: " + lanHelper.Name + " at " + lanHelper.IPAddress);
await InviteLANHelper(lanHelper.IPAddress, lanHelper.Name, lanHelper.WorldId);
return;
}
}
log.Warning("[HelperManager] Make sure helper clients are running with 'I'm a High-Level Helper' enabled"); log.Warning("[HelperManager] Make sure helper clients are running with 'I'm a High-Level Helper' enabled");
return;
}
helpersToInvite.AddRange(availableHelpers);
log.Information($"[HelperManager] Auto mode: Inviting {helpersToInvite.Count} AUTO-DISCOVERED helper(s)...");
}
else if (configuration.HelperSelection == HelperSelectionMode.Dropdown)
{
if (string.IsNullOrEmpty(configuration.PreferredHelper))
{
log.Warning("[HelperManager] Dropdown mode selected but no helper chosen!");
return;
}
string[] parts = configuration.PreferredHelper.Split('@');
if (parts.Length != 2)
{
log.Error("[HelperManager] Invalid preferred helper format: " + configuration.PreferredHelper);
return;
}
string helperName = parts[0].Trim();
string worldName = parts[1].Trim();
(string, ushort) matchingHelper = availableHelpers.FirstOrDefault<(string, ushort)>(delegate((string Name, ushort WorldId) h)
{
ExcelSheet<World> excelSheet = Plugin.DataManager.GetExcelSheet<World>();
string text2 = "Unknown";
if (excelSheet != null)
{
foreach (World current in excelSheet)
{
if (current.RowId == h.WorldId)
{
text2 = current.Name.ExtractText();
break;
}
}
}
return h.Name == helperName && text2 == worldName;
});
var (text, num) = matchingHelper;
if (text == null && num == 0)
{
log.Warning("[HelperManager] Preferred helper " + configuration.PreferredHelper + " not found in discovered helpers!");
return;
}
helpersToInvite.Add(matchingHelper);
log.Information("[HelperManager] Dropdown mode: Inviting selected helper " + configuration.PreferredHelper);
}
bool allHelpersPresent = false;
if (partyList != null && partyList.Length > 0 && helpersToInvite.Count > 0)
{
int presentCount = 0;
foreach (var (hName, hWorld) in helpersToInvite)
{
foreach (IPartyMember member in partyList)
{
if (member.Name.ToString() == hName && member.World.RowId == hWorld)
{
presentCount++;
break;
}
}
}
if (presentCount >= helpersToInvite.Count)
{
allHelpersPresent = true;
}
}
if (allHelpersPresent)
{
log.Information("[HelperManager] All desired helpers are ALREADY in party! Skipping disband.");
}
else if (partyList != null && partyList.Length > 1)
{
bool anyHelperPresent = false;
foreach (var (hName2, hWorld2) in helpersToInvite)
{
foreach (IPartyMember member2 in partyList)
{
if (member2.Name.ToString() == hName2 && member2.World.RowId == hWorld2)
{
anyHelperPresent = true;
break;
}
}
}
if (anyHelperPresent)
{
log.Information("[HelperManager] Some helpers already in party - NOT disbanding, simply inviting remaining.");
} }
else else
{ {
log.Information($"[HelperManager] Inviting {availableHelpers.Count} AUTO-DISCOVERED helper(s)...");
DisbandParty(); DisbandParty();
await Task.Delay(500); await Task.Delay(500);
foreach (var (name, worldId) in availableHelpers) }
}
else
{
DisbandParty();
await Task.Delay(500);
}
foreach (var (name, worldId) in helpersToInvite)
{ {
if (string.IsNullOrEmpty(name) || worldId == 0) if (string.IsNullOrEmpty(name) || worldId == 0)
{ {
@ -147,13 +338,23 @@ public class HelperManager : IDisposable
} }
} }
} }
}
}); });
} }
public List<(string Name, ushort WorldId)> GetAvailableHelpers() public List<(string Name, ushort WorldId)> GetAvailableHelpers()
{ {
return new List<(string, ushort)>(availableHelpers); List<(string, ushort)> allHelpers = new List<(string, ushort)>(availableHelpers);
if (lanHelperClient != null)
{
foreach (LANHelperInfo lanHelper in lanHelperClient.DiscoveredHelpers)
{
if (!allHelpers.Any<(string, ushort)>(((string Name, ushort WorldId) h) => h.Name == lanHelper.Name && h.WorldId == lanHelper.WorldId))
{
allHelpers.Add((lanHelper.Name, lanHelper.WorldId));
}
}
}
return allHelpers;
} }
private void LeaveParty() private void LeaveParty()
@ -211,7 +412,7 @@ public class HelperManager : IDisposable
private void OnDutyEnter() private void OnDutyEnter()
{ {
log.Information("[HelperManager] Entered duty"); log.Debug("[HelperManager] Entered duty");
if (!configuration.IsHighLevelHelper) if (!configuration.IsHighLevelHelper)
{ {
return; return;
@ -344,9 +545,28 @@ public class HelperManager : IDisposable
{ {
GroupManager.Group* group = groupManager->GetGroup(); GroupManager.Group* group = groupManager->GetGroup();
if (group != null && group->MemberCount > 0) if (group != null && group->MemberCount > 0)
{
bool requesterInParty = false;
if (partyList != null)
{
foreach (IPartyMember member in partyList)
{
if (member.Name.ToString() == characterName && member.World.RowId == worldId)
{
requesterInParty = true;
break;
}
}
}
if (requesterInParty)
{
log.Information($"[HelperManager] Request from {characterName}@{worldId} who is ALREADY in my party! Ignoring leave request.");
needsToLeaveParty = false;
}
else
{ {
needsToLeaveParty = true; needsToLeaveParty = true;
log.Information("[HelperManager] Currently in party, notifying quester..."); log.Information("[HelperManager] Currently in party (but not with requester), notifying quester...");
crossProcessIPC.NotifyHelperInParty(localName, localWorldId); crossProcessIPC.NotifyHelperInParty(localName, localWorldId);
if (condition[ConditionFlag.BoundByDuty]) if (condition[ConditionFlag.BoundByDuty])
{ {
@ -356,6 +576,7 @@ public class HelperManager : IDisposable
} }
} }
} }
}
if (!isInDuty) if (!isInDuty)
{ {
if (needsToLeaveParty) if (needsToLeaveParty)
@ -427,6 +648,36 @@ public class HelperManager : IDisposable
crossProcessIPC.RequestHelperAnnouncements(); crossProcessIPC.RequestHelperAnnouncements();
} }
private async Task InviteLANHelper(string ipAddress, string helperName, ushort worldId)
{
if (lanHelperClient == null)
{
return;
}
log.Information("[HelperManager] ========================================");
log.Information("[HelperManager] === INVITING LAN HELPER ===");
log.Information("[HelperManager] Helper: " + helperName);
log.Information("[HelperManager] IP: " + ipAddress);
log.Information("[HelperManager] ========================================");
DisbandParty();
await Task.Delay(500);
log.Information("[HelperManager] Sending helper request to " + ipAddress + "...");
if (!(await lanHelperClient.RequestHelperAsync(ipAddress, "LAN Dungeon")))
{
log.Error("[HelperManager] Failed to send helper request to " + ipAddress);
return;
}
await Task.Delay(1000);
log.Information("[HelperManager] Sending party invite to " + helperName + "...");
if (!partyInviteService.InviteToParty(helperName, worldId))
{
log.Error("[HelperManager] Failed to invite " + helperName + " to party");
return;
}
await lanHelperClient.NotifyInviteSentAsync(ipAddress, helperName);
log.Information("[HelperManager] ✓ LAN helper invite complete");
}
public void Dispose() public void Dispose()
{ {
condition.ConditionChange -= OnConditionChanged; condition.ConditionChange -= OnConditionChanged;

View file

@ -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();
}
}

View file

@ -0,0 +1,577 @@
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();
}
}

View file

@ -21,6 +21,8 @@ public class MultiClientIPC : IDisposable
private readonly ICallGateProvider<string, ushort, object?> passengerMountedProvider; private readonly ICallGateProvider<string, ushort, object?> passengerMountedProvider;
private readonly ICallGateProvider<string, ushort, string, object?> helperStatusProvider;
private readonly ICallGateSubscriber<string, ushort, object?> requestHelperSubscriber; private readonly ICallGateSubscriber<string, ushort, object?> requestHelperSubscriber;
private readonly ICallGateSubscriber<object?> dismissHelperSubscriber; private readonly ICallGateSubscriber<object?> dismissHelperSubscriber;
@ -31,6 +33,8 @@ public class MultiClientIPC : IDisposable
private readonly ICallGateSubscriber<string, ushort, object?> passengerMountedSubscriber; private readonly ICallGateSubscriber<string, ushort, object?> passengerMountedSubscriber;
private readonly ICallGateSubscriber<string, ushort, string, object?> helperStatusSubscriber;
public event Action<string, ushort>? OnHelperRequested; public event Action<string, ushort>? OnHelperRequested;
public event Action? OnHelperDismissed; public event Action? OnHelperDismissed;
@ -41,6 +45,8 @@ public class MultiClientIPC : IDisposable
public event Action<string, ushort>? OnPassengerMounted; public event Action<string, ushort>? OnPassengerMounted;
public event Action<string, ushort, string>? OnHelperStatusUpdate;
public MultiClientIPC(IDalamudPluginInterface pluginInterface, IPluginLog log) public MultiClientIPC(IDalamudPluginInterface pluginInterface, IPluginLog log)
{ {
this.pluginInterface = pluginInterface; this.pluginInterface = pluginInterface;
@ -50,11 +56,13 @@ public class MultiClientIPC : IDisposable
helperAvailableProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.HelperAvailable"); helperAvailableProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.HelperAvailable");
chatMessageProvider = pluginInterface.GetIpcProvider<string, object>("QSTCompanion.ChatMessage"); chatMessageProvider = pluginInterface.GetIpcProvider<string, object>("QSTCompanion.ChatMessage");
passengerMountedProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.PassengerMounted"); passengerMountedProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.PassengerMounted");
helperStatusProvider = pluginInterface.GetIpcProvider<string, ushort, string, object>("QSTCompanion.HelperStatus");
requestHelperSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.RequestHelper"); requestHelperSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.RequestHelper");
dismissHelperSubscriber = pluginInterface.GetIpcSubscriber<object>("QSTCompanion.DismissHelper"); dismissHelperSubscriber = pluginInterface.GetIpcSubscriber<object>("QSTCompanion.DismissHelper");
helperAvailableSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.HelperAvailable"); helperAvailableSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.HelperAvailable");
chatMessageSubscriber = pluginInterface.GetIpcSubscriber<string, object>("QSTCompanion.ChatMessage"); chatMessageSubscriber = pluginInterface.GetIpcSubscriber<string, object>("QSTCompanion.ChatMessage");
passengerMountedSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.PassengerMounted"); passengerMountedSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.PassengerMounted");
helperStatusSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, string, object>("QSTCompanion.HelperStatus");
requestHelperProvider.RegisterFunc(delegate(string name, ushort worldId) requestHelperProvider.RegisterFunc(delegate(string name, ushort worldId)
{ {
OnRequestHelperReceived(name, worldId); OnRequestHelperReceived(name, worldId);
@ -80,6 +88,11 @@ public class MultiClientIPC : IDisposable
OnPassengerMountedReceived(questerName, questerWorld); OnPassengerMountedReceived(questerName, questerWorld);
return (object?)null; return (object?)null;
}); });
helperStatusProvider.RegisterFunc(delegate(string helperName, ushort helperWorld, string status)
{
OnHelperStatusReceived(helperName, helperWorld, status);
return (object?)null;
});
log.Information("[MultiClientIPC] ✅ IPC initialized successfully"); log.Information("[MultiClientIPC] ✅ IPC initialized successfully");
} }
@ -213,6 +226,32 @@ public class MultiClientIPC : IDisposable
} }
} }
public void BroadcastHelperStatus(string helperName, ushort worldId, string status)
{
try
{
log.Debug($"[MultiClientIPC] Broadcasting helper status: {helperName}@{worldId} = {status}");
helperStatusSubscriber.InvokeFunc(helperName, worldId, status);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Failed to broadcast helper status: " + ex.Message);
}
}
private void OnHelperStatusReceived(string helperName, ushort helperWorld, string status)
{
try
{
log.Debug($"[MultiClientIPC] Received helper status: {helperName}@{helperWorld} = {status}");
this.OnHelperStatusUpdate?.Invoke(helperName, helperWorld, status);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Error handling helper status: " + ex.Message);
}
}
public void Dispose() public void Dispose()
{ {
try try
@ -221,6 +260,7 @@ public class MultiClientIPC : IDisposable
dismissHelperProvider.UnregisterFunc(); dismissHelperProvider.UnregisterFunc();
helperAvailableProvider.UnregisterFunc(); helperAvailableProvider.UnregisterFunc();
chatMessageProvider.UnregisterFunc(); chatMessageProvider.UnregisterFunc();
helperStatusProvider.UnregisterFunc();
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -1,6 +1,5 @@
using System; using System;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI; using FFXIVClientStructs.FFXIV.Component.GUI;
namespace QuestionableCompanion.Services; namespace QuestionableCompanion.Services;
@ -21,6 +20,8 @@ public class PartyInviteAutoAccept : IDisposable
private DateTime autoAcceptUntil = DateTime.MinValue; private DateTime autoAcceptUntil = DateTime.MinValue;
private bool hasLoggedAlwaysAccept;
public PartyInviteAutoAccept(IPluginLog log, IFramework framework, IGameGui gameGui, IPartyList partyList, Configuration configuration) public PartyInviteAutoAccept(IPluginLog log, IFramework framework, IGameGui gameGui, IPartyList partyList, Configuration configuration)
{ {
this.log = log; this.log = log;
@ -47,11 +48,34 @@ public class PartyInviteAutoAccept : IDisposable
log.Information("[PartyInviteAutoAccept] Will accept ALL party invites during this time!"); log.Information("[PartyInviteAutoAccept] Will accept ALL party invites during this time!");
} }
public void EnableForQuester(string questerName)
{
shouldAutoAccept = true;
autoAcceptUntil = DateTime.Now.AddSeconds(60.0);
log.Information("[PartyInviteAutoAccept] Auto-accept enabled for quester: " + questerName);
log.Information("[PartyInviteAutoAccept] Will accept invites for 60 seconds");
}
private unsafe void OnFrameworkUpdate(IFramework framework) private unsafe void OnFrameworkUpdate(IFramework framework)
{ {
if (!shouldAutoAccept) bool shouldAcceptNow = false;
if (configuration.IsHighLevelHelper && configuration.AlwaysAutoAcceptInvites)
{ {
return; if (!hasLoggedAlwaysAccept)
{
log.Information("[PartyInviteAutoAccept] === ALWAYS AUTO-ACCEPT ENABLED ===");
log.Information("[PartyInviteAutoAccept] Helper will continuously accept ALL party invites");
log.Information("[PartyInviteAutoAccept] This mode is ALWAYS ON (no timeout)");
hasLoggedAlwaysAccept = true;
}
shouldAcceptNow = true;
}
else if (shouldAutoAccept)
{
if (hasLoggedAlwaysAccept)
{
log.Information("[PartyInviteAutoAccept] Always auto-accept disabled");
hasLoggedAlwaysAccept = false;
} }
if (DateTime.Now > autoAcceptUntil) if (DateTime.Now > autoAcceptUntil)
{ {
@ -59,6 +83,17 @@ public class PartyInviteAutoAccept : IDisposable
log.Information("[PartyInviteAutoAccept] Auto-accept window expired"); log.Information("[PartyInviteAutoAccept] Auto-accept window expired");
return; return;
} }
shouldAcceptNow = true;
}
else if (hasLoggedAlwaysAccept)
{
log.Information("[PartyInviteAutoAccept] Always auto-accept disabled");
hasLoggedAlwaysAccept = false;
}
if (!shouldAcceptNow)
{
return;
}
try try
{ {
string[] obj = new string[6] { "SelectYesno", "SelectYesNo", "_PartyInvite", "PartyInvite", "SelectString", "_Notification" }; string[] obj = new string[6] { "SelectYesno", "SelectYesNo", "_PartyInvite", "PartyInvite", "SelectString", "_Notification" };
@ -72,54 +107,22 @@ public class PartyInviteAutoAccept : IDisposable
break; break;
} }
} }
if (addonPtr == IntPtr.Zero) if (addonPtr != IntPtr.Zero)
{ {
if (DateTime.Now.Second % 5 != 0) AtkUnitBase* addon = (AtkUnitBase*)addonPtr;
{ if (addon == null)
return;
}
log.Debug($"[PartyInviteAutoAccept] Still waiting for party invite dialog... ({(autoAcceptUntil - DateTime.Now).TotalSeconds:F0}s remaining)");
if (DateTime.Now.Second % 10 != 0)
{
return;
}
log.Warning("[PartyInviteAutoAccept] === DUMPING ALL VISIBLE ADDONS ===");
RaptureAtkUnitManager* atkStage = RaptureAtkUnitManager.Instance();
if (atkStage != null)
{
AtkUnitManager* unitManager = &atkStage->AtkUnitManager;
for (int j = 0; j < unitManager->AllLoadedUnitsList.Count; j++)
{
AtkUnitBase* addon = unitManager->AllLoadedUnitsList.Entries[j].Value;
if (addon != null && addon->IsVisible)
{
string name2 = addon->NameString;
log.Warning("[PartyInviteAutoAccept] Visible addon: " + name2);
}
}
}
log.Warning("[PartyInviteAutoAccept] === END ADDON DUMP ===");
}
else
{
AtkUnitBase* addon2 = (AtkUnitBase*)addonPtr;
if (addon2 == null)
{ {
log.Warning("[PartyInviteAutoAccept] Addon pointer is null!"); log.Warning("[PartyInviteAutoAccept] Addon pointer is null!");
return;
} }
if (!addon2->IsVisible) else if (addon->IsVisible)
{ {
log.Debug("[PartyInviteAutoAccept] Addon exists but not visible yet");
return;
}
AtkValue* values = stackalloc AtkValue[1]; AtkValue* values = stackalloc AtkValue[1];
*values = new AtkValue *values = new AtkValue
{ {
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int, Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
Int = 0 Int = 0
}; };
addon2->FireCallback(1u, values); addon->FireCallback(1u, values);
AtkValue* values2 = stackalloc AtkValue[2]; AtkValue* values2 = stackalloc AtkValue[2];
*values2 = new AtkValue *values2 = new AtkValue
{ {
@ -131,7 +134,8 @@ public class PartyInviteAutoAccept : IDisposable
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.UInt, Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.UInt,
UInt = 0u UInt = 0u
}; };
addon2->FireCallback(2u, values2); addon->FireCallback(2u, values2);
}
} }
} }
catch (Exception ex) catch (Exception ex)

View file

@ -293,6 +293,22 @@ public class QuestPreCheckService : IDisposable
log.Information("[QuestPreCheck] Pre-check results cleared"); log.Information("[QuestPreCheck] Pre-check results cleared");
} }
public void ClearCharacterData(string characterName)
{
if (questDatabase.ContainsKey(characterName))
{
int questCount = questDatabase[characterName].Count;
questDatabase.Remove(characterName);
SaveQuestDatabase();
log.Information($"[QuestPreCheck] Cleared {questCount} quests for {characterName}");
}
else
{
log.Information("[QuestPreCheck] No quest data found for " + characterName);
}
lastRefreshByCharacter.Remove(characterName);
}
public void Dispose() public void Dispose()
{ {
SaveQuestDatabase(); SaveQuestDatabase();

View file

@ -538,6 +538,31 @@ public class QuestRotationExecutionService : IDisposable
return false; return false;
} }
public void ClearCharacterQuestData(string characterName)
{
log.Information("[QuestRotation] Clearing all quest data for " + characterName);
int questsCleared = 0;
foreach (KeyValuePair<uint, List<string>> kvp in questCompletionByCharacter.ToList())
{
if (kvp.Value.Remove(characterName))
{
questsCleared++;
}
if (kvp.Value.Count == 0)
{
questCompletionByCharacter.Remove(kvp.Key);
}
}
log.Information($"[QuestRotation] Removed {characterName} from {questsCleared} quests in rotation tracking");
if (preCheckService != null)
{
preCheckService.ClearCharacterData(characterName);
log.Information("[QuestRotation] Cleared " + characterName + " data from PreCheck service");
}
onDataChanged?.Invoke();
log.Information("[QuestRotation] Quest data reset complete for " + characterName);
}
private void ScanAndSaveAllCompletedQuests(string characterName) private void ScanAndSaveAllCompletedQuests(string characterName)
{ {
if (string.IsNullOrEmpty(characterName)) if (string.IsNullOrEmpty(characterName))
@ -637,12 +662,19 @@ public class QuestRotationExecutionService : IDisposable
{ {
log.Warning("[ErrorRecovery] Disconnect detected for " + charToRelog); log.Warning("[ErrorRecovery] Disconnect detected for " + charToRelog);
log.Information("[ErrorRecovery] Automatically relogging to " + charToRelog + "..."); log.Information("[ErrorRecovery] Automatically relogging to " + charToRelog + "...");
if (errorRecoveryService.RequestRelog())
{
errorRecoveryService.Reset(); errorRecoveryService.Reset();
currentState.Phase = RotationPhase.WaitingForCharacterLogin; currentState.Phase = RotationPhase.WaitingForCharacterLogin;
currentState.CurrentCharacter = charToRelog; currentState.CurrentCharacter = charToRelog;
currentState.PhaseStartTime = DateTime.Now; currentState.PhaseStartTime = DateTime.Now;
autoRetainerIpc.SwitchCharacter(charToRelog);
log.Information("[ErrorRecovery] Relog initiated for " + charToRelog); log.Information("[ErrorRecovery] Relog initiated for " + charToRelog);
}
else
{
log.Error("[ErrorRecovery] Failed to request relog via AutoRetainer");
errorRecoveryService.Reset();
}
return; return;
} }
log.Warning("[ErrorRecovery] Disconnect detected but no character to relog to"); log.Warning("[ErrorRecovery] Disconnect detected but no character to relog to");
@ -652,13 +684,7 @@ public class QuestRotationExecutionService : IDisposable
{ {
deathHandler.Update(); deathHandler.Update();
} }
if (dungeonAutomation != null) if (dungeonAutomation != null && !submarineManager.IsSubmarinePaused)
{
if (submarineManager.IsSubmarinePaused)
{
log.Debug("[QuestRotation] Submarine multi-mode active - skipping dungeon validation");
}
else
{ {
dungeonAutomation.Update(); dungeonAutomation.Update();
if (isRotationActive && configuration.EnableAutoDutyUnsynced && !dungeonAutomation.IsWaitingForParty && currentState.Phase != RotationPhase.WaitingForCharacterLogin && currentState.Phase != RotationPhase.WaitingBeforeCharacterSwitch && currentState.Phase != RotationPhase.WaitingForHomeworldReturn && currentState.Phase != RotationPhase.ScanningQuests && currentState.Phase != RotationPhase.CheckingQuestCompletion && currentState.Phase != RotationPhase.InitializingFirstCharacter) if (isRotationActive && configuration.EnableAutoDutyUnsynced && !dungeonAutomation.IsWaitingForParty && currentState.Phase != RotationPhase.WaitingForCharacterLogin && currentState.Phase != RotationPhase.WaitingBeforeCharacterSwitch && currentState.Phase != RotationPhase.WaitingForHomeworldReturn && currentState.Phase != RotationPhase.ScanningQuests && currentState.Phase != RotationPhase.CheckingQuestCompletion && currentState.Phase != RotationPhase.InitializingFirstCharacter)
@ -666,7 +692,6 @@ public class QuestRotationExecutionService : IDisposable
_ = submarineManager.IsSubmarinePaused; _ = submarineManager.IsSubmarinePaused;
} }
} }
}
if (combatDutyDetection != null) if (combatDutyDetection != null)
{ {
combatDutyDetection.Update(); combatDutyDetection.Update();
@ -1164,7 +1189,25 @@ public class QuestRotationExecutionService : IDisposable
{ {
string currentQuestIdStr2 = questionableIPC.GetCurrentQuestId(); string currentQuestIdStr2 = questionableIPC.GetCurrentQuestId();
byte? currentSequence2 = questionableIPC.GetCurrentSequence(); byte? currentSequence2 = questionableIPC.GetCurrentSequence();
if (!string.IsNullOrEmpty(currentQuestIdStr2) && currentSequence2.HasValue && uint.TryParse(currentQuestIdStr2, out var currentQuestId2)) uint currentQuestId2;
if (string.IsNullOrEmpty(currentQuestIdStr2) && currentState.HasQuestBeenAccepted)
{
if (QuestManager.Instance() != null)
{
byte gameQuestSeq = QuestManager.GetQuestSequence((ushort)questId);
if (gameQuestSeq >= activeStopPoint.Sequence.Value)
{
log.Information("[QuestRotation] ✓ Questionable auto-stopped at stop point!");
log.Information($"[QuestRotation] Quest {questId} Sequence {gameQuestSeq} >= {activeStopPoint.Sequence.Value}");
shouldRotate = true;
}
else
{
log.Debug($"[QuestRotation] Questionable stopped but not at stop sequence (seq {gameQuestSeq} < {activeStopPoint.Sequence.Value})");
}
}
}
else if (!string.IsNullOrEmpty(currentQuestIdStr2) && currentSequence2.HasValue && uint.TryParse(currentQuestIdStr2, out currentQuestId2))
{ {
if (currentQuestId2 == questId) if (currentQuestId2 == questId)
{ {
@ -1315,6 +1358,8 @@ public class QuestRotationExecutionService : IDisposable
{ {
return; return;
} }
if (configuration.ReturnToHomeworldOnStopQuest)
{
log.Information("[QuestRotation] ========================================"); log.Information("[QuestRotation] ========================================");
log.Information("[QuestRotation] === SENDING HOMEWORLD RETURN COMMAND ==="); log.Information("[QuestRotation] === SENDING HOMEWORLD RETURN COMMAND ===");
log.Information("[QuestRotation] ========================================"); log.Information("[QuestRotation] ========================================");
@ -1327,6 +1372,11 @@ public class QuestRotationExecutionService : IDisposable
{ {
log.Error("[QuestRotation] Failed to send /li command: " + ex.Message); log.Error("[QuestRotation] Failed to send /li command: " + ex.Message);
} }
}
else
{
log.Information("[QuestRotation] Skipping homeworld return (setting disabled)");
}
Task.Delay(2000).ContinueWith(delegate Task.Delay(2000).ContinueWith(delegate
{ {
framework.RunOnFrameworkThread(delegate framework.RunOnFrameworkThread(delegate

View file

@ -47,10 +47,6 @@ public class StepsOfFaithHandler : IDisposable
public bool ShouldActivate(uint questId, bool isInSoloDuty) public bool ShouldActivate(uint questId, bool isInSoloDuty)
{ {
if (!config.EnableAutoDutyUnsynced)
{
return false;
}
if (isActive) if (isActive)
{ {
return false; return false;
@ -70,8 +66,10 @@ public class StepsOfFaithHandler : IDisposable
} }
if (characterHandledStatus.GetValueOrDefault(characterName, defaultValue: false)) if (characterHandledStatus.GetValueOrDefault(characterName, defaultValue: false))
{ {
log.Debug("[StepsOfFaith] Character " + characterName + " already handled SoF - skipping");
return false; return false;
} }
log.Information("[StepsOfFaith] Handler will activate for " + characterName);
return true; return true;
} }
@ -108,11 +106,6 @@ public class StepsOfFaithHandler : IDisposable
} }
log.Information("[StepsOfFaith] Waiting 25s for stabilization..."); log.Information("[StepsOfFaith] Waiting 25s for stabilization...");
Thread.Sleep(25000); Thread.Sleep(25000);
log.Information("[StepsOfFaith] Disabling Bossmod Rotation...");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/vbm ar disable");
});
log.Information("[StepsOfFaith] Moving to target position..."); log.Information("[StepsOfFaith] Moving to target position...");
framework.RunOnFrameworkThread(delegate framework.RunOnFrameworkThread(delegate
{ {

View file

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@ -28,6 +27,8 @@ public class SubmarineManager : IDisposable
private bool submarinesPaused; private bool submarinesPaused;
private bool externalPause;
private bool submarinesWaitingForSeq0; private bool submarinesWaitingForSeq0;
private bool submarineReloginInProgress; private bool submarineReloginInProgress;
@ -36,7 +37,17 @@ public class SubmarineManager : IDisposable
private string? originalCharacterForSubmarines; private string? originalCharacterForSubmarines;
public bool IsSubmarinePaused => submarinesPaused; public bool IsSubmarinePaused
{
get
{
if (!submarinesPaused)
{
return externalPause;
}
return true;
}
}
public bool IsWaitingForSequence0 => submarinesWaitingForSeq0; public bool IsWaitingForSequence0 => submarinesWaitingForSeq0;
@ -44,6 +55,12 @@ public class SubmarineManager : IDisposable
public bool IsSubmarineJustCompleted => submarineJustCompleted; public bool IsSubmarineJustCompleted => submarineJustCompleted;
public void SetExternalPause(bool paused)
{
externalPause = paused;
log.Information($"[SubmarineManager] External pause set to: {paused}");
}
public SubmarineManager(IPluginLog log, AutoRetainerIPC autoRetainerIPC, Configuration config, ICommandManager? commandManager = null, IFramework? framework = null) public SubmarineManager(IPluginLog log, AutoRetainerIPC autoRetainerIPC, Configuration config, ICommandManager? commandManager = null, IFramework? framework = null)
{ {
this.log = log; this.log = log;
@ -88,7 +105,7 @@ public class SubmarineManager : IDisposable
public bool CheckSubmarines() public bool CheckSubmarines()
{ {
if (!config.EnableSubmarineCheck) if (!config.EnableSubmarineCheck || externalPause)
{ {
return false; return false;
} }
@ -154,7 +171,7 @@ public class SubmarineManager : IDisposable
public int CheckSubmarinesSoon() public int CheckSubmarinesSoon()
{ {
if (!config.EnableSubmarineCheck) if (!config.EnableSubmarineCheck || externalPause)
{ {
return 0; return 0;
} }
@ -230,25 +247,39 @@ public class SubmarineManager : IDisposable
{ {
JObject json = JObject.Parse(jsonContent); JObject json = JObject.Parse(jsonContent);
HashSet<string> enabledSubs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); HashSet<string> enabledSubs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (json.SelectTokens("$..EnabledSubs").FirstOrDefault() is JArray enabledSubsArray) IEnumerable<JToken> enumerable = json.SelectTokens("$..EnabledSubs");
int arrayCount = 0;
foreach (JToken item in enumerable)
{ {
foreach (JToken item in enabledSubsArray) if (!(item is JArray enabledSubsArray))
{ {
string subName = item.Value<string>(); continue;
}
arrayCount++;
foreach (JToken item2 in enabledSubsArray)
{
string subName = item2.Value<string>();
if (!string.IsNullOrEmpty(subName)) if (!string.IsNullOrEmpty(subName))
{ {
enabledSubs.Add(subName); enabledSubs.Add(subName);
} }
} }
}
if (arrayCount > 0)
{
if (enabledSubs.Count > 0) if (enabledSubs.Count > 0)
{ {
log.Information($"[SubmarineManager] Found {enabledSubs.Count} enabled submarines: {string.Join(", ", enabledSubs)}"); log.Information($"[SubmarineManager] Found {enabledSubs.Count} unique submarine name(s) across {arrayCount} character(s): {string.Join(", ", enabledSubs)}");
} }
else else
{ {
log.Information("[SubmarineManager] EnabledSubs array found but empty - NO submarines will be checked"); log.Information($"[SubmarineManager] Found {arrayCount} EnabledSubs array(s) but all empty - NO submarines will be checked");
} }
FindReturnTimes(json, returnTimes, enabledSubs); FindReturnTimes(json, returnTimes, enabledSubs);
if (returnTimes.Count > 0)
{
log.Information($"[SubmarineManager] Total submarines to monitor: {returnTimes.Count} (including same-named subs from different characters)");
}
} }
else else
{ {
@ -281,10 +312,6 @@ public class SubmarineManager : IDisposable
{ {
long returnTime = returnTimeToken.Value<long>(); long returnTime = returnTimeToken.Value<long>();
returnTimes.Add(returnTime); returnTimes.Add(returnTime);
if (enabledSubs != null)
{
log.Debug($"[SubmarineManager] Including submarine '{submarineName}' (ReturnTime: {returnTime})");
}
} }
} }
{ {

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using System.Threading.Tasks;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
@ -140,6 +141,8 @@ public class NewMainWindow : Window, IDisposable
private DateTime lastEventQuestRefresh = DateTime.MinValue; private DateTime lastEventQuestRefresh = DateTime.MinValue;
private string? newLANHelperIP;
private readonly Dictionary<string, List<string>> dataCenterWorlds = new Dictionary<string, List<string>> private readonly Dictionary<string, List<string>> dataCenterWorlds = new Dictionary<string, List<string>>
{ {
{ {
@ -533,6 +536,7 @@ public class NewMainWindow : Window, IDisposable
DrawSidebarItem("Event Quest", 6, 0); DrawSidebarItem("Event Quest", 6, 0);
DrawSidebarItem("MSQ Progression", 7, 0); DrawSidebarItem("MSQ Progression", 7, 0);
DrawSidebarItem("Data Center Travel", 8, 0); DrawSidebarItem("Data Center Travel", 8, 0);
DrawSidebarItem("Multiboxing", 12, 0);
DrawSidebarItem("Settings", 9, 0); DrawSidebarItem("Settings", 9, 0);
} }
else else
@ -616,7 +620,7 @@ public class NewMainWindow : Window, IDisposable
uint rightColor = ImGui.ColorConvertFloat4ToU32(new Vector4(colorSecondary.X * 0.3f, colorSecondary.Y * 0.3f, colorSecondary.Z * 0.3f, 1f)); uint rightColor = ImGui.ColorConvertFloat4ToU32(new Vector4(colorSecondary.X * 0.3f, colorSecondary.Y * 0.3f, colorSecondary.Z * 0.3f, 1f));
drawList.AddRectFilledMultiColor(windowPos, windowPos + new Vector2(windowSize.X, height), leftColor, rightColor, rightColor, leftColor); drawList.AddRectFilledMultiColor(windowPos, windowPos + new Vector2(windowSize.X, height), leftColor, rightColor, rightColor, leftColor);
Vector2 titlePos = windowPos + new Vector2(10f, 7f); Vector2 titlePos = windowPos + new Vector2(10f, 7f);
drawList.AddText(titlePos, ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.9f)), "Questionable Companion V.1.0.5"); drawList.AddText(titlePos, ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.9f)), "Questionable Companion V.1.0.6");
Vector2 minimizeButtonPos = windowPos + new Vector2(windowSize.X - 60f, 3f); Vector2 minimizeButtonPos = windowPos + new Vector2(windowSize.X - 60f, 3f);
Vector2 minimizeButtonSize = new Vector2(24f, 24f); Vector2 minimizeButtonSize = new Vector2(24f, 24f);
if (ImGui.IsMouseHoveringRect(minimizeButtonPos, minimizeButtonPos + minimizeButtonSize)) if (ImGui.IsMouseHoveringRect(minimizeButtonPos, minimizeButtonPos + minimizeButtonSize))
@ -718,6 +722,9 @@ public class NewMainWindow : Window, IDisposable
case 11: case 11:
DrawWarningTab(); DrawWarningTab();
break; break;
case 12:
DrawMultiboxingTab();
break;
} }
} }
} }
@ -776,9 +783,9 @@ public class NewMainWindow : Window, IDisposable
{ {
selectedDataCenter = config.DCTravelDataCenter; selectedDataCenter = config.DCTravelDataCenter;
} }
if (string.IsNullOrEmpty(selectedWorld) && !string.IsNullOrEmpty(config.DCTravelTargetWorld)) if (string.IsNullOrEmpty(selectedWorld) && !string.IsNullOrEmpty(config.DCTravelWorld))
{ {
selectedWorld = config.DCTravelTargetWorld; selectedWorld = config.DCTravelWorld;
} }
if (string.IsNullOrEmpty(selectedDataCenter)) if (string.IsNullOrEmpty(selectedDataCenter))
{ {
@ -921,7 +928,7 @@ public class NewMainWindow : Window, IDisposable
ImGui.TextUnformatted(text2); ImGui.TextUnformatted(text2);
ImGui.SameLine(); ImGui.SameLine();
ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary);
ImGui.TextUnformatted((config.DCTravelTargetWorld.Length > 0) ? config.DCTravelTargetWorld : "Not Set"); ImGui.TextUnformatted((config.DCTravelWorld.Length > 0) ? config.DCTravelWorld : "Not Set");
ImGui.PopStyleColor(); ImGui.PopStyleColor();
ImU8String text3 = new ImU8String(8, 0); ImU8String text3 = new ImU8String(8, 0);
text3.AppendLiteral("Status: "); text3.AppendLiteral("Status: ");
@ -946,7 +953,7 @@ public class NewMainWindow : Window, IDisposable
if (ImGui.Button("Apply", new Vector2(120f, 30f))) if (ImGui.Button("Apply", new Vector2(120f, 30f)))
{ {
config.DCTravelDataCenter = selectedDataCenter; config.DCTravelDataCenter = selectedDataCenter;
config.DCTravelTargetWorld = selectedWorld; config.DCTravelWorld = selectedWorld;
config.Save(); config.Save();
log.Information("[DCTravel] Configuration saved: " + selectedDataCenter + " -> " + selectedWorld); log.Information("[DCTravel] Configuration saved: " + selectedDataCenter + " -> " + selectedWorld);
} }
@ -955,7 +962,7 @@ public class NewMainWindow : Window, IDisposable
if (ImGui.Button("Cancel", new Vector2(120f, 30f))) if (ImGui.Button("Cancel", new Vector2(120f, 30f)))
{ {
selectedDataCenter = config.DCTravelDataCenter; selectedDataCenter = config.DCTravelDataCenter;
selectedWorld = config.DCTravelTargetWorld; selectedWorld = config.DCTravelWorld;
if (string.IsNullOrEmpty(selectedDataCenter)) if (string.IsNullOrEmpty(selectedDataCenter))
{ {
selectedDataCenter = dataCenterWorlds.Keys.First(); selectedDataCenter = dataCenterWorlds.Keys.First();
@ -978,81 +985,18 @@ public class NewMainWindow : Window, IDisposable
} }
} }
private void DrawSettingsTabFull() private void DrawMultiboxingTab()
{ {
ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary);
ImGui.TextUnformatted("Plugin Settings"); ImGui.TextUnformatted("Multiboxing Settings");
ImGui.PopStyleColor(); ImGui.PopStyleColor();
ImGuiHelpers.ScaledDummy(10f); ImGuiHelpers.ScaledDummy(10f);
using ImRaii.IEndObject child = ImRaii.Child("SettingsScrollArea", new Vector2(0f, 0f), border: false, ImGuiWindowFlags.None); using ImRaii.IEndObject child = ImRaii.Child("MultiboxingScrollArea", new Vector2(0f, 0f), border: false, ImGuiWindowFlags.None);
if (!child.Success) if (!child.Success)
{ {
return; return;
} }
Configuration config = plugin.Configuration; Configuration config = plugin.Configuration;
DrawSettingSection("Submarine Management", delegate
{
config.EnableSubmarineCheck = DrawSettingWithInfo("Enable Submarine Monitoring", config.EnableSubmarineCheck, "Automatically monitors submarines and pauses quest rotation when submarines are ready.\nPrevents quest progression while submarines need attention.\nImpact: Rotation will pause when submarines are detected.");
if (ImGui.IsItemDeactivatedAfterEdit())
{
config.Save();
}
if (config.EnableSubmarineCheck)
{
ImGui.Indent();
int v = config.SubmarineCheckInterval;
if (ImGui.SliderInt("Check Interval (seconds)", ref v, 30, 300))
{
config.SubmarineCheckInterval = v;
config.Save();
}
DrawInfoIcon("How often to check for submarine status.\nLower values = more frequent checks but higher CPU usage.");
int v2 = config.SubmarineReloginCooldown;
if (ImGui.SliderInt("Cooldown after Relog (seconds)", ref v2, 60, 300))
{
config.SubmarineReloginCooldown = v2;
config.Save();
}
DrawInfoIcon("Time to wait after character switch before checking submarines again.");
int v3 = config.SubmarineWaitTime;
if (ImGui.SliderInt("Wait time before submarine (seconds)", ref v3, 10, 120))
{
config.SubmarineWaitTime = v3;
config.Save();
}
DrawInfoIcon("Delay before starting submarine operations after detection.");
ImGui.Unindent();
}
}, config.EnableSubmarineCheck);
ImGuiHelpers.ScaledDummy(10f);
DrawSettingSection("AutoRetainer Post Process Event Quests", delegate
{
config.RunEventQuestsOnARPostProcess = DrawSettingWithInfo("Run Event Quests on AR Post Process", config.RunEventQuestsOnARPostProcess, "AUTO-DETECTION: Automatically detects and runs active Event Quests when AutoRetainer completes a character.\nEvent Quests are detected via Questionable IPC (same as manual Event Quest tab).\nAll prerequisites will be automatically resolved and executed.\nAutoRetainer will wait until all Event Quests are completed before proceeding.\nImpact: Extends AR post-process time but ensures Event Quests are completed.");
if (ImGui.IsItemDeactivatedAfterEdit())
{
config.Save();
}
if (config.RunEventQuestsOnARPostProcess)
{
ImGui.Indent();
ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.4f, 0.8f, 0.4f, 1f));
ImGui.TextUnformatted("Auto-Detection Enabled");
ImGui.PopStyleColor();
ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary);
ImGui.TextWrapped("Event Quests will be automatically detected from Questionable when AR Post Process starts. No manual configuration needed - just enable this setting and the plugin will handle the rest!");
ImGui.PopStyleColor();
ImGuiHelpers.ScaledDummy(5f);
int v = config.EventQuestPostProcessTimeoutMinutes;
if (ImGui.SliderInt("Timeout (minutes)", ref v, 10, 60))
{
config.EventQuestPostProcessTimeoutMinutes = v;
config.Save();
}
DrawInfoIcon("Maximum time to wait for Event Quests to complete.\nAfter timeout, AR will proceed with next character.");
ImGui.Unindent();
}
}, config.RunEventQuestsOnARPostProcess);
ImGuiHelpers.ScaledDummy(10f);
DrawSettingSection("Dungeon Automation", delegate DrawSettingSection("Dungeon Automation", delegate
{ {
bool enableAutoDutyUnsynced = config.EnableAutoDutyUnsynced; bool enableAutoDutyUnsynced = config.EnableAutoDutyUnsynced;
@ -1086,6 +1030,15 @@ public class NewMainWindow : Window, IDisposable
config.Save(); config.Save();
} }
DrawInfoIcon("How often to re-send party invites if members don't join."); DrawInfoIcon("How often to re-send party invites if members don't join.");
ImGuiHelpers.ScaledDummy(5f);
bool v4 = config.EnableARRPrimalCheck;
if (ImGui.Checkbox("Check ARR Primals when hitting flag", ref v4))
{
config.EnableARRPrimalCheck = v4;
config.Save();
Plugin.Log.Information("[Multiboxing] ARR Primal Check: " + (v4 ? "ENABLED" : "DISABLED"));
}
DrawInfoIcon("Checks if ARR Hard Mode Primals (Ifrit/Garuda/Titan) are done.\nRequired for Quest 363 (Good Intentions).");
ImGui.Unindent(); ImGui.Unindent();
} }
}, config.EnableAutoDutyUnsynced); }, config.EnableAutoDutyUnsynced);
@ -1116,6 +1069,7 @@ public class NewMainWindow : Window, IDisposable
config.IsQuester = true; config.IsQuester = true;
config.IsHighLevelHelper = false; config.IsHighLevelHelper = false;
config.Save(); config.Save();
Plugin.Log.Information("[Multiboxing] Role changed to: Quester");
} }
ImGui.SameLine(); ImGui.SameLine();
DrawInfoIcon("This client will quest and invite helpers for dungeons"); DrawInfoIcon("This client will quest and invite helpers for dungeons");
@ -1126,11 +1080,168 @@ public class NewMainWindow : Window, IDisposable
Plugin.Framework.RunOnFrameworkThread(delegate Plugin.Framework.RunOnFrameworkThread(delegate
{ {
Plugin.Instance?.GetHelperManager()?.AnnounceIfHelper(); Plugin.Instance?.GetHelperManager()?.AnnounceIfHelper();
Plugin.Instance?.GetChauffeurMode()?.StartHelperStatusBroadcast();
}); });
config.Save(); config.Save();
Plugin.Log.Information("[Multiboxing] Role changed to: High-Level Helper");
} }
ImGui.SameLine(); ImGui.SameLine();
DrawInfoIcon("This client will help with dungeons.\nAutoDuty starts/stops automatically on duty enter/leave"); DrawInfoIcon("This client will help with dungeons.\nAutoDuty starts/stops automatically on duty enter/leave");
if (config.IsHighLevelHelper)
{
ImGuiHelpers.ScaledDummy(5f);
ImGui.Indent();
bool v = config.AlwaysAutoAcceptInvites;
if (ImGui.Checkbox("Always Auto-Accept Party Invites", ref v))
{
config.AlwaysAutoAcceptInvites = v;
config.Save();
}
DrawInfoIcon("Continuously accept ALL party invites (useful for ManualInput mode without IPC)");
ImGui.Unindent();
}
ImGuiHelpers.ScaledDummy(10f);
ImGui.Separator();
ImGuiHelpers.ScaledDummy(5f);
ImGui.TextColored(in colorPrimary, "LAN Multi-PC Helper System");
ImGui.TextWrapped("Connect helpers on different PCs in your HOME NETWORK.");
ImGuiHelpers.ScaledDummy(3f);
config.EnableLANHelpers = DrawSettingWithInfo("Enable LAN Helper System", config.EnableLANHelpers, "Connect to helpers on other PCs in YOUR home network.\nNOT accessible from internet! Only devices in your home can connect.");
if (ImGui.IsItemDeactivatedAfterEdit())
{
config.Save();
}
if (config.EnableLANHelpers)
{
ImGui.Indent();
if (config.IsHighLevelHelper)
{
bool flag = DrawSettingWithInfo("Start LAN Server on this PC", config.StartLANServer, "Enable so OTHER PCs in your home can connect to THIS PC.\nNOT exposed to internet! Only devices in your home can connect.");
if (flag != config.StartLANServer)
{
config.StartLANServer = flag;
config.Save();
plugin.ToggleLANServer(flag);
}
}
ImGuiHelpers.ScaledDummy(5f);
ImGui.TextColored(in colorSecondary, "Server Port:");
ImGui.SetNextItemWidth(150f);
int data = config.LANServerPort;
if (ImGui.InputInt("##LANPort", ref data) && data >= 1024 && data <= 65535)
{
config.LANServerPort = data;
config.Save();
}
ImGui.SameLine();
DrawInfoIcon("Port for local network communication (default: 47788).\nFirewall may need to allow this port.");
ImGuiHelpers.ScaledDummy(5f);
ImGui.TextColored(in colorSecondary, "Helper PC IP Addresses:");
ImGui.TextWrapped("Add IPs of OTHER PCs in your home with helper characters:");
ImGuiHelpers.ScaledDummy(3f);
if (config.LANHelperIPs == null)
{
config.LANHelperIPs = new List<string>();
}
for (int num2 = 0; num2 < config.LANHelperIPs.Count; num2++)
{
ImU8String strId = new ImU8String(3, 1);
strId.AppendLiteral("IP_");
strId.AppendFormatted(num2);
ImGui.PushID(strId);
ImGui.BulletText(config.LANHelperIPs[num2]);
ImGui.SameLine();
if (ImGui.SmallButton("\ud83d\udd04 Reconnect"))
{
string ip = config.LANHelperIPs[num2];
LANHelperClient lanClient = plugin.GetLANHelperClient();
if (lanClient != null)
{
Task.Run(async delegate
{
Plugin.Log.Information("[UI] Manual reconnect to " + ip + "...");
await lanClient.ConnectToHelperAsync(ip);
});
}
}
ImGui.SameLine();
if (ImGui.SmallButton("Remove"))
{
config.LANHelperIPs.RemoveAt(num2);
config.Save();
num2--;
}
ImGui.PopID();
}
if (config.LANHelperIPs.Count == 0)
{
ImGui.TextColored(new Vector4(1f, 0.8f, 0.2f, 1f), "No IPs configured");
ImGui.TextWrapped("Add IPs below (or use 127.0.0.1 for same-PC testing)");
}
ImGuiHelpers.ScaledDummy(3f);
ImGui.TextColored(in colorSecondary, "Add new IP:");
ImGui.SetNextItemWidth(200f);
string buf = newLANHelperIP ?? "";
if (ImGui.InputText("##NewIP", ref buf, 50))
{
newLANHelperIP = buf;
}
ImGui.SameLine();
if (ImGui.Button("Add IP") && !string.IsNullOrWhiteSpace(newLANHelperIP))
{
string trimmedIP = newLANHelperIP.Trim();
if (!config.LANHelperIPs.Contains(trimmedIP))
{
config.LANHelperIPs.Add(trimmedIP);
config.Save();
newLANHelperIP = "";
LANHelperClient lanClient2 = plugin.GetLANHelperClient();
if (lanClient2 != null)
{
Task.Run(async delegate
{
await lanClient2.ConnectToHelperAsync(trimmedIP);
});
}
}
}
ImGui.SameLine();
if (ImGui.SmallButton("Add Localhost") && !config.LANHelperIPs.Contains("127.0.0.1"))
{
config.LANHelperIPs.Add("127.0.0.1");
config.Save();
LANHelperClient lanClient3 = plugin.GetLANHelperClient();
if (lanClient3 != null)
{
Task.Run(async delegate
{
await lanClient3.ConnectToHelperAsync("127.0.0.1");
});
}
}
ImGuiHelpers.ScaledDummy(3f);
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1f), "\ud83d\udca1 Tip: Run 'ipconfig' and use your IPv4-Adresse (like 192.168.x.x)");
ImGuiHelpers.ScaledDummy(5f);
ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.4f, 0.8f, 0.4f, 1f));
if (config.StartLANServer)
{
ImU8String text = new ImU8String(48, 1);
text.AppendLiteral("✓ LAN Server enabled (LOCAL network only, port ");
text.AppendFormatted(config.LANServerPort);
text.AppendLiteral(")");
ImGui.TextWrapped(text);
}
if (config.LANHelperIPs.Count > 0)
{
ImU8String text2 = new ImU8String(37, 1);
text2.AppendLiteral("✓ Will connect to ");
text2.AppendFormatted(config.LANHelperIPs.Count);
text2.AppendLiteral(" local helper PC(s)");
ImGui.TextWrapped(text2);
}
ImGui.PopStyleColor();
ImGui.Unindent();
}
ImGuiHelpers.ScaledDummy(10f); ImGuiHelpers.ScaledDummy(10f);
if (config.IsQuester) if (config.IsQuester)
{ {
@ -1140,140 +1251,202 @@ public class NewMainWindow : Window, IDisposable
ImGui.TextWrapped("Helpers are automatically discovered via IPC when they have 'I'm a High-Level Helper' enabled:"); ImGui.TextWrapped("Helpers are automatically discovered via IPC when they have 'I'm a High-Level Helper' enabled:");
ImGuiHelpers.ScaledDummy(5f); ImGuiHelpers.ScaledDummy(5f);
List<(string, ushort)> availableHelpers = plugin.GetAvailableHelpers(); List<(string, ushort)> availableHelpers = plugin.GetAvailableHelpers();
if (availableHelpers.Count != 0) if (availableHelpers.Count == 0)
{
ImGui.TextColored(new Vector4(1f, 0.8f, 0.2f, 1f), "No helpers discovered yet");
ImGui.TextWrapped("Make sure helper clients are running with 'I'm a High-Level Helper' enabled.");
}
else
{ {
Vector4 col = new Vector4(0.2f, 1f, 0.2f, 1f); Vector4 col = new Vector4(0.2f, 1f, 0.2f, 1f);
ImU8String text = new ImU8String(21, 1); ImU8String text3 = new ImU8String(20, 1);
text.AppendFormatted(availableHelpers.Count); text3.AppendFormatted(availableHelpers.Count);
text.AppendLiteral(" helper(s) available:"); text3.AppendLiteral(" helper(s) available");
ImGui.TextColored(in col, text); ImGui.TextColored(in col, text3);
}
ImGuiHelpers.ScaledDummy(5f); ImGuiHelpers.ScaledDummy(5f);
ImGui.TextUnformatted("Preferred Helper for Chauffeur:"); ImGui.Separator();
ImGuiHelpers.ScaledDummy(5f);
ImGui.TextColored(in colorPrimary, "Helper Selection Mode");
ImGuiHelpers.ScaledDummy(3f);
int helperSelection = (int)config.HelperSelection;
if (ImGui.RadioButton("Auto", helperSelection == 0))
{
config.HelperSelection = HelperSelectionMode.Auto;
config.PreferredHelper = "";
config.ManualHelperName = "";
config.Save();
}
ImGui.SameLine();
DrawInfoIcon("First available helper via IPC");
if (ImGui.RadioButton("Dropdown", helperSelection == 1))
{
config.HelperSelection = HelperSelectionMode.Dropdown;
config.ManualHelperName = "";
config.Save();
}
ImGui.SameLine();
DrawInfoIcon("Select specific helper from list");
if (config.HelperSelection == HelperSelectionMode.Dropdown && availableHelpers.Count > 0)
{
ImGui.Indent();
ImGui.SetNextItemWidth(250f); ImGui.SetNextItemWidth(250f);
List<string> list = new List<string> { "Auto (First Available)" }; string text4 = (string.IsNullOrEmpty(config.PreferredHelper) ? "-- Select --" : config.PreferredHelper);
if (ImGui.BeginCombo("##PreferredHelper", text4))
{
foreach (var item5 in availableHelpers) foreach (var item5 in availableHelpers)
{ {
string item = item5.Item1; string item = item5.Item1;
ushort item2 = item5.Item2; ushort item2 = item5.Item2;
ExcelSheet<World> excelSheet = Plugin.DataManager.GetExcelSheet<World>(); ExcelSheet<World> excelSheet = Plugin.DataManager.GetExcelSheet<World>();
string text2 = "Unknown"; string text5 = "Unknown";
if (excelSheet != null) if (excelSheet != null)
{ {
foreach (World current2 in excelSheet) foreach (World current2 in excelSheet)
{ {
if (current2.RowId == item2) if (current2.RowId == item2)
{ {
text2 = current2.Name.ExtractText(); text5 = current2.Name.ExtractText();
break; break;
} }
} }
} }
list.Add(item + "@" + text2); string text6 = item + "@" + text5;
} bool selected = config.PreferredHelper == text6;
string text3 = (string.IsNullOrEmpty(config.PreferredHelper) ? "Auto (First Available)" : config.PreferredHelper); if (ImGui.Selectable(text6, selected))
if (ImGui.BeginCombo("##PreferredHelper", text3))
{ {
foreach (string current3 in list) config.PreferredHelper = text6;
{
bool flag = text3 == current3;
if (ImGui.Selectable(current3, flag))
{
config.PreferredHelper = ((current3 == "Auto (First Available)") ? "" : current3);
config.Save(); config.Save();
} }
if (flag)
{
ImGui.SetItemDefaultFocus();
}
} }
ImGui.EndCombo(); ImGui.EndCombo();
} }
ImGui.SameLine();
DrawInfoIcon("Select which helper to use for Chauffeur Mode.\n'Auto' will use the first available helper.");
if (!string.IsNullOrEmpty(config.PreferredHelper)) if (!string.IsNullOrEmpty(config.PreferredHelper))
{ {
ImGuiHelpers.ScaledDummy(3f); string text7 = (Plugin.Instance?.GetChauffeurMode())?.GetHelperStatus(config.PreferredHelper);
string text4 = (Plugin.Instance?.GetChauffeurMode())?.GetHelperStatus(config.PreferredHelper); Vector4 col;
Vector4 col2; Vector4 col2;
ImU8String text5; ImU8String text8;
switch (text4) switch (text7)
{ {
case "Available": case "Available":
col = new Vector4(0.2f, 1f, 0.2f, 1f); col = new Vector4(0.2f, 1f, 0.2f, 1f);
goto IL_04f7; goto IL_0c39;
case "Transporting": case "Transporting":
col = new Vector4(1f, 0.8f, 0f, 1f); col = new Vector4(1f, 0.8f, 0f, 1f);
goto IL_04f7; goto IL_0c39;
case "InDungeon": case "InDungeon":
col = new Vector4(1f, 0.3f, 0.3f, 1f); col = new Vector4(1f, 0.3f, 0.3f, 1f);
goto IL_04f7; goto IL_0c39;
default: default:
col = colorSecondary; col = colorSecondary;
goto IL_04f7; goto IL_0c39;
case null: case null:
{
ImGui.TextColored(in colorSecondary, "Helper Status: Unknown (waiting for update...)");
break; break;
} IL_0c39:
IL_04f7:
col2 = col; col2 = col;
text5 = new ImU8String(15, 1); ImGui.SameLine();
text5.AppendLiteral("Helper Status: "); text8 = new ImU8String(2, 1);
text5.AppendFormatted(text4); text8.AppendLiteral("[");
ImGui.TextColored(in col2, text5); text8.AppendFormatted(text7);
text8.AppendLiteral("]");
ImGui.TextColored(in col2, text8);
break; break;
} }
} }
ImGui.Unindent();
}
else if (config.HelperSelection == HelperSelectionMode.Dropdown && availableHelpers.Count == 0)
{
ImGui.Indent();
ImGui.TextColored(new Vector4(1f, 0.8f, 0.2f, 1f), "⚠ No helpers available to select");
ImGui.Unindent();
}
if (ImGui.RadioButton("Manual Input", helperSelection == 2))
{
config.HelperSelection = HelperSelectionMode.ManualInput;
config.PreferredHelper = "";
config.Save();
}
ImGui.SameLine();
DrawInfoIcon("Manual entry (Dungeon invites only - NOT Chauffeur/Following!)");
if (config.HelperSelection == HelperSelectionMode.ManualInput)
{
ImGui.Indent();
ImGui.SetNextItemWidth(250f);
string buf2 = config.ManualHelperName;
if (ImGui.InputText("##ManualHelperInput", ref buf2, 100))
{
config.ManualHelperName = buf2;
config.Save();
}
ImGui.SameLine();
DrawInfoIcon("Format: CharacterName@WorldName");
if (!string.IsNullOrEmpty(config.ManualHelperName))
{
if (config.ManualHelperName.Contains("@"))
{
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.2f, 1f, 0.2f, 1f), "✓");
}
else
{
ImGui.SameLine();
ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "⚠");
}
}
ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.8f, 0.2f, 1f));
ImGui.TextWrapped("⚠ Cannot be used with Chauffeur/Following (requires IPC)");
ImGui.PopStyleColor();
ImGui.Unindent();
}
if (availableHelpers.Count > 0)
{
ImGuiHelpers.ScaledDummy(5f); ImGuiHelpers.ScaledDummy(5f);
ImGui.TextUnformatted("Available Helpers:"); ImGui.TextUnformatted("Available Helpers:");
ChauffeurModeService chauffeurModeService = Plugin.Instance?.GetChauffeurMode(); ChauffeurModeService chauffeurModeService = Plugin.Instance?.GetChauffeurMode();
{
foreach (var item6 in availableHelpers) foreach (var item6 in availableHelpers)
{ {
string item3 = item6.Item1; string item3 = item6.Item1;
ushort item4 = item6.Item2; ushort item4 = item6.Item2;
ExcelSheet<World> excelSheet2 = Plugin.DataManager.GetExcelSheet<World>(); ExcelSheet<World> excelSheet2 = Plugin.DataManager.GetExcelSheet<World>();
string text6 = "Unknown"; string text9 = "Unknown";
if (excelSheet2 != null) if (excelSheet2 != null)
{ {
foreach (World current5 in excelSheet2) foreach (World current4 in excelSheet2)
{ {
if (current5.RowId == item4) if (current4.RowId == item4)
{ {
text6 = current5.Name.ExtractText(); text9 = current4.Name.ExtractText();
break; break;
} }
} }
} }
string text7 = item3 + "@" + text6; string text10 = item3 + "@" + text9;
string text8 = chauffeurModeService?.GetHelperStatus(text7); string text11 = chauffeurModeService?.GetHelperStatus(text10);
ImU8String text9 = new ImU8String(4, 1); ImU8String text12 = new ImU8String(4, 1);
text9.AppendLiteral(" • "); text12.AppendLiteral(" • ");
text9.AppendFormatted(text7); text12.AppendFormatted(text10);
ImGui.TextUnformatted(text9); ImGui.TextUnformatted(text12);
if (text8 != null) if (text11 != null)
{ {
ImGui.SameLine(); ImGui.SameLine();
Vector4 col3 = text8 switch Vector4 col3 = text11 switch
{ {
"Available" => new Vector4(0.2f, 1f, 0.2f, 1f), "Available" => new Vector4(0.2f, 1f, 0.2f, 1f),
"Transporting" => new Vector4(1f, 0.8f, 0f, 1f), "Transporting" => new Vector4(1f, 0.8f, 0f, 1f),
"InDungeon" => new Vector4(1f, 0.3f, 0.3f, 1f), "InDungeon" => new Vector4(1f, 0.3f, 0.3f, 1f),
_ => colorSecondary, _ => colorSecondary,
}; };
ImU8String text10 = new ImU8String(2, 1); ImU8String text13 = new ImU8String(2, 1);
text10.AppendLiteral("["); text13.AppendLiteral("[");
text10.AppendFormatted(text8); text13.AppendFormatted(text11);
text10.AppendLiteral("]"); text13.AppendLiteral("]");
ImGui.TextColored(in col3, text10); ImGui.TextColored(in col3, text13);
} }
} }
return;
} }
} }
ImGui.TextColored(new Vector4(1f, 0.8f, 0.2f, 1f), "No helpers discovered yet");
ImGui.TextWrapped("Make sure helper clients are running with 'I'm a High-Level Helper' enabled.");
}
}, config.IsQuester || config.IsHighLevelHelper); }, config.IsQuester || config.IsHighLevelHelper);
ImGuiHelpers.ScaledDummy(10f); ImGuiHelpers.ScaledDummy(10f);
DrawSettingSection("Chauffeur Mode", delegate DrawSettingSection("Chauffeur Mode", delegate
@ -1291,6 +1464,7 @@ public class NewMainWindow : Window, IDisposable
{ {
config.ChauffeurModeEnabled = v; config.ChauffeurModeEnabled = v;
config.Save(); config.Save();
Plugin.Log.Information("[Multiboxing] Chauffeur Mode: " + (v ? "ENABLED" : "DISABLED"));
} }
DrawInfoIcon("Enable automatic helper summoning for long-distance travel in non-flying zones"); DrawInfoIcon("Enable automatic helper summoning for long-distance travel in non-flying zones");
if (config.ChauffeurModeEnabled) if (config.ChauffeurModeEnabled)
@ -1488,6 +1662,7 @@ public class NewMainWindow : Window, IDisposable
{ {
config.EnableHelperFollowing = v; config.EnableHelperFollowing = v;
config.Save(); config.Save();
Plugin.Log.Information("[Multiboxing] Helper Following (Quester): " + (v ? "ENABLED" : "DISABLED"));
} }
if (string.IsNullOrEmpty(config.AssignedHelperForFollowing)) if (string.IsNullOrEmpty(config.AssignedHelperForFollowing))
{ {
@ -1565,6 +1740,7 @@ public class NewMainWindow : Window, IDisposable
{ {
config.EnableHelperFollowing = v2; config.EnableHelperFollowing = v2;
config.Save(); config.Save();
Plugin.Log.Information("[Multiboxing] Helper Following (Helper): " + (v2 ? "ENABLED" : "DISABLED"));
} }
if (string.IsNullOrEmpty(config.AssignedQuesterForFollowing)) if (string.IsNullOrEmpty(config.AssignedQuesterForFollowing))
{ {
@ -1617,6 +1793,82 @@ public class NewMainWindow : Window, IDisposable
} }
} }
}, config.EnableHelperFollowing); }, config.EnableHelperFollowing);
}
private void DrawSettingsTabFull()
{
ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary);
ImGui.TextUnformatted("Plugin Settings");
ImGui.PopStyleColor();
ImGuiHelpers.ScaledDummy(10f);
using ImRaii.IEndObject child = ImRaii.Child("SettingsScrollArea", new Vector2(0f, 0f), border: false, ImGuiWindowFlags.None);
if (!child.Success)
{
return;
}
Configuration config = plugin.Configuration;
DrawSettingSection("Submarine Management", delegate
{
config.EnableSubmarineCheck = DrawSettingWithInfo("Enable Submarine Monitoring", config.EnableSubmarineCheck, "Automatically monitors submarines and pauses quest rotation when submarines are ready.\nPrevents quest progression while submarines need attention.\nImpact: Rotation will pause when submarines are detected.");
if (ImGui.IsItemDeactivatedAfterEdit())
{
config.Save();
}
if (config.EnableSubmarineCheck)
{
ImGui.Indent();
int v = config.SubmarineCheckInterval;
if (ImGui.SliderInt("Check Interval (seconds)##Submarine", ref v, 30, 300))
{
config.SubmarineCheckInterval = v;
config.Save();
}
DrawInfoIcon("How often to check for submarine status.\nLower values = more frequent checks but higher CPU usage.");
int v2 = config.SubmarineReloginCooldown;
if (ImGui.SliderInt("Cooldown after Relog (seconds)", ref v2, 60, 300))
{
config.SubmarineReloginCooldown = v2;
config.Save();
}
DrawInfoIcon("Time to wait after character switch before checking submarines again.");
int v3 = config.SubmarineWaitTime;
if (ImGui.SliderInt("Wait time before submarine (seconds)", ref v3, 10, 120))
{
config.SubmarineWaitTime = v3;
config.Save();
}
DrawInfoIcon("Delay before starting submarine operations after detection.");
ImGui.Unindent();
}
}, config.EnableSubmarineCheck);
ImGuiHelpers.ScaledDummy(10f);
DrawSettingSection("AutoRetainer Post Process Event Quests", delegate
{
config.RunEventQuestsOnARPostProcess = DrawSettingWithInfo("Run Event Quests on AR Post Process", config.RunEventQuestsOnARPostProcess, "AUTO-DETECTION: Automatically detects and runs active Event Quests when AutoRetainer completes a character.\nEvent Quests are detected via Questionable IPC (same as manual Event Quest tab).\nAll prerequisites will be automatically resolved and executed.\nAutoRetainer will wait until all Event Quests are completed before proceeding.\nImpact: Extends AR post-process time but ensures Event Quests are completed.");
if (ImGui.IsItemDeactivatedAfterEdit())
{
config.Save();
}
if (config.RunEventQuestsOnARPostProcess)
{
ImGui.Indent();
ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.4f, 0.8f, 0.4f, 1f));
ImGui.TextUnformatted("Auto-Detection Enabled");
ImGui.PopStyleColor();
ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary);
ImGui.TextWrapped("Event Quests will be automatically detected from Questionable when AR Post Process starts. No manual configuration needed - just enable this setting and the plugin will handle the rest!");
ImGui.PopStyleColor();
ImGuiHelpers.ScaledDummy(5f);
int v = config.EventQuestPostProcessTimeoutMinutes;
if (ImGui.SliderInt("Timeout (minutes)", ref v, 10, 60))
{
config.EventQuestPostProcessTimeoutMinutes = v;
config.Save();
}
DrawInfoIcon("Maximum time to wait for Event Quests to complete.\nAfter timeout, AR will proceed with next character.");
ImGui.Unindent();
}
}, config.RunEventQuestsOnARPostProcess);
ImGuiHelpers.ScaledDummy(10f); ImGuiHelpers.ScaledDummy(10f);
DrawSettingSection("Movement Monitor", delegate DrawSettingSection("Movement Monitor", delegate
{ {
@ -2529,7 +2781,7 @@ public class NewMainWindow : Window, IDisposable
private void DrawMSQOverall(List<string> characters) private void DrawMSQOverall(List<string> characters)
{ {
using ImRaii.IEndObject table = ImRaii.Table("MSQOverallTable", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY); using ImRaii.IEndObject table = ImRaii.Table("MSQOverallTable", 5, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY);
if (!table.Success) if (!table.Success)
{ {
return; return;
@ -2538,10 +2790,12 @@ public class NewMainWindow : Window, IDisposable
ImGui.TableSetupColumn("MSQ Progress", ImGuiTableColumnFlags.WidthFixed, 120f); ImGui.TableSetupColumn("MSQ Progress", ImGuiTableColumnFlags.WidthFixed, 120f);
ImGui.TableSetupColumn("Current MSQ", ImGuiTableColumnFlags.WidthStretch); ImGui.TableSetupColumn("Current MSQ", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn("Completion %", ImGuiTableColumnFlags.WidthFixed, 100f); ImGui.TableSetupColumn("Completion %", ImGuiTableColumnFlags.WidthFixed, 100f);
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 70f);
ImGui.TableSetupScrollFreeze(0, 1); ImGui.TableSetupScrollFreeze(0, 1);
ImGui.TableHeadersRow(); ImGui.TableHeadersRow();
foreach (string character in characters) for (int charIndex = 0; charIndex < characters.Count; charIndex++)
{ {
string character = characters[charIndex];
if (!characterProgressCache.TryGetValue(character, out CharacterProgressInfo progressInfo)) if (!characterProgressCache.TryGetValue(character, out CharacterProgressInfo progressInfo))
{ {
GetCharacterProgress(character); GetCharacterProgress(character);
@ -2569,6 +2823,27 @@ public class NewMainWindow : Window, IDisposable
overlay.AppendFormatted(percentage, "F1"); overlay.AppendFormatted(percentage, "F1");
overlay.AppendLiteral("%"); overlay.AppendLiteral("%");
ImGui.ProgressBar(fraction, sizeArg, overlay); ImGui.ProgressBar(fraction, sizeArg, overlay);
ImGui.TableNextColumn();
using (ImRaii.PushId(charIndex))
{
ImGui.PushStyleColor(ImGuiCol.Button, colorAccent);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(colorAccent.X * 1.2f, colorAccent.Y * 1.2f, colorAccent.Z * 1.2f, 1f));
if (ImGui.Button("Reset"))
{
questRotationService.ClearCharacterQuestData(character);
characterProgressCache.Remove(character);
log.Information("[MSQProgression] Reset quest data for " + character);
}
ImGui.PopStyleColor(2);
if (ImGui.IsItemHovered())
{
ImU8String tooltip = new ImU8String(85, 1);
tooltip.AppendLiteral("Reset all quest completion data for ");
tooltip.AppendFormatted(character);
tooltip.AppendLiteral(".\nUse this if data was corrupted during rotation.");
ImGui.SetTooltip(tooltip);
}
}
} }
} }

View file

@ -132,6 +132,12 @@ public class Configuration : IPluginConfiguration
public HelperStatus CurrentHelperStatus { get; set; } public HelperStatus CurrentHelperStatus { get; set; }
public HelperSelectionMode HelperSelection { get; set; }
public string ManualHelperName { get; set; } = "";
public bool AlwaysAutoAcceptInvites { get; set; }
public bool EnableHelperFollowing { get; set; } public bool EnableHelperFollowing { get; set; }
public float HelperFollowDistance { get; set; } = 100f; public float HelperFollowDistance { get; set; } = 100f;
@ -142,6 +148,8 @@ public class Configuration : IPluginConfiguration
public string AssignedHelperForFollowing { get; set; } = ""; public string AssignedHelperForFollowing { get; set; } = "";
public bool EnableARRPrimalCheck { get; set; }
public bool EnableSafeWaitBeforeCharacterSwitch { get; set; } public bool EnableSafeWaitBeforeCharacterSwitch { get; set; }
public bool EnableSafeWaitAfterCharacterSwitch { get; set; } public bool EnableSafeWaitAfterCharacterSwitch { get; set; }
@ -187,6 +195,14 @@ public class Configuration : IPluginConfiguration
} }
}; };
public bool EnableLANHelpers { get; set; }
public int LANServerPort { get; set; } = 47788;
public List<string> LANHelperIPs { get; set; } = new List<string>();
public bool StartLANServer { get; set; }
public void Save() public void Save()
{ {
Plugin.PluginInterface.SavePluginConfig(this); Plugin.PluginInterface.SavePluginConfig(this);

View file

@ -0,0 +1,8 @@
namespace QuestionableCompanion;
public enum HelperSelectionMode
{
Auto,
Dropdown,
ManualInput
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks;
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Command; using Dalamud.Game.Command;
using Dalamud.Interface.Windowing; using Dalamud.Interface.Windowing;
@ -129,6 +130,12 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
private ErrorRecoveryService ErrorRecoveryService { get; init; } private ErrorRecoveryService ErrorRecoveryService { get; init; }
private LANHelperServer? LANHelperServer { get; set; }
private LANHelperClient? LANHelperClient { get; set; }
private ARRTrialAutomationService ARRTrialAutomation { get; init; }
private ConfigWindow ConfigWindow { get; init; } private ConfigWindow ConfigWindow { get; init; }
private NewMainWindow NewMainWindow { get; init; } private NewMainWindow NewMainWindow { get; init; }
@ -178,17 +185,67 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
DeathHandler = new DeathHandlerService(Condition, Log, ClientState, CommandManager, Framework, Configuration, GameGui, DataManager); DeathHandler = new DeathHandlerService(Condition, Log, ClientState, CommandManager, Framework, Configuration, GameGui, DataManager);
Log.Debug("[Plugin] Initializing MemoryHelper..."); Log.Debug("[Plugin] Initializing MemoryHelper...");
MemoryHelper = new MemoryHelper(Log, GameInterop); MemoryHelper = new MemoryHelper(Log, GameInterop);
if (Configuration.EnableLANHelpers)
{
Log.Information("[Plugin] LAN Helper System ENABLED - Initializing...");
LANHelperClient = new LANHelperClient(Log, ClientState, Framework, Configuration);
if (Configuration.StartLANServer)
{
Log.Information("[Plugin] Starting LAN Helper Server...");
LANHelperServer = new LANHelperServer(Log, ClientState, Framework, Configuration, PartyInviteAutoAccept, CommandManager, this);
LANHelperServer.Start();
}
Task.Run(async delegate
{
await Task.Delay(2000);
await LANHelperClient.Initialize();
});
}
else
{
Log.Debug("[Plugin] LAN Helper System disabled");
}
Log.Debug("[Plugin] Initializing HelperManager..."); Log.Debug("[Plugin] Initializing HelperManager...");
HelperManager = new HelperManager(Configuration, Log, CommandManager, Condition, ClientState, Framework, PartyInviteService, MultiClientIPC, CrossProcessIPC, PartyInviteAutoAccept, MemoryHelper); HelperManager = new HelperManager(Configuration, Log, CommandManager, Condition, ClientState, Framework, PartyInviteService, MultiClientIPC, CrossProcessIPC, PartyInviteAutoAccept, MemoryHelper, LANHelperClient, PartyList);
Log.Debug("[Plugin] Initializing DungeonAutomation..."); Log.Debug("[Plugin] Initializing DungeonAutomation...");
DungeonAutomation = new DungeonAutomationService(Condition, Log, ClientState, CommandManager, Framework, GameGui, Configuration, HelperManager, MemoryHelper, QuestionableIPC); DungeonAutomation = new DungeonAutomationService(Condition, Log, ClientState, CommandManager, Framework, GameGui, Configuration, HelperManager, MemoryHelper, QuestionableIPC, CrossProcessIPC, MultiClientIPC);
Log.Debug("[Plugin] Initializing StepsOfFaithHandler..."); Log.Debug("[Plugin] Initializing StepsOfFaithHandler...");
StepsOfFaithHandler = new StepsOfFaithHandler(Condition, Log, ClientState, CommandManager, Framework, Configuration); StepsOfFaithHandler = new StepsOfFaithHandler(Condition, Log, ClientState, CommandManager, Framework, Configuration);
Log.Debug("[Plugin] Initializing MSQProgressionService..."); Log.Debug("[Plugin] Initializing MSQProgressionService...");
MSQProgressionService = new MSQProgressionService(DataManager, Log, QuestDetection, ObjectTable, Framework); MSQProgressionService = new MSQProgressionService(DataManager, Log, QuestDetection, ObjectTable, Framework);
Log.Debug("[Plugin] Initializing ChauffeurMode..."); Log.Debug("[Plugin] Initializing ChauffeurMode...");
ChauffeurMode = new ChauffeurModeService(Configuration, Log, ClientState, Condition, Framework, CommandManager, DataManager, PartyList, ObjectTable, QuestionableIPC, CrossProcessIPC, PartyInviteService, PartyInviteAutoAccept, PluginInterface, MemoryHelper, MovementMonitor); ChauffeurMode = new ChauffeurModeService(Configuration, Log, ClientState, Condition, Framework, CommandManager, DataManager, PartyList, ObjectTable, QuestionableIPC, CrossProcessIPC, PartyInviteService, PartyInviteAutoAccept, PluginInterface, MemoryHelper, MovementMonitor);
Log.Debug("[Plugin] Initializing ARRTrialAutomation...");
ARRTrialAutomation = new ARRTrialAutomationService(Log, Framework, CommandManager, ChatGui, Configuration, QuestionableIPC, SubmarineManager, HelperManager, PartyList, Condition, MemoryHelper);
QuestDetection.QuestCompleted += delegate(uint questId, string questName)
{
if (questId == 89)
{
Log.Information("[Plugin] Quest 89 completed - triggering ARR Primal check");
ARRTrialAutomation.OnTriggerQuestComplete();
}
ARRTrialAutomation.OnQuestComplete(questId);
};
Log.Debug("[Plugin] ARRTrialAutomation wired to QuestDetection.QuestCompleted");
MovementMonitor.SetChauffeurMode(ChauffeurMode); MovementMonitor.SetChauffeurMode(ChauffeurMode);
if (LANHelperClient != null)
{
LANHelperClient.OnChauffeurMessageReceived += delegate(object? sender, LANHelperClient.ChauffeurMessageEventArgs args)
{
Framework.RunOnFrameworkThread(delegate
{
if (args.Type == LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT)
{
ChauffeurMode.OnChauffeurMountReady(args.Data.QuesterName, args.Data.QuesterWorldId);
}
else if (args.Type == LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST)
{
ChauffeurMode.OnChauffeurArrived(args.Data.QuesterName, args.Data.QuesterWorldId);
}
});
};
Log.Debug("[Plugin] LANHelperClient Chauffeur events wired to ChauffeurMode");
}
Log.Debug("[Plugin] Initializing AR Post Process Event Quest Service..."); Log.Debug("[Plugin] Initializing AR Post Process Event Quest Service...");
EventQuestResolver eventQuestResolver = new EventQuestResolver(DataManager, Log); EventQuestResolver eventQuestResolver = new EventQuestResolver(DataManager, Log);
ARPostProcessService = new ARPostProcessEventQuestService(PluginInterface, QuestionableIPC, eventQuestResolver, Configuration, Log, Framework, CommandManager, LifestreamIPC); ARPostProcessService = new ARPostProcessEventQuestService(PluginInterface, QuestionableIPC, eventQuestResolver, Configuration, Log, Framework, CommandManager, LifestreamIPC);
@ -198,7 +255,7 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
AlliedSocietyRotationService = new AlliedSocietyRotationService(QuestionableIPC, AlliedSocietyDatabase, AlliedSocietyQuestSelector, AutoRetainerIPC, Configuration, Log, Framework, CommandManager, Condition, ClientState); AlliedSocietyRotationService = new AlliedSocietyRotationService(QuestionableIPC, AlliedSocietyDatabase, AlliedSocietyQuestSelector, AutoRetainerIPC, Configuration, Log, Framework, CommandManager, Condition, ClientState);
AlliedSocietyPriorityWindow = new AlliedSocietyPriorityWindow(Configuration, AlliedSocietyDatabase); AlliedSocietyPriorityWindow = new AlliedSocietyPriorityWindow(Configuration, AlliedSocietyDatabase);
Log.Debug("[Plugin] Initializing Error Recovery Service..."); Log.Debug("[Plugin] Initializing Error Recovery Service...");
ErrorRecoveryService = new ErrorRecoveryService(Log, GameInterop, ClientState, AutoRetainerIPC, Framework, GameGui); ErrorRecoveryService = new ErrorRecoveryService(Log, GameInterop, ClientState, Framework, GameGui, AutoRetainerIPC);
QuestRotationService.SetErrorRecoveryService(ErrorRecoveryService); QuestRotationService.SetErrorRecoveryService(ErrorRecoveryService);
MultiClientIPC.OnChatMessageReceived += OnMultiClientChatReceived; MultiClientIPC.OnChatMessageReceived += OnMultiClientChatReceived;
CrossProcessIPC.OnChatMessageReceived += OnMultiClientChatReceived; CrossProcessIPC.OnChatMessageReceived += OnMultiClientChatReceived;
@ -211,6 +268,7 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
QuestRotationService.SetDeathHandler(DeathHandler); QuestRotationService.SetDeathHandler(DeathHandler);
QuestRotationService.SetDungeonAutomation(DungeonAutomation); QuestRotationService.SetDungeonAutomation(DungeonAutomation);
QuestRotationService.SetStepsOfFaithHandler(StepsOfFaithHandler); QuestRotationService.SetStepsOfFaithHandler(StepsOfFaithHandler);
DungeonAutomation.SetRotationActiveChecker(() => QuestRotationService.IsRotationActive);
Log.Debug("[Plugin] Initializing DataCenterService..."); Log.Debug("[Plugin] Initializing DataCenterService...");
DataCenterService dataCenterService = new DataCenterService(DataManager, Log); DataCenterService dataCenterService = new DataCenterService(DataManager, Log);
Log.Debug($"[Plugin] Loaded {Configuration.StopPoints?.Count ?? 0} stop points from config"); Log.Debug($"[Plugin] Loaded {Configuration.StopPoints?.Count ?? 0} stop points from config");
@ -295,6 +353,35 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
} }
} }
public LANHelperClient? GetLANHelperClient()
{
return LANHelperClient;
}
public void ToggleLANServer(bool enable)
{
if (enable)
{
if (LANHelperServer == null)
{
Log.Information("[Plugin] Starting LAN Helper Server (Runtime)...");
LANHelperServer = new LANHelperServer(Log, ClientState, Framework, Configuration, PartyInviteAutoAccept, CommandManager, this);
LANHelperServer.Start();
}
else if (!LANHelperServer.IsRunning)
{
LANHelperServer.Start();
}
}
else if (LANHelperServer != null)
{
Log.Information("[Plugin] Stopping LAN Helper Server (Runtime)...");
LANHelperServer.Stop();
LANHelperServer.Dispose();
LANHelperServer = null;
}
}
private void SaveEventQuestCompletionData() private void SaveEventQuestCompletionData()
{ {
if (EventQuestService != null) if (EventQuestService != null)
@ -401,6 +488,8 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
QuestTrackingService?.Dispose(); QuestTrackingService?.Dispose();
QuestDetection?.Dispose(); QuestDetection?.Dispose();
HelperManager?.Dispose(); HelperManager?.Dispose();
LANHelperServer?.Dispose();
LANHelperClient?.Dispose();
PartyInviteAutoAccept?.Dispose(); PartyInviteAutoAccept?.Dispose();
CrossProcessIPC?.Dispose(); CrossProcessIPC?.Dispose();
MultiClientIPC?.Dispose(); MultiClientIPC?.Dispose();
@ -456,13 +545,15 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
private void OnCommand(string command, string args) private void OnCommand(string command, string args)
{ {
string argLower = args.Trim().ToLower(); string argLower = args.Trim().ToLower();
if (argLower == "dbg") switch (argLower)
{ {
case "arrtrials":
ARRTrialAutomation.StartTrialChain();
return;
case "dbg":
DebugWindow.Toggle(); DebugWindow.Toggle();
return; return;
} case "task":
if (argLower == "task")
{
TestGetCurrentTask(); TestGetCurrentTask();
return; return;
} }
@ -701,18 +792,69 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
Log.Information("========================================"); Log.Information("========================================");
return; return;
} }
string modeText = Configuration.HelperSelection switch
{
HelperSelectionMode.Auto => "Auto (First Available)",
HelperSelectionMode.Dropdown => "Dropdown (Select Specific Helper)",
HelperSelectionMode.ManualInput => "Manual Input",
_ => "Unknown",
};
Log.Information("[TEST] Current Selection Mode: " + modeText);
Log.Information("[TEST] ----------------------------------------");
if (Configuration.HelperSelection == HelperSelectionMode.ManualInput)
{
if (string.IsNullOrEmpty(Configuration.ManualHelperName))
{
Log.Error("[TEST] Manual Input mode selected, but no helper name configured!");
Log.Error("[TEST] Please configure a helper name in Settings (format: CharacterName@WorldName)");
}
else
{
Log.Information("[TEST] Manual Helper: " + Configuration.ManualHelperName);
Log.Information("[TEST] This helper will be invited directly (no IPC wait required)");
}
}
else if (Configuration.HelperSelection == HelperSelectionMode.Dropdown)
{
List<(string, ushort)> availableHelpers = HelperManager.GetAvailableHelpers(); List<(string, ushort)> availableHelpers = HelperManager.GetAvailableHelpers();
if (availableHelpers.Count == 0) if (availableHelpers.Count == 0)
{
Log.Warning("[TEST] No helpers discovered via IPC!");
Log.Warning("[TEST] Make sure helper clients are running with 'I'm a High-Level Helper' enabled");
}
else
{
Log.Information($"[TEST] Auto-discovered helpers: {availableHelpers.Count}");
foreach (var (name, worldId) in availableHelpers)
{
Log.Information($"[TEST] - {name}@{worldId}");
}
}
if (string.IsNullOrEmpty(Configuration.PreferredHelper))
{
Log.Warning("[TEST] Dropdown mode selected, but no specific helper chosen!");
Log.Warning("[TEST] Please select a helper from the dropdown in Settings");
}
else
{
Log.Information("[TEST] Selected Helper: " + Configuration.PreferredHelper);
}
}
else
{
List<(string, ushort)> availableHelpers2 = HelperManager.GetAvailableHelpers();
if (availableHelpers2.Count == 0)
{ {
Log.Error("[TEST] No helpers discovered via IPC!"); Log.Error("[TEST] No helpers discovered via IPC!");
Log.Error("[TEST] Make sure helper clients are running with 'I'm a High-Level Helper' enabled"); Log.Error("[TEST] Make sure helper clients are running with 'I'm a High-Level Helper' enabled");
Log.Information("========================================"); Log.Information("========================================");
return; return;
} }
Log.Information($"[TEST] Auto-discovered helpers: {availableHelpers.Count}"); Log.Information($"[TEST] Auto-discovered helpers: {availableHelpers2.Count}");
foreach (var (name, worldId) in availableHelpers) foreach (var (name2, worldId2) in availableHelpers2)
{ {
Log.Information($"[TEST] - {name}@{worldId}"); Log.Information($"[TEST] - {name2}@{worldId2}");
}
} }
Log.Information("[TEST] Invoking HelperManager.InviteHelpers()..."); Log.Information("[TEST] Invoking HelperManager.InviteHelpers()...");
HelperManager.InviteHelpers(); HelperManager.InviteHelpers();
@ -1115,6 +1257,11 @@ public sealed class Plugin : IDalamudPlugin, IDisposable
return HelperManager; return HelperManager;
} }
public LANHelperServer? GetLANHelperServer()
{
return LANHelperServer;
}
public DungeonAutomationService? GetDungeonAutomation() public DungeonAutomationService? GetDungeonAutomation()
{ {
return DungeonAutomation; return DungeonAutomation;